diff --git a/README.md b/README.md
index efca075042..e09b4d86a5 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
# osu!
[![Build status](https://ci.appveyor.com/api/projects/status/u2p01nx7l6og8buh?svg=true)](https://ci.appveyor.com/project/peppy/osu)
-[![GitHub release](https://img.shields.io/github/release/ppy/osu.svg)]()
+[![GitHub release](https://img.shields.io/github/release/ppy/osu.svg)](https://github.com/ppy/osu/releases/latest)
[![CodeFactor](https://www.codefactor.io/repository/github/ppy/osu/badge)](https://www.codefactor.io/repository/github/ppy/osu)
[![dev chat](https://discordapp.com/api/guilds/188630481301012481/widget.png?style=shield)](https://discord.gg/ppy)
diff --git a/osu.Android.props b/osu.Android.props
index 9ad5946311..7060e88026 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -52,6 +52,6 @@
-
+
diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj
index 4554f8b83a..3e0f0cb7f6 100644
--- a/osu.Desktop/osu.Desktop.csproj
+++ b/osu.Desktop/osu.Desktop.csproj
@@ -24,16 +24,13 @@
+
-
+
-
-
-
-
-
+
diff --git a/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs b/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs
index 32e8ab5da7..64ded8e94f 100644
--- a/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs
+++ b/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs
@@ -45,6 +45,11 @@ namespace osu.Game.Rulesets.Catch.Replays
float positionChange = Math.Abs(lastPosition - h.EffectiveX);
double timeAvailable = h.StartTime - lastTime;
+ if (timeAvailable < 0)
+ {
+ return;
+ }
+
// So we can either make it there without a dash or not.
// If positionChange is 0, we don't need to move, so speedRequired should also be 0 (could be NaN if timeAvailable is 0 too)
// The case where positionChange > 0 and timeAvailable == 0 results in PositiveInfinity which provides expected beheaviour.
diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/BorderPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Default/BorderPiece.cs
index 7308d6b499..8d8ee49af7 100644
--- a/osu.Game.Rulesets.Catch/Skinning/Default/BorderPiece.cs
+++ b/osu.Game.Rulesets.Catch/Skinning/Default/BorderPiece.cs
@@ -29,4 +29,3 @@ namespace osu.Game.Rulesets.Catch.Skinning.Default
}
}
}
-
diff --git a/osu.Game.Rulesets.Catch/Skinning/Default/HyperBorderPiece.cs b/osu.Game.Rulesets.Catch/Skinning/Default/HyperBorderPiece.cs
index d160956a6e..c8895f32f4 100644
--- a/osu.Game.Rulesets.Catch/Skinning/Default/HyperBorderPiece.cs
+++ b/osu.Game.Rulesets.Catch/Skinning/Default/HyperBorderPiece.cs
@@ -19,4 +19,3 @@ namespace osu.Game.Rulesets.Catch.Skinning.Default
}
}
}
-
diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModConstantSpeed.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModConstantSpeed.cs
new file mode 100644
index 0000000000..60363aaeef
--- /dev/null
+++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModConstantSpeed.cs
@@ -0,0 +1,31 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Testing;
+using osu.Game.Rulesets.Mania.Mods;
+using osu.Game.Rulesets.Mania.Objects.Drawables;
+using osu.Game.Rulesets.UI.Scrolling;
+using osu.Game.Rulesets.UI.Scrolling.Algorithms;
+using osu.Game.Tests.Visual;
+
+namespace osu.Game.Rulesets.Mania.Tests.Mods
+{
+ public class TestSceneManiaModConstantSpeed : ModTestScene
+ {
+ protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset();
+
+ [Test]
+ public void TestConstantScroll() => CreateModTest(new ModTestData
+ {
+ Mod = new ManiaModConstantSpeed(),
+ PassCondition = () =>
+ {
+ var hitObject = Player.ChildrenOfType().FirstOrDefault();
+ return hitObject?.Dependencies.Get().Algorithm is ConstantScrollAlgorithm;
+ }
+ });
+ }
+}
diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs
index 59c766fd84..4c729fef83 100644
--- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs
+++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs
@@ -238,6 +238,7 @@ namespace osu.Game.Rulesets.Mania
new ManiaModMirror(),
new ManiaModDifficultyAdjust(),
new ManiaModInvert(),
+ new ManiaModConstantSpeed()
};
case ModType.Automation:
diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs
new file mode 100644
index 0000000000..078394b1d8
--- /dev/null
+++ b/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs
@@ -0,0 +1,35 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Graphics.Sprites;
+using osu.Game.Configuration;
+using osu.Game.Rulesets.Mania.Objects;
+using osu.Game.Rulesets.Mania.UI;
+using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.UI;
+
+namespace osu.Game.Rulesets.Mania.Mods
+{
+ public class ManiaModConstantSpeed : Mod, IApplicableToDrawableRuleset
+ {
+ public override string Name => "Constant Speed";
+
+ public override string Acronym => "CS";
+
+ public override double ScoreMultiplier => 1;
+
+ public override string Description => "No more tricky speed changes!";
+
+ public override IconUsage? Icon => FontAwesome.Solid.Equals;
+
+ public override ModType Type => ModType.Conversion;
+
+ public override bool Ranked => false;
+
+ public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset)
+ {
+ var maniaRuleset = (DrawableManiaRuleset)drawableRuleset;
+ maniaRuleset.ScrollMethod = ScrollVisualisationMethod.Constant;
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs
index 941ac9816c..4ee060e91e 100644
--- a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs
+++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs
@@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
@@ -11,6 +12,7 @@ using osu.Framework.Graphics;
using osu.Framework.Input;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Configuration;
using osu.Game.Input.Handlers;
using osu.Game.Replays;
using osu.Game.Rulesets.Mania.Beatmaps;
@@ -49,6 +51,22 @@ namespace osu.Game.Rulesets.Mania.UI
protected new ManiaRulesetConfigManager Config => (ManiaRulesetConfigManager)base.Config;
+ public ScrollVisualisationMethod ScrollMethod
+ {
+ get => scrollMethod;
+ set
+ {
+ if (IsLoaded)
+ throw new InvalidOperationException($"Can't alter {nameof(ScrollMethod)} after ruleset is already loaded");
+
+ scrollMethod = value;
+ }
+ }
+
+ private ScrollVisualisationMethod scrollMethod = ScrollVisualisationMethod.Sequential;
+
+ protected override ScrollVisualisationMethod VisualisationMethod => scrollMethod;
+
private readonly Bindable configDirection = new Bindable();
private readonly Bindable configTimeRange = new BindableDouble();
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOutOfOrderHits.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs
similarity index 99%
rename from osu.Game.Rulesets.Osu.Tests/TestSceneOutOfOrderHits.cs
rename to osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs
index 296b421a11..177a4f50a1 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneOutOfOrderHits.cs
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs
@@ -25,7 +25,7 @@ using osuTK;
namespace osu.Game.Rulesets.Osu.Tests
{
- public class TestSceneOutOfOrderHits : RateAdjustedBeatmapTestScene
+ public class TestSceneStartTimeOrderedHitPolicy : RateAdjustedBeatmapTestScene
{
private const double early_miss_window = 1000; // time after -1000 to -500 is considered a miss
private const double late_miss_window = 500; // time after +500 is considered a miss
@@ -169,7 +169,7 @@ namespace osu.Game.Rulesets.Osu.Tests
addJudgementAssert(hitObjects[0], HitResult.Great);
addJudgementAssert(hitObjects[1], HitResult.Great);
addJudgementOffsetAssert(hitObjects[0], -200); // time_first_circle - 200
- addJudgementOffsetAssert(hitObjects[0], -200); // time_second_circle - first_circle_time - 100
+ addJudgementOffsetAssert(hitObjects[1], -200); // time_second_circle - first_circle_time - 100
}
///
diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs
index 56aedebed3..c58f703bef 100644
--- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs
+++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSpinner.cs
@@ -243,7 +243,14 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
base.Update();
if (HandleUserInput)
- RotationTracker.Tracking = !Result.HasResult && (OsuActionInputManager?.PressedActions.Any(x => x == OsuAction.LeftButton || x == OsuAction.RightButton) ?? false);
+ {
+ bool isValidSpinningTime = Time.Current >= HitObject.StartTime && Time.Current <= HitObject.EndTime;
+ bool correctButtonPressed = (OsuActionInputManager?.PressedActions.Any(x => x == OsuAction.LeftButton || x == OsuAction.RightButton) ?? false);
+
+ RotationTracker.Tracking = !Result.HasResult
+ && correctButtonPressed
+ && isValidSpinningTime;
+ }
if (spinningSample != null && spinnerFrequencyModulate)
spinningSample.Frequency.Value = spinning_sample_modulated_base_frequency + Progress;
@@ -255,6 +262,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
if (!SpmCounter.IsPresent && RotationTracker.Tracking)
SpmCounter.FadeIn(HitObject.TimeFadeIn);
+
SpmCounter.SetRotation(Result.RateAdjustedRotation);
updateBonusScore();
diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerSpmCounter.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerSpmCounter.cs
index e5952ecf97..69355f624b 100644
--- a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerSpmCounter.cs
+++ b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerSpmCounter.cs
@@ -4,16 +4,21 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Utils;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
+using osu.Game.Rulesets.Objects.Drawables;
namespace osu.Game.Rulesets.Osu.Skinning.Default
{
public class SpinnerSpmCounter : Container
{
+ [Resolved]
+ private DrawableHitObject drawableSpinner { get; set; }
+
private readonly OsuSpriteText spmText;
public SpinnerSpmCounter()
@@ -38,6 +43,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
};
}
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+ drawableSpinner.HitObjectApplied += resetState;
+ }
+
private double spm;
public double SpinsPerMinute
@@ -82,5 +93,19 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
records.Enqueue(new RotationRecord { Rotation = currentRotation, Time = Time.Current });
}
+
+ private void resetState(DrawableHitObject hitObject)
+ {
+ SpinsPerMinute = 0;
+ records.Clear();
+ }
+
+ protected override void Dispose(bool isDisposing)
+ {
+ base.Dispose(isDisposing);
+
+ if (drawableSpinner != null)
+ drawableSpinner.HitObjectApplied -= resetState;
+ }
}
}
diff --git a/osu.Game.Rulesets.Osu/UI/IHitPolicy.cs b/osu.Game.Rulesets.Osu/UI/IHitPolicy.cs
new file mode 100644
index 0000000000..5d8ea035a7
--- /dev/null
+++ b/osu.Game.Rulesets.Osu/UI/IHitPolicy.cs
@@ -0,0 +1,31 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Drawables;
+using osu.Game.Rulesets.UI;
+
+namespace osu.Game.Rulesets.Osu.UI
+{
+ public interface IHitPolicy
+ {
+ ///
+ /// The containing the s which this applies to.
+ ///
+ IHitObjectContainer HitObjectContainer { set; }
+
+ ///
+ /// Determines whether a can be hit at a point in time.
+ ///
+ /// The to check.
+ /// The time to check.
+ /// Whether can be hit at the given .
+ bool IsHittable(DrawableHitObject hitObject, double time);
+
+ ///
+ /// Handles a being hit.
+ ///
+ /// The that was hit.
+ void HandleHit(DrawableHitObject hitObject);
+ }
+}
diff --git a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs
index 975b444699..e085714265 100644
--- a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs
+++ b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs
@@ -31,7 +31,7 @@ namespace osu.Game.Rulesets.Osu.UI
private readonly ProxyContainer spinnerProxies;
private readonly JudgementContainer judgementLayer;
private readonly FollowPointRenderer followPoints;
- private readonly OrderedHitPolicy hitPolicy;
+ private readonly StartTimeOrderedHitPolicy hitPolicy;
public static readonly Vector2 BASE_SIZE = new Vector2(512, 384);
@@ -54,7 +54,7 @@ namespace osu.Game.Rulesets.Osu.UI
approachCircles = new ProxyContainer { RelativeSizeAxes = Axes.Both },
};
- hitPolicy = new OrderedHitPolicy(HitObjectContainer);
+ hitPolicy = new StartTimeOrderedHitPolicy { HitObjectContainer = HitObjectContainer };
var hitWindows = new OsuHitWindows();
diff --git a/osu.Game.Rulesets.Osu/UI/OrderedHitPolicy.cs b/osu.Game.Rulesets.Osu/UI/StartTimeOrderedHitPolicy.cs
similarity index 76%
rename from osu.Game.Rulesets.Osu/UI/OrderedHitPolicy.cs
rename to osu.Game.Rulesets.Osu/UI/StartTimeOrderedHitPolicy.cs
index 8e4f81347d..0173156246 100644
--- a/osu.Game.Rulesets.Osu/UI/OrderedHitPolicy.cs
+++ b/osu.Game.Rulesets.Osu/UI/StartTimeOrderedHitPolicy.cs
@@ -11,28 +11,17 @@ using osu.Game.Rulesets.UI;
namespace osu.Game.Rulesets.Osu.UI
{
///
- /// Ensures that s are hit in-order. Affectionately known as "note lock".
+ /// Ensures that s are hit in-order of their start times. Affectionately known as "note lock".
/// If a is hit out of order:
///
/// - The hit is blocked if it occurred earlier than the previous 's start time.
/// - The hit causes all previous s to missed otherwise.
///
///
- public class OrderedHitPolicy
+ public class StartTimeOrderedHitPolicy : IHitPolicy
{
- private readonly HitObjectContainer hitObjectContainer;
+ public IHitObjectContainer HitObjectContainer { get; set; }
- public OrderedHitPolicy(HitObjectContainer hitObjectContainer)
- {
- this.hitObjectContainer = hitObjectContainer;
- }
-
- ///
- /// Determines whether a can be hit at a point in time.
- ///
- /// The to check.
- /// The time to check.
- /// Whether can be hit at the given .
public bool IsHittable(DrawableHitObject hitObject, double time)
{
DrawableHitObject blockingObject = null;
@@ -54,10 +43,6 @@ namespace osu.Game.Rulesets.Osu.UI
return blockingObject.Judged || time >= blockingObject.HitObject.StartTime;
}
- ///
- /// Handles a being hit to potentially miss all earlier s.
- ///
- /// The that was hit.
public void HandleHit(DrawableHitObject hitObject)
{
// Hitobjects which themselves don't block future hitobjects don't cause misses (e.g. slider ticks, spinners).
@@ -67,6 +52,7 @@ namespace osu.Game.Rulesets.Osu.UI
if (!IsHittable(hitObject, hitObject.HitObject.StartTime + hitObject.Result.TimeOffset))
throw new InvalidOperationException($"A {hitObject} was hit before it became hittable!");
+ // Miss all hitobjects prior to the hit one.
foreach (var obj in enumerateHitObjectsUpTo(hitObject.HitObject.StartTime))
{
if (obj.Judged)
@@ -86,7 +72,7 @@ namespace osu.Game.Rulesets.Osu.UI
private IEnumerable enumerateHitObjectsUpTo(double targetTime)
{
- foreach (var obj in hitObjectContainer.AliveObjects)
+ foreach (var obj in HitObjectContainer.AliveObjects)
{
if (obj.HitObject.StartTime >= targetTime)
yield break;
diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs
index 56a73ad7df..4006652bd5 100644
--- a/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs
+++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs
@@ -1,11 +1,45 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System.Linq;
+using osu.Framework.Bindables;
+using osu.Game.Beatmaps;
+using osu.Game.Configuration;
using osu.Game.Rulesets.Mods;
namespace osu.Game.Rulesets.Taiko.Mods
{
public class TaikoModDifficultyAdjust : ModDifficultyAdjust
{
+ [SettingSource("Scroll Speed", "Adjust a beatmap's set scroll speed", LAST_SETTING_ORDER + 1)]
+ public BindableNumber ScrollSpeed { get; } = new BindableFloat
+ {
+ Precision = 0.05f,
+ MinValue = 0.25f,
+ MaxValue = 4,
+ Default = 1,
+ Value = 1,
+ };
+
+ public override string SettingDescription
+ {
+ get
+ {
+ string scrollSpeed = ScrollSpeed.IsDefault ? string.Empty : $"Scroll x{ScrollSpeed.Value:N1}";
+
+ return string.Join(", ", new[]
+ {
+ base.SettingDescription,
+ scrollSpeed
+ }.Where(s => !string.IsNullOrEmpty(s)));
+ }
+ }
+
+ protected override void ApplySettings(BeatmapDifficulty difficulty)
+ {
+ base.ApplySettings(difficulty);
+
+ ApplySetting(ScrollSpeed, scroll => difficulty.SliderMultiplier *= scroll);
+ }
}
}
diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs
index d1ad4c9d8d..ad6fdf59e2 100644
--- a/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs
+++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModEasy.cs
@@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mods;
namespace osu.Game.Rulesets.Taiko.Mods
@@ -8,5 +9,16 @@ namespace osu.Game.Rulesets.Taiko.Mods
public class TaikoModEasy : ModEasy
{
public override string Description => @"Beats move slower, and less accuracy required!";
+
+ ///
+ /// Multiplier factor added to the scrolling speed.
+ ///
+ private const double slider_multiplier = 0.8;
+
+ public override void ApplyToDifficulty(BeatmapDifficulty difficulty)
+ {
+ base.ApplyToDifficulty(difficulty);
+ difficulty.SliderMultiplier *= slider_multiplier;
+ }
}
}
diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModHardRock.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModHardRock.cs
index 49d225cdb5..a5a8b75f80 100644
--- a/osu.Game.Rulesets.Taiko/Mods/TaikoModHardRock.cs
+++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModHardRock.cs
@@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mods;
namespace osu.Game.Rulesets.Taiko.Mods
@@ -9,5 +10,21 @@ namespace osu.Game.Rulesets.Taiko.Mods
{
public override double ScoreMultiplier => 1.06;
public override bool Ranked => true;
+
+ ///
+ /// Multiplier factor added to the scrolling speed.
+ ///
+ ///
+ /// This factor is made up of two parts: the base part (1.4) and the aspect ratio adjustment (4/3).
+ /// Stable applies the latter by dividing the width of the user's display by the width of a display with the same height, but 4:3 aspect ratio.
+ /// TODO: Revisit if taiko playfield ever changes away from a hard-coded 16:9 (see https://github.com/ppy/osu/issues/5685).
+ ///
+ private const double slider_multiplier = 1.4 * 4 / 3;
+
+ public override void ApplyToDifficulty(BeatmapDifficulty difficulty)
+ {
+ base.ApplyToDifficulty(difficulty);
+ difficulty.SliderMultiplier *= slider_multiplier;
+ }
}
}
diff --git a/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj b/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj
index c44ed69c4d..19e36a63f1 100644
--- a/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj
+++ b/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj
@@ -69,5 +69,9 @@
osu.Game
+
+
+
+
\ No newline at end of file
diff --git a/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj b/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj
index ca68369ebb..67b2298f4c 100644
--- a/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj
+++ b/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj
@@ -45,6 +45,7 @@
+
\ No newline at end of file
diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs
index 7bee580863..bcde899789 100644
--- a/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs
+++ b/osu.Game.Tests/Beatmaps/Formats/LegacyStoryboardDecoderTest.cs
@@ -129,5 +129,25 @@ namespace osu.Game.Tests.Beatmaps.Formats
Assert.AreEqual(3456, ((StoryboardSprite)background.Elements.Single()).InitialPosition.X);
}
}
+
+ [Test]
+ public void TestDecodeOutOfRangeLoopAnimationType()
+ {
+ var decoder = new LegacyStoryboardDecoder();
+
+ using (var resStream = TestResources.OpenResource("animation-types.osb"))
+ using (var stream = new LineBufferedReader(resStream))
+ {
+ var storyboard = decoder.Decode(stream);
+
+ StoryboardLayer foreground = storyboard.Layers.Single(l => l.Depth == 0);
+ Assert.AreEqual(AnimationLoopType.LoopForever, ((StoryboardAnimation)foreground.Elements[0]).LoopType);
+ Assert.AreEqual(AnimationLoopType.LoopOnce, ((StoryboardAnimation)foreground.Elements[1]).LoopType);
+ Assert.AreEqual(AnimationLoopType.LoopForever, ((StoryboardAnimation)foreground.Elements[2]).LoopType);
+ Assert.AreEqual(AnimationLoopType.LoopOnce, ((StoryboardAnimation)foreground.Elements[3]).LoopType);
+ Assert.AreEqual(AnimationLoopType.LoopForever, ((StoryboardAnimation)foreground.Elements[4]).LoopType);
+ Assert.AreEqual(AnimationLoopType.LoopForever, ((StoryboardAnimation)foreground.Elements[5]).LoopType);
+ }
+ }
}
}
diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs
new file mode 100644
index 0000000000..7dcaabca3d
--- /dev/null
+++ b/osu.Game.Tests/Mods/ModUtilsTest.cs
@@ -0,0 +1,160 @@
+// 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 Moq;
+using NUnit.Framework;
+using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.Osu.Mods;
+using osu.Game.Utils;
+
+namespace osu.Game.Tests.Mods
+{
+ [TestFixture]
+ public class ModUtilsTest
+ {
+ [Test]
+ public void TestModIsCompatibleByItself()
+ {
+ var mod = new Mock();
+ Assert.That(ModUtils.CheckCompatibleSet(new[] { mod.Object }));
+ }
+
+ [Test]
+ public void TestIncompatibleThroughTopLevel()
+ {
+ var mod1 = new Mock();
+ var mod2 = new Mock();
+
+ mod1.Setup(m => m.IncompatibleMods).Returns(new[] { mod2.Object.GetType() });
+
+ // Test both orderings.
+ Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod1.Object, mod2.Object }), Is.False);
+ Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod2.Object, mod1.Object }), Is.False);
+ }
+
+ [Test]
+ public void TestMultiModIncompatibleWithTopLevel()
+ {
+ var mod1 = new Mock();
+
+ // The nested mod.
+ var mod2 = new Mock();
+ mod2.Setup(m => m.IncompatibleMods).Returns(new[] { mod1.Object.GetType() });
+
+ var multiMod = new MultiMod(new MultiMod(mod2.Object));
+
+ // Test both orderings.
+ Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { multiMod, mod1.Object }), Is.False);
+ Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod1.Object, multiMod }), Is.False);
+ }
+
+ [Test]
+ public void TestTopLevelIncompatibleWithMultiMod()
+ {
+ // The nested mod.
+ var mod1 = new Mock();
+ var multiMod = new MultiMod(new MultiMod(mod1.Object));
+
+ var mod2 = new Mock();
+ mod2.Setup(m => m.IncompatibleMods).Returns(new[] { typeof(CustomMod1) });
+
+ // Test both orderings.
+ Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { multiMod, mod2.Object }), Is.False);
+ Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod2.Object, multiMod }), Is.False);
+ }
+
+ [Test]
+ public void TestCompatibleMods()
+ {
+ var mod1 = new Mock();
+ var mod2 = new Mock();
+
+ // Test both orderings.
+ Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod1.Object, mod2.Object }), Is.True);
+ Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod2.Object, mod1.Object }), Is.True);
+ }
+
+ [Test]
+ public void TestIncompatibleThroughBaseType()
+ {
+ var mod1 = new Mock();
+ var mod2 = new Mock();
+ mod2.Setup(m => m.IncompatibleMods).Returns(new[] { typeof(Mod) });
+
+ // Test both orderings.
+ Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod1.Object, mod2.Object }), Is.False);
+ Assert.That(ModUtils.CheckCompatibleSet(new Mod[] { mod2.Object, mod1.Object }), Is.False);
+ }
+
+ [Test]
+ public void TestAllowedThroughMostDerivedType()
+ {
+ var mod = new Mock();
+ Assert.That(ModUtils.CheckAllowed(new[] { mod.Object }, new[] { mod.Object.GetType() }));
+ }
+
+ [Test]
+ public void TestNotAllowedThroughBaseType()
+ {
+ var mod = new Mock();
+ Assert.That(ModUtils.CheckAllowed(new[] { mod.Object }, new[] { typeof(Mod) }), Is.False);
+ }
+
+ private static readonly object[] invalid_mod_test_scenarios =
+ {
+ // incompatible pair.
+ new object[]
+ {
+ new Mod[] { new OsuModDoubleTime(), new OsuModHalfTime() },
+ new[] { typeof(OsuModDoubleTime), typeof(OsuModHalfTime) }
+ },
+ // incompatible pair with derived class.
+ new object[]
+ {
+ new Mod[] { new OsuModNightcore(), new OsuModHalfTime() },
+ new[] { typeof(OsuModNightcore), typeof(OsuModHalfTime) }
+ },
+ // system mod.
+ new object[]
+ {
+ new Mod[] { new OsuModDoubleTime(), new OsuModTouchDevice() },
+ new[] { typeof(OsuModTouchDevice) }
+ },
+ // multi mod.
+ new object[]
+ {
+ new Mod[] { new MultiMod(new OsuModHalfTime()), new OsuModHalfTime() },
+ new[] { typeof(MultiMod) }
+ },
+ // valid pair.
+ new object[]
+ {
+ new Mod[] { new OsuModDoubleTime(), new OsuModHardRock() },
+ null
+ }
+ };
+
+ [TestCaseSource(nameof(invalid_mod_test_scenarios))]
+ public void TestInvalidModScenarios(Mod[] inputMods, Type[] expectedInvalid)
+ {
+ bool isValid = ModUtils.CheckValidForGameplay(inputMods, out var invalid);
+
+ Assert.That(isValid, Is.EqualTo(expectedInvalid == null));
+
+ if (isValid)
+ Assert.IsNull(invalid);
+ else
+ Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid));
+ }
+
+ public abstract class CustomMod1 : Mod
+ {
+ }
+
+ public abstract class CustomMod2 : Mod
+ {
+ }
+ }
+}
diff --git a/osu.Game.Tests/NonVisual/OngoingOperationTrackerTest.cs b/osu.Game.Tests/NonVisual/OngoingOperationTrackerTest.cs
new file mode 100644
index 0000000000..10216c3339
--- /dev/null
+++ b/osu.Game.Tests/NonVisual/OngoingOperationTrackerTest.cs
@@ -0,0 +1,106 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Screens;
+using osu.Framework.Testing;
+using osu.Game.Screens;
+using osu.Game.Screens.OnlinePlay;
+using osu.Game.Tests.Visual;
+
+namespace osu.Game.Tests.NonVisual
+{
+ [HeadlessTest]
+ public class OngoingOperationTrackerTest : OsuTestScene
+ {
+ private OngoingOperationTracker tracker;
+ private IBindable operationInProgress;
+
+ [SetUpSteps]
+ public void SetUp()
+ {
+ AddStep("create tracker", () => Child = tracker = new OngoingOperationTracker());
+ AddStep("bind to operation status", () => operationInProgress = tracker.InProgress.GetBoundCopy());
+ }
+
+ [Test]
+ public void TestOperationTracking()
+ {
+ IDisposable firstOperation = null;
+ IDisposable secondOperation = null;
+
+ AddStep("begin first operation", () => firstOperation = tracker.BeginOperation());
+ AddAssert("first operation in progress", () => operationInProgress.Value);
+
+ AddStep("cannot start another operation",
+ () => Assert.Throws(() => tracker.BeginOperation()));
+
+ AddStep("end first operation", () => firstOperation.Dispose());
+ AddAssert("first operation is ended", () => !operationInProgress.Value);
+
+ AddStep("start second operation", () => secondOperation = tracker.BeginOperation());
+ AddAssert("second operation in progress", () => operationInProgress.Value);
+
+ AddStep("dispose first operation again", () => firstOperation.Dispose());
+ AddAssert("second operation still in progress", () => operationInProgress.Value);
+
+ AddStep("dispose second operation", () => secondOperation.Dispose());
+ AddAssert("second operation is ended", () => !operationInProgress.Value);
+ }
+
+ [Test]
+ public void TestOperationDisposalAfterTracker()
+ {
+ IDisposable operation = null;
+
+ AddStep("begin operation", () => operation = tracker.BeginOperation());
+ AddStep("dispose tracker", () => tracker.Expire());
+ AddStep("end operation", () => operation.Dispose());
+ AddAssert("operation is ended", () => !operationInProgress.Value);
+ }
+
+ [Test]
+ public void TestOperationDisposalAfterScreenExit()
+ {
+ TestScreenWithTracker screen = null;
+ OsuScreenStack stack;
+ IDisposable operation = null;
+
+ AddStep("create screen with tracker", () =>
+ {
+ Child = stack = new OsuScreenStack
+ {
+ RelativeSizeAxes = Axes.Both
+ };
+
+ stack.Push(screen = new TestScreenWithTracker());
+ });
+ AddUntilStep("wait for loaded", () => screen.IsLoaded);
+
+ AddStep("begin operation", () => operation = screen.OngoingOperationTracker.BeginOperation());
+ AddAssert("operation in progress", () => screen.OngoingOperationTracker.InProgress.Value);
+
+ AddStep("dispose after screen exit", () =>
+ {
+ screen.Exit();
+ operation.Dispose();
+ });
+ AddAssert("operation ended", () => !screen.OngoingOperationTracker.InProgress.Value);
+ }
+
+ private class TestScreenWithTracker : OsuScreen
+ {
+ public OngoingOperationTracker OngoingOperationTracker { get; private set; }
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ InternalChild = OngoingOperationTracker = new OngoingOperationTracker();
+ }
+ }
+ }
+}
diff --git a/osu.Game.Tests/NonVisual/TaskChainTest.cs b/osu.Game.Tests/NonVisual/TaskChainTest.cs
new file mode 100644
index 0000000000..d83eaafe20
--- /dev/null
+++ b/osu.Game.Tests/NonVisual/TaskChainTest.cs
@@ -0,0 +1,111 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using osu.Game.Utils;
+
+namespace osu.Game.Tests.NonVisual
+{
+ [TestFixture]
+ public class TaskChainTest
+ {
+ private TaskChain taskChain;
+ private int currentTask;
+ private CancellationTokenSource globalCancellationToken;
+
+ [SetUp]
+ public void Setup()
+ {
+ globalCancellationToken = new CancellationTokenSource();
+ taskChain = new TaskChain();
+ currentTask = 0;
+ }
+
+ [TearDown]
+ public void TearDown()
+ {
+ globalCancellationToken?.Cancel();
+ }
+
+ [Test]
+ public async Task TestChainedTasksRunSequentially()
+ {
+ var task1 = addTask();
+ var task2 = addTask();
+ var task3 = addTask();
+
+ task3.mutex.Set();
+ task2.mutex.Set();
+ task1.mutex.Set();
+
+ await Task.WhenAll(task1.task, task2.task, task3.task);
+
+ Assert.That(task1.task.Result, Is.EqualTo(1));
+ Assert.That(task2.task.Result, Is.EqualTo(2));
+ Assert.That(task3.task.Result, Is.EqualTo(3));
+ }
+
+ [Test]
+ public async Task TestChainedTaskWithIntermediateCancelRunsInSequence()
+ {
+ var task1 = addTask();
+ var task2 = addTask();
+ var task3 = addTask();
+
+ // Cancel task2, allow task3 to complete.
+ task2.cancellation.Cancel();
+ task2.mutex.Set();
+ task3.mutex.Set();
+
+ // Allow task3 to potentially complete.
+ Thread.Sleep(1000);
+
+ // Allow task1 to complete.
+ task1.mutex.Set();
+
+ // Wait on both tasks.
+ await Task.WhenAll(task1.task, task3.task);
+
+ Assert.That(task1.task.Result, Is.EqualTo(1));
+ Assert.That(task2.task.IsCompleted, Is.False);
+ Assert.That(task3.task.Result, Is.EqualTo(2));
+ }
+
+ [Test]
+ public async Task TestChainedTaskDoesNotCompleteBeforeChildTasks()
+ {
+ var mutex = new ManualResetEventSlim(false);
+
+ var task = taskChain.Add(async () => await Task.Run(() => mutex.Wait(globalCancellationToken.Token)));
+
+ // Allow task to potentially complete
+ Thread.Sleep(1000);
+
+ Assert.That(task.IsCompleted, Is.False);
+
+ // Allow the task to complete.
+ mutex.Set();
+
+ await task;
+ }
+
+ private (Task task, ManualResetEventSlim mutex, CancellationTokenSource cancellation) addTask()
+ {
+ var mutex = new ManualResetEventSlim(false);
+ var completionSource = new TaskCompletionSource();
+
+ var cancellationSource = new CancellationTokenSource();
+ var token = CancellationTokenSource.CreateLinkedTokenSource(cancellationSource.Token, globalCancellationToken.Token);
+
+ taskChain.Add(() =>
+ {
+ mutex.Wait(globalCancellationToken.Token);
+ completionSource.SetResult(Interlocked.Increment(ref currentTask));
+ }, token.Token);
+
+ return (completionSource.Task, mutex, cancellationSource);
+ }
+ }
+}
diff --git a/osu.Game.Tests/Online/TestAPIModSerialization.cs b/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs
similarity index 99%
rename from osu.Game.Tests/Online/TestAPIModSerialization.cs
rename to osu.Game.Tests/Online/TestAPIModJsonSerialization.cs
index 5948582d77..aa6f66da81 100644
--- a/osu.Game.Tests/Online/TestAPIModSerialization.cs
+++ b/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs
@@ -16,7 +16,7 @@ using osu.Game.Rulesets.UI;
namespace osu.Game.Tests.Online
{
[TestFixture]
- public class TestAPIModSerialization
+ public class TestAPIModJsonSerialization
{
[Test]
public void TestAcronymIsPreserved()
diff --git a/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs b/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs
new file mode 100644
index 0000000000..4294f89397
--- /dev/null
+++ b/osu.Game.Tests/Online/TestAPIModMessagePackSerialization.cs
@@ -0,0 +1,139 @@
+// 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 MessagePack;
+using NUnit.Framework;
+using osu.Framework.Bindables;
+using osu.Game.Beatmaps;
+using osu.Game.Configuration;
+using osu.Game.Online.API;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Difficulty;
+using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.UI;
+
+namespace osu.Game.Tests.Online
+{
+ [TestFixture]
+ public class TestAPIModMessagePackSerialization
+ {
+ [Test]
+ public void TestAcronymIsPreserved()
+ {
+ var apiMod = new APIMod(new TestMod());
+
+ var deserialized = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(apiMod));
+
+ Assert.That(deserialized.Acronym, Is.EqualTo(apiMod.Acronym));
+ }
+
+ [Test]
+ public void TestRawSettingIsPreserved()
+ {
+ var apiMod = new APIMod(new TestMod { TestSetting = { Value = 2 } });
+
+ var deserialized = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(apiMod));
+
+ Assert.That(deserialized.Settings, Contains.Key("test_setting").With.ContainValue(2.0));
+ }
+
+ [Test]
+ public void TestConvertedModHasCorrectSetting()
+ {
+ var apiMod = new APIMod(new TestMod { TestSetting = { Value = 2 } });
+
+ var deserialized = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(apiMod));
+ var converted = (TestMod)deserialized.ToMod(new TestRuleset());
+
+ Assert.That(converted.TestSetting.Value, Is.EqualTo(2));
+ }
+
+ [Test]
+ public void TestDeserialiseTimeRampMod()
+ {
+ // Create the mod with values different from default.
+ var apiMod = new APIMod(new TestModTimeRamp
+ {
+ AdjustPitch = { Value = false },
+ InitialRate = { Value = 1.25 },
+ FinalRate = { Value = 0.25 }
+ });
+
+ var deserialised = MessagePackSerializer.Deserialize(MessagePackSerializer.Serialize(apiMod));
+ var converted = (TestModTimeRamp)deserialised.ToMod(new TestRuleset());
+
+ Assert.That(converted.AdjustPitch.Value, Is.EqualTo(false));
+ Assert.That(converted.InitialRate.Value, Is.EqualTo(1.25));
+ Assert.That(converted.FinalRate.Value, Is.EqualTo(0.25));
+ }
+
+ private class TestRuleset : Ruleset
+ {
+ public override IEnumerable GetModsFor(ModType type) => new Mod[]
+ {
+ new TestMod(),
+ new TestModTimeRamp(),
+ };
+
+ public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => throw new System.NotImplementedException();
+
+ public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => throw new System.NotImplementedException();
+
+ public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => throw new System.NotImplementedException();
+
+ public override string Description { get; } = string.Empty;
+ public override string ShortName { get; } = string.Empty;
+ }
+
+ private class TestMod : Mod
+ {
+ public override string Name => "Test Mod";
+ public override string Acronym => "TM";
+ public override double ScoreMultiplier => 1;
+
+ [SettingSource("Test")]
+ public BindableNumber TestSetting { get; } = new BindableDouble
+ {
+ MinValue = 0,
+ MaxValue = 10,
+ Default = 5,
+ Precision = 0.01,
+ };
+ }
+
+ private class TestModTimeRamp : ModTimeRamp
+ {
+ public override string Name => "Test Mod";
+ public override string Acronym => "TMTR";
+ public override double ScoreMultiplier => 1;
+
+ [SettingSource("Initial rate", "The starting speed of the track")]
+ public override BindableNumber InitialRate { get; } = new BindableDouble
+ {
+ MinValue = 1,
+ MaxValue = 2,
+ Default = 1.5,
+ Value = 1.5,
+ Precision = 0.01,
+ };
+
+ [SettingSource("Final rate", "The speed increase to ramp towards")]
+ public override BindableNumber FinalRate { get; } = new BindableDouble
+ {
+ MinValue = 0,
+ MaxValue = 1,
+ Default = 0.5,
+ Value = 0.5,
+ Precision = 0.01,
+ };
+
+ [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")]
+ public override BindableBool AdjustPitch { get; } = new BindableBool
+ {
+ Default = true,
+ Value = true
+ };
+ }
+ }
+}
diff --git a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs
new file mode 100644
index 0000000000..d3475de157
--- /dev/null
+++ b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs
@@ -0,0 +1,188 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading;
+using System.Threading.Tasks;
+using JetBrains.Annotations;
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Audio;
+using osu.Framework.Bindables;
+using osu.Framework.Extensions;
+using osu.Framework.Platform;
+using osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.Formats;
+using osu.Game.Database;
+using osu.Game.IO;
+using osu.Game.IO.Archives;
+using osu.Game.Online.API;
+using osu.Game.Online.Rooms;
+using osu.Game.Rulesets;
+using osu.Game.Tests.Resources;
+using osu.Game.Tests.Visual;
+
+namespace osu.Game.Tests.Online
+{
+ [HeadlessTest]
+ public class TestSceneOnlinePlayBeatmapAvailabilityTracker : OsuTestScene
+ {
+ private RulesetStore rulesets;
+ private TestBeatmapManager beatmaps;
+
+ private string testBeatmapFile;
+ private BeatmapInfo testBeatmapInfo;
+ private BeatmapSetInfo testBeatmapSet;
+
+ private readonly Bindable selectedItem = new Bindable();
+ private OnlinePlayBeatmapAvailablilityTracker availablilityTracker;
+
+ [BackgroundDependencyLoader]
+ private void load(AudioManager audio, GameHost host)
+ {
+ Dependencies.Cache(rulesets = new RulesetStore(ContextFactory));
+ Dependencies.CacheAs(beatmaps = new TestBeatmapManager(LocalStorage, ContextFactory, rulesets, API, audio, host, Beatmap.Default));
+ }
+
+ [SetUp]
+ public void SetUp() => Schedule(() =>
+ {
+ beatmaps.AllowImport = new TaskCompletionSource();
+
+ testBeatmapFile = TestResources.GetTestBeatmapForImport();
+
+ testBeatmapInfo = getTestBeatmapInfo(testBeatmapFile);
+ testBeatmapSet = testBeatmapInfo.BeatmapSet;
+
+ var existing = beatmaps.QueryBeatmapSet(s => s.OnlineBeatmapSetID == testBeatmapSet.OnlineBeatmapSetID);
+ if (existing != null)
+ beatmaps.Delete(existing);
+
+ selectedItem.Value = new PlaylistItem
+ {
+ Beatmap = { Value = testBeatmapInfo },
+ Ruleset = { Value = testBeatmapInfo.Ruleset },
+ };
+
+ Child = availablilityTracker = new OnlinePlayBeatmapAvailablilityTracker
+ {
+ SelectedItem = { BindTarget = selectedItem, }
+ };
+ });
+
+ [Test]
+ public void TestBeatmapDownloadingFlow()
+ {
+ AddAssert("ensure beatmap unavailable", () => !beatmaps.IsAvailableLocally(testBeatmapSet));
+ addAvailabilityCheckStep("state not downloaded", BeatmapAvailability.NotDownloaded);
+
+ AddStep("start downloading", () => beatmaps.Download(testBeatmapSet));
+ addAvailabilityCheckStep("state downloading 0%", () => BeatmapAvailability.Downloading(0.0f));
+
+ AddStep("set progress 40%", () => ((TestDownloadRequest)beatmaps.GetExistingDownload(testBeatmapSet)).SetProgress(0.4f));
+ addAvailabilityCheckStep("state downloading 40%", () => BeatmapAvailability.Downloading(0.4f));
+
+ AddStep("finish download", () => ((TestDownloadRequest)beatmaps.GetExistingDownload(testBeatmapSet)).TriggerSuccess(testBeatmapFile));
+ addAvailabilityCheckStep("state importing", BeatmapAvailability.Importing);
+
+ AddStep("allow importing", () => beatmaps.AllowImport.SetResult(true));
+ AddUntilStep("wait for import", () => beatmaps.CurrentImportTask?.IsCompleted == true);
+ addAvailabilityCheckStep("state locally available", BeatmapAvailability.LocallyAvailable);
+ }
+
+ [Test]
+ public void TestTrackerRespectsSoftDeleting()
+ {
+ AddStep("allow importing", () => beatmaps.AllowImport.SetResult(true));
+ AddStep("import beatmap", () => beatmaps.Import(testBeatmapFile).Wait());
+ addAvailabilityCheckStep("state locally available", BeatmapAvailability.LocallyAvailable);
+
+ AddStep("delete beatmap", () => beatmaps.Delete(beatmaps.QueryBeatmapSet(b => b.OnlineBeatmapSetID == testBeatmapSet.OnlineBeatmapSetID)));
+ addAvailabilityCheckStep("state not downloaded", BeatmapAvailability.NotDownloaded);
+
+ AddStep("undelete beatmap", () => beatmaps.Undelete(beatmaps.QueryBeatmapSet(b => b.OnlineBeatmapSetID == testBeatmapSet.OnlineBeatmapSetID)));
+ addAvailabilityCheckStep("state locally available", BeatmapAvailability.LocallyAvailable);
+ }
+
+ [Test]
+ public void TestTrackerRespectsChecksum()
+ {
+ AddStep("allow importing", () => beatmaps.AllowImport.SetResult(true));
+
+ AddStep("import altered beatmap", () =>
+ {
+ beatmaps.Import(TestResources.GetTestBeatmapForImport(true)).Wait();
+ });
+ addAvailabilityCheckStep("state still not downloaded", BeatmapAvailability.NotDownloaded);
+
+ AddStep("recreate tracker", () => Child = availablilityTracker = new OnlinePlayBeatmapAvailablilityTracker
+ {
+ SelectedItem = { BindTarget = selectedItem }
+ });
+ addAvailabilityCheckStep("state not downloaded as well", BeatmapAvailability.NotDownloaded);
+ }
+
+ private void addAvailabilityCheckStep(string description, Func expected)
+ {
+ AddAssert(description, () => availablilityTracker.Availability.Value.Equals(expected.Invoke()));
+ }
+
+ private static BeatmapInfo getTestBeatmapInfo(string archiveFile)
+ {
+ BeatmapInfo info;
+
+ using (var archive = new ZipArchiveReader(File.OpenRead(archiveFile)))
+ using (var stream = archive.GetStream("Soleily - Renatus (Gamu) [Insane].osu"))
+ using (var reader = new LineBufferedReader(stream))
+ {
+ var decoder = Decoder.GetDecoder(reader);
+ var beatmap = decoder.Decode(reader);
+
+ info = beatmap.BeatmapInfo;
+ info.BeatmapSet.Beatmaps = new List { info };
+ info.BeatmapSet.Metadata = info.Metadata;
+ info.MD5Hash = stream.ComputeMD5Hash();
+ info.Hash = stream.ComputeSHA2Hash();
+ }
+
+ return info;
+ }
+
+ private class TestBeatmapManager : BeatmapManager
+ {
+ public TaskCompletionSource AllowImport = new TaskCompletionSource();
+
+ public Task CurrentImportTask { get; private set; }
+
+ protected override ArchiveDownloadRequest CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize)
+ => new TestDownloadRequest(set);
+
+ public TestBeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, GameHost host = null, WorkingBeatmap defaultBeatmap = null, bool performOnlineLookups = false)
+ : base(storage, contextFactory, rulesets, api, audioManager, host, defaultBeatmap, performOnlineLookups)
+ {
+ }
+
+ public override async Task Import(BeatmapSetInfo item, ArchiveReader archive = null, CancellationToken cancellationToken = default)
+ {
+ await AllowImport.Task;
+ return await (CurrentImportTask = base.Import(item, archive, cancellationToken));
+ }
+ }
+
+ private class TestDownloadRequest : ArchiveDownloadRequest
+ {
+ public new void SetProgress(float progress) => base.SetProgress(progress);
+ public new void TriggerSuccess(string filename) => base.TriggerSuccess(filename);
+
+ public TestDownloadRequest(BeatmapSetInfo model)
+ : base(model)
+ {
+ }
+
+ protected override string Target => null;
+ }
+ }
+}
diff --git a/osu.Game.Tests/Resources/animation-types.osb b/osu.Game.Tests/Resources/animation-types.osb
new file mode 100644
index 0000000000..82233b7d30
--- /dev/null
+++ b/osu.Game.Tests/Resources/animation-types.osb
@@ -0,0 +1,9 @@
+osu file format v14
+
+[Events]
+Animation,Foreground,Centre,"forever-string.png",330,240,10,108,LoopForever
+Animation,Foreground,Centre,"once-string.png",330,240,10,108,LoopOnce
+Animation,Foreground,Centre,"forever-number.png",330,240,10,108,0
+Animation,Foreground,Centre,"once-number.png",330,240,10,108,1
+Animation,Foreground,Centre,"undefined-number.png",330,240,10,108,16
+Animation,Foreground,Centre,"omitted.png",330,240,10,108
diff --git a/osu.Game.Tests/Rulesets/Mods/ModTimeRampTest.cs b/osu.Game.Tests/Rulesets/Mods/ModTimeRampTest.cs
new file mode 100644
index 0000000000..4b9f2181dc
--- /dev/null
+++ b/osu.Game.Tests/Rulesets/Mods/ModTimeRampTest.cs
@@ -0,0 +1,113 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using NUnit.Framework;
+using osu.Framework.Audio.Track;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.Osu.Objects;
+
+namespace osu.Game.Tests.Rulesets.Mods
+{
+ [TestFixture]
+ public class ModTimeRampTest
+ {
+ private const double start_time = 1000;
+ private const double duration = 9000;
+
+ private TrackVirtual track;
+
+ [SetUp]
+ public void SetUp()
+ {
+ track = new TrackVirtual(20_000);
+ }
+
+ [TestCase(0, 1)]
+ [TestCase(start_time, 1)]
+ [TestCase(start_time + duration * ModTimeRamp.FINAL_RATE_PROGRESS / 2, 1.25)]
+ [TestCase(start_time + duration * ModTimeRamp.FINAL_RATE_PROGRESS, 1.5)]
+ [TestCase(start_time + duration, 1.5)]
+ [TestCase(15000, 1.5)]
+ public void TestModWindUp(double time, double expectedRate)
+ {
+ var beatmap = createSingleSpinnerBeatmap();
+ var mod = new ModWindUp();
+ mod.ApplyToBeatmap(beatmap);
+ mod.ApplyToTrack(track);
+
+ seekTrackAndUpdateMod(mod, time);
+
+ Assert.That(mod.SpeedChange.Value, Is.EqualTo(expectedRate));
+ }
+
+ [TestCase(0, 1)]
+ [TestCase(start_time, 1)]
+ [TestCase(start_time + duration * ModTimeRamp.FINAL_RATE_PROGRESS / 2, 0.75)]
+ [TestCase(start_time + duration * ModTimeRamp.FINAL_RATE_PROGRESS, 0.5)]
+ [TestCase(start_time + duration, 0.5)]
+ [TestCase(15000, 0.5)]
+ public void TestModWindDown(double time, double expectedRate)
+ {
+ var beatmap = createSingleSpinnerBeatmap();
+ var mod = new ModWindDown
+ {
+ FinalRate = { Value = 0.5 }
+ };
+ mod.ApplyToBeatmap(beatmap);
+ mod.ApplyToTrack(track);
+
+ seekTrackAndUpdateMod(mod, time);
+
+ Assert.That(mod.SpeedChange.Value, Is.EqualTo(expectedRate));
+ }
+
+ [TestCase(0, 1)]
+ [TestCase(start_time, 1)]
+ [TestCase(2 * start_time, 1.5)]
+ public void TestZeroDurationMap(double time, double expectedRate)
+ {
+ var beatmap = createSingleObjectBeatmap();
+ var mod = new ModWindUp();
+ mod.ApplyToBeatmap(beatmap);
+ mod.ApplyToTrack(track);
+
+ seekTrackAndUpdateMod(mod, time);
+
+ Assert.That(mod.SpeedChange.Value, Is.EqualTo(expectedRate));
+ }
+
+ private void seekTrackAndUpdateMod(ModTimeRamp mod, double time)
+ {
+ track.Seek(time);
+ // update the mod via a fake playfield to re-calculate the current rate.
+ mod.Update(null);
+ }
+
+ private static Beatmap createSingleSpinnerBeatmap()
+ {
+ return new Beatmap
+ {
+ HitObjects =
+ {
+ new Spinner
+ {
+ StartTime = start_time,
+ Duration = duration
+ }
+ }
+ };
+ }
+
+ private static Beatmap createSingleObjectBeatmap()
+ {
+ return new Beatmap
+ {
+ HitObjects =
+ {
+ new HitCircle { StartTime = start_time }
+ }
+ };
+ }
+ }
+}
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs
index 3adc1bd425..94a9fd7b35 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSummaryTimeline.cs
@@ -5,6 +5,8 @@ using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Osu;
+using osu.Game.Rulesets.Osu.Beatmaps;
+using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Components.Timelines.Summary;
using osuTK;
@@ -13,6 +15,9 @@ namespace osu.Game.Tests.Visual.Editing
[TestFixture]
public class TestSceneEditorSummaryTimeline : EditorClockTestScene
{
+ [Cached(typeof(EditorBeatmap))]
+ private readonly EditorBeatmap editorBeatmap = new EditorBeatmap(new OsuBeatmap());
+
[BackgroundDependencyLoader]
private void load()
{
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimelineHitObjectBlueprint.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimelineHitObjectBlueprint.cs
new file mode 100644
index 0000000000..35f394fe1d
--- /dev/null
+++ b/osu.Game.Tests/Visual/Editing/TestSceneTimelineHitObjectBlueprint.cs
@@ -0,0 +1,55 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Graphics;
+using osu.Framework.Testing;
+using osu.Game.Rulesets.Objects.Types;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Screens.Edit.Compose.Components.Timeline;
+using osuTK;
+using osuTK.Input;
+using static osu.Game.Screens.Edit.Compose.Components.Timeline.TimelineHitObjectBlueprint;
+
+namespace osu.Game.Tests.Visual.Editing
+{
+ public class TestSceneTimelineHitObjectBlueprint : TimelineTestScene
+ {
+ public override Drawable CreateTestComponent() => new TimelineBlueprintContainer(Composer);
+
+ [Test]
+ public void TestDisallowZeroDurationObjects()
+ {
+ DragBar dragBar;
+
+ AddStep("add spinner", () =>
+ {
+ EditorBeatmap.Clear();
+ EditorBeatmap.Add(new Spinner
+ {
+ Position = new Vector2(256, 256),
+ StartTime = 150,
+ Duration = 500
+ });
+ });
+
+ AddStep("hold down drag bar", () =>
+ {
+ // distinguishes between the actual drag bar and its "underlay shadow".
+ dragBar = this.ChildrenOfType().Single(bar => bar.HandlePositionalInput);
+ InputManager.MoveMouseTo(dragBar);
+ InputManager.PressButton(MouseButton.Left);
+ });
+
+ AddStep("try to drag bar past start", () =>
+ {
+ var blueprint = this.ChildrenOfType().Single();
+ InputManager.MoveMouseTo(blueprint.SelectionQuad.TopLeft - new Vector2(100, 0));
+ InputManager.ReleaseButton(MouseButton.Left);
+ });
+
+ AddAssert("object has non-zero duration", () => EditorBeatmap.HitObjects.OfType().Single().Duration > 0);
+ }
+ }
+}
diff --git a/osu.Game.Tests/Visual/Editing/TimelineTestScene.cs b/osu.Game.Tests/Visual/Editing/TimelineTestScene.cs
index 63bb018d6e..d6db171cf0 100644
--- a/osu.Game.Tests/Visual/Editing/TimelineTestScene.cs
+++ b/osu.Game.Tests/Visual/Editing/TimelineTestScene.cs
@@ -23,22 +23,24 @@ namespace osu.Game.Tests.Visual.Editing
protected HitObjectComposer Composer { get; private set; }
+ protected EditorBeatmap EditorBeatmap { get; private set; }
+
[BackgroundDependencyLoader]
private void load(AudioManager audio)
{
Beatmap.Value = new WaveformTestBeatmap(audio);
var playable = Beatmap.Value.GetPlayableBeatmap(Beatmap.Value.BeatmapInfo.Ruleset);
- var editorBeatmap = new EditorBeatmap(playable);
+ EditorBeatmap = new EditorBeatmap(playable);
- Dependencies.Cache(editorBeatmap);
- Dependencies.CacheAs(editorBeatmap);
+ Dependencies.Cache(EditorBeatmap);
+ Dependencies.CacheAs(EditorBeatmap);
Composer = playable.BeatmapInfo.Ruleset.CreateInstance().CreateHitObjectComposer().With(d => d.Alpha = 0);
AddRange(new Drawable[]
{
- editorBeatmap,
+ EditorBeatmap,
Composer,
new FillFlowContainer
{
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardSamplePlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardSamplePlayback.cs
new file mode 100644
index 0000000000..1544f8fd35
--- /dev/null
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardSamplePlayback.cs
@@ -0,0 +1,74 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Configuration;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Osu;
+using osu.Game.Storyboards;
+using osu.Game.Storyboards.Drawables;
+
+namespace osu.Game.Tests.Visual.Gameplay
+{
+ public class TestSceneStoryboardSamplePlayback : PlayerTestScene
+ {
+ private Storyboard storyboard;
+
+ [BackgroundDependencyLoader]
+ private void load(OsuConfigManager config)
+ {
+ config.Set(OsuSetting.ShowStoryboard, true);
+
+ storyboard = new Storyboard();
+ var backgroundLayer = storyboard.GetLayer("Background");
+ backgroundLayer.Add(new StoryboardSampleInfo("Intro/welcome.mp3", time: -7000, volume: 20));
+ backgroundLayer.Add(new StoryboardSampleInfo("Intro/welcome.mp3", time: -5000, volume: 20));
+ backgroundLayer.Add(new StoryboardSampleInfo("Intro/welcome.mp3", time: 0, volume: 20));
+ }
+
+ [Test]
+ public void TestStoryboardSamplesStopDuringPause()
+ {
+ checkForFirstSamplePlayback();
+
+ AddStep("player paused", () => Player.Pause());
+ AddAssert("player is currently paused", () => Player.GameplayClockContainer.IsPaused.Value);
+ AddAssert("all storyboard samples stopped immediately", () => allStoryboardSamples.All(sound => !sound.IsPlaying));
+
+ AddStep("player resume", () => Player.Resume());
+ AddUntilStep("any storyboard samples playing after resume", () => allStoryboardSamples.Any(sound => sound.IsPlaying));
+ }
+
+ [Test]
+ public void TestStoryboardSamplesStopOnSkip()
+ {
+ checkForFirstSamplePlayback();
+
+ AddStep("skip intro", () => InputManager.Key(osuTK.Input.Key.Space));
+ AddAssert("all storyboard samples stopped immediately", () => allStoryboardSamples.All(sound => !sound.IsPlaying));
+
+ AddUntilStep("any storyboard samples playing after skip", () => allStoryboardSamples.Any(sound => sound.IsPlaying));
+ }
+
+ private void checkForFirstSamplePlayback()
+ {
+ AddUntilStep("storyboard loaded", () => Player.Beatmap.Value.StoryboardLoaded);
+ AddUntilStep("any storyboard samples playing", () => allStoryboardSamples.Any(sound => sound.IsPlaying));
+ }
+
+ private IEnumerable allStoryboardSamples => Player.ChildrenOfType();
+
+ protected override bool AllowFail => false;
+
+ protected override Ruleset CreatePlayerRuleset() => new OsuRuleset();
+ protected override TestPlayer CreatePlayer(Ruleset ruleset) => new TestPlayer(true, false);
+
+ protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null) =>
+ new ClockBackedTestWorkingBeatmap(beatmap, storyboard ?? this.storyboard, Clock, Audio);
+ }
+}
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs
index 874c1694eb..960aad10c6 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs
@@ -11,8 +11,8 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.Drawables;
using osu.Game.Graphics.Containers;
-using osu.Game.Graphics.UserInterface;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Rulesets;
@@ -20,6 +20,7 @@ using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Screens.OnlinePlay;
using osu.Game.Tests.Beatmaps;
+using osu.Game.Users;
using osuTK;
using osuTK.Input;
@@ -241,7 +242,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
}
private void moveToItem(int index, Vector2? offset = null)
- => AddStep($"move mouse to item {index}", () => InputManager.MoveMouseTo(playlist.ChildrenOfType>().ElementAt(index), offset));
+ => AddStep($"move mouse to item {index}", () => InputManager.MoveMouseTo(playlist.ChildrenOfType().ElementAt(index), offset));
private void moveToDragger(int index, Vector2? offset = null) => AddStep($"move mouse to dragger {index}", () =>
{
@@ -252,7 +253,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
private void moveToDeleteButton(int index, Vector2? offset = null) => AddStep($"move mouse to delete button {index}", () =>
{
var item = playlist.ChildrenOfType>().ElementAt(index);
- InputManager.MoveMouseTo(item.ChildrenOfType().ElementAt(0), offset);
+ InputManager.MoveMouseTo(item.ChildrenOfType().ElementAt(0), offset);
});
private void assertHandleVisibility(int index, bool visible)
@@ -260,7 +261,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
() => (playlist.ChildrenOfType.PlaylistItemHandle>().ElementAt(index).Alpha > 0) == visible);
private void assertDeleteButtonVisibility(int index, bool visible)
- => AddAssert($"delete button {index} {(visible ? "is" : "is not")} visible", () => (playlist.ChildrenOfType().ElementAt(2 + index * 2).Alpha > 0) == visible);
+ => AddAssert($"delete button {index} {(visible ? "is" : "is not")} visible", () => (playlist.ChildrenOfType().ElementAt(2 + index * 2).Alpha > 0) == visible);
private void createPlaylist(bool allowEdit, bool allowSelection)
{
@@ -278,7 +279,21 @@ namespace osu.Game.Tests.Visual.Multiplayer
playlist.Items.Add(new PlaylistItem
{
ID = i,
- Beatmap = { Value = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo },
+ Beatmap =
+ {
+ Value = i % 2 == 1
+ ? new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo
+ : new BeatmapInfo
+ {
+ Metadata = new BeatmapMetadata
+ {
+ Artist = "Artist",
+ Author = new User { Username = "Creator name here" },
+ Title = "Long title used to check background colour",
+ },
+ BeatmapSet = new BeatmapSetInfo()
+ }
+ },
Ruleset = { Value = new OsuRuleset().RulesetInfo },
RequiredMods =
{
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs
new file mode 100644
index 0000000000..26a0301d8a
--- /dev/null
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs
@@ -0,0 +1,21 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using NUnit.Framework;
+using osu.Framework.Graphics.Containers;
+using osu.Game.Screens.OnlinePlay;
+
+namespace osu.Game.Tests.Visual.Multiplayer
+{
+ public class TestSceneFreeModSelectOverlay : MultiplayerTestScene
+ {
+ [SetUp]
+ public new void Setup() => Schedule(() =>
+ {
+ Child = new FreeModSelectOverlay
+ {
+ State = { Value = Visibility.Visible }
+ };
+ });
+ }
+}
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs
index 968a869532..e2c98c0aad 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs
@@ -8,6 +8,8 @@ using osu.Framework.Graphics.Sprites;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Online.Multiplayer;
+using osu.Game.Rulesets.Mods;
+using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Screens.OnlinePlay.Multiplayer.Participants;
using osu.Game.Users;
using osuTK;
@@ -123,5 +125,32 @@ namespace osu.Game.Tests.Visual.Multiplayer
}
});
}
+
+ [Test]
+ public void TestUserWithMods()
+ {
+ AddStep("add user", () =>
+ {
+ Client.AddUser(new User
+ {
+ Id = 0,
+ Username = "User 0",
+ CurrentModeRank = RNG.Next(1, 100000),
+ CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg",
+ });
+
+ Client.ChangeUserMods(0, new Mod[]
+ {
+ new OsuModHardRock(),
+ new OsuModDifficultyAdjust { ApproachRate = { Value = 1 } }
+ });
+ });
+
+ for (var i = MultiplayerUserState.Idle; i < MultiplayerUserState.Results; i++)
+ {
+ var state = i;
+ AddStep($"set state: {state}", () => Client.ChangeUserState(0, state));
+ }
+ }
}
}
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs
index 878776bf51..3b3b1bee86 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs
@@ -6,6 +6,7 @@ using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
+using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Platform;
using osu.Framework.Testing;
@@ -26,8 +27,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
public class TestSceneMultiplayerReadyButton : MultiplayerTestScene
{
private MultiplayerReadyButton button;
+ private OnlinePlayBeatmapAvailablilityTracker beatmapTracker;
private BeatmapSetInfo importedSet;
+ private readonly Bindable selectedItem = new Bindable();
+
private BeatmapManager beatmaps;
private RulesetStore rulesets;
@@ -39,6 +43,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
Dependencies.Cache(rulesets = new RulesetStore(ContextFactory));
Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, host, Beatmap.Default));
beatmaps.Import(TestResources.GetTestBeatmapForImport(true)).Wait();
+
+ Add(beatmapTracker = new OnlinePlayBeatmapAvailablilityTracker
+ {
+ SelectedItem = { BindTarget = selectedItem }
+ });
+
+ Dependencies.Cache(beatmapTracker);
}
[SetUp]
@@ -46,20 +57,20 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First();
Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First());
+ selectedItem.Value = new PlaylistItem
+ {
+ Beatmap = { Value = Beatmap.Value.BeatmapInfo },
+ Ruleset = { Value = Beatmap.Value.BeatmapInfo.Ruleset },
+ };
- Child = button = new MultiplayerReadyButton
+ if (button != null)
+ Remove(button);
+
+ Add(button = new MultiplayerReadyButton
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(200, 50),
- SelectedItem =
- {
- Value = new PlaylistItem
- {
- Beatmap = { Value = Beatmap.Value.BeatmapInfo },
- Ruleset = { Value = Beatmap.Value.BeatmapInfo.Ruleset }
- }
- },
OnReadyClick = async () =>
{
readyClickOperation = OngoingOperationTracker.BeginOperation();
@@ -73,7 +84,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
await Client.ToggleReady();
readyClickOperation.Dispose();
}
- };
+ });
});
[Test]
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerRoomManager.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerRoomManager.cs
index 7a3845cbf3..6de5704410 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerRoomManager.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerRoomManager.cs
@@ -101,7 +101,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
});
});
- AddAssert("multiplayer room joined", () => roomContainer.Client.Room != null);
+ AddUntilStep("multiplayer room joined", () => roomContainer.Client.Room != null);
}
[Test]
@@ -133,7 +133,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
});
});
- AddAssert("multiplayer room joined", () => roomContainer.Client.Room != null);
+ AddUntilStep("multiplayer room joined", () => roomContainer.Client.Room != null);
}
private TestMultiplayerRoomManager createRoomManager()
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs
similarity index 95%
rename from osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSongSelect.cs
rename to osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs
index e0fd7d9874..1d13c6229c 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchSongSelect.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs
@@ -19,11 +19,11 @@ using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Screens.OnlinePlay.Components;
-using osu.Game.Screens.Select;
+using osu.Game.Screens.OnlinePlay.Playlists;
namespace osu.Game.Tests.Visual.Multiplayer
{
- public class TestSceneMatchSongSelect : RoomTestScene
+ public class TestScenePlaylistsSongSelect : RoomTestScene
{
[Resolved]
private BeatmapManager beatmapManager { get; set; }
@@ -32,7 +32,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
private RulesetStore rulesets;
- private TestMatchSongSelect songSelect;
+ private TestPlaylistsSongSelect songSelect;
[BackgroundDependencyLoader]
private void load(GameHost host, AudioManager audio)
@@ -89,7 +89,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
Beatmap.SetDefault();
});
- AddStep("create song select", () => LoadScreen(songSelect = new TestMatchSongSelect()));
+ AddStep("create song select", () => LoadScreen(songSelect = new TestPlaylistsSongSelect()));
AddUntilStep("wait for present", () => songSelect.IsCurrentScreen());
}
@@ -143,6 +143,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
/// Tests that the same instances are not shared between two playlist items.
///
[Test]
+ [Ignore("Temporarily disabled due to a non-trivial test failure")]
public void TestNewItemHasNewModInstances()
{
AddStep("set dt mod", () => SelectedMods.Value = new[] { new OsuModDoubleTime() });
@@ -176,7 +177,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("item has rate 1.5", () => Precision.AlmostEquals(1.5, ((OsuModDoubleTime)Room.Playlist.First().RequiredMods[0]).SpeedChange.Value));
}
- private class TestMatchSongSelect : MatchSongSelect
+ private class TestPlaylistsSongSelect : PlaylistsSongSelect
{
public new MatchBeatmapDetailArea BeatmapDetails => (MatchBeatmapDetailArea)base.BeatmapDetails;
}
diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs
index 6cb1687d1f..1349264bf9 100644
--- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs
+++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs
@@ -1,32 +1,81 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-using osu.Game.Overlays;
+using System.Collections.Generic;
+using System.Linq;
using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Online.API;
+using osu.Game.Online.API.Requests;
+using osu.Game.Online.API.Requests.Responses;
+using osu.Game.Overlays;
+using osu.Game.Overlays.BeatmapListing;
+using osu.Game.Rulesets;
namespace osu.Game.Tests.Visual.Online
{
public class TestSceneBeatmapListingOverlay : OsuTestScene
{
- protected override bool UseOnlineAPI => true;
+ private readonly List setsForResponse = new List();
- private readonly BeatmapListingOverlay overlay;
+ private BeatmapListingOverlay overlay;
- public TestSceneBeatmapListingOverlay()
+ [BackgroundDependencyLoader]
+ private void load()
{
- Add(overlay = new BeatmapListingOverlay());
+ Child = overlay = new BeatmapListingOverlay { State = { Value = Visibility.Visible } };
+
+ ((DummyAPIAccess)API).HandleRequest = req =>
+ {
+ if (req is SearchBeatmapSetsRequest searchBeatmapSetsRequest)
+ {
+ searchBeatmapSetsRequest.TriggerSuccess(new SearchBeatmapSetsResponse
+ {
+ BeatmapSets = setsForResponse,
+ });
+ }
+ };
}
[Test]
- public void TestShow()
+ public void TestNoBeatmapsPlaceholder()
{
- AddStep("Show", overlay.Show);
+ AddStep("fetch for 0 beatmaps", () => fetchFor());
+ AddUntilStep("placeholder shown", () => overlay.ChildrenOfType().SingleOrDefault()?.IsPresent == true);
+
+ AddStep("fetch for 1 beatmap", () => fetchFor(CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet));
+ AddUntilStep("placeholder hidden", () => !overlay.ChildrenOfType().Any());
+
+ AddStep("fetch for 0 beatmaps", () => fetchFor());
+ AddUntilStep("placeholder shown", () => overlay.ChildrenOfType().SingleOrDefault()?.IsPresent == true);
+
+ // fetch once more to ensure nothing happens in displaying placeholder again when it already is present.
+ AddStep("fetch for 0 beatmaps again", () => fetchFor());
+ AddUntilStep("placeholder shown", () => overlay.ChildrenOfType().SingleOrDefault()?.IsPresent == true);
}
- [Test]
- public void TestHide()
+ private void fetchFor(params BeatmapSetInfo[] beatmaps)
{
- AddStep("Hide", overlay.Hide);
+ setsForResponse.Clear();
+ setsForResponse.AddRange(beatmaps.Select(b => new TestAPIBeatmapSet(b)));
+
+ // trigger arbitrary change for fetching.
+ overlay.ChildrenOfType().Single().Query.TriggerChange();
+ }
+
+ private class TestAPIBeatmapSet : APIBeatmapSet
+ {
+ private readonly BeatmapSetInfo beatmapSet;
+
+ public TestAPIBeatmapSet(BeatmapSetInfo beatmapSet)
+ {
+ this.beatmapSet = beatmapSet;
+ }
+
+ public override BeatmapSetInfo ToBeatmapSet(RulesetStore rulesets) => beatmapSet;
}
}
}
diff --git a/osu.Game.Tests/Visual/Online/TestSceneOnlineBeatmapListingOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneOnlineBeatmapListingOverlay.cs
new file mode 100644
index 0000000000..fe1701a554
--- /dev/null
+++ b/osu.Game.Tests/Visual/Online/TestSceneOnlineBeatmapListingOverlay.cs
@@ -0,0 +1,33 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Game.Overlays;
+using NUnit.Framework;
+
+namespace osu.Game.Tests.Visual.Online
+{
+ [Description("uses online API")]
+ public class TestSceneOnlineBeatmapListingOverlay : OsuTestScene
+ {
+ protected override bool UseOnlineAPI => true;
+
+ private readonly BeatmapListingOverlay overlay;
+
+ public TestSceneOnlineBeatmapListingOverlay()
+ {
+ Add(overlay = new BeatmapListingOverlay());
+ }
+
+ [Test]
+ public void TestShow()
+ {
+ AddStep("Show", overlay.Show);
+ }
+
+ [Test]
+ public void TestHide()
+ {
+ AddStep("Hide", overlay.Hide);
+ }
+ }
+}
diff --git a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs
index 492abdd88d..01e67b1681 100644
--- a/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs
+++ b/osu.Game.Tests/Visual/Online/TestSceneStandAloneChatDisplay.cs
@@ -8,16 +8,16 @@ using osu.Game.Users;
using osuTK;
using System;
using System.Linq;
+using NUnit.Framework;
using osu.Framework.Graphics.Containers;
using osu.Game.Graphics.Containers;
using osu.Game.Overlays.Chat;
+using osuTK.Input;
namespace osu.Game.Tests.Visual.Online
{
- public class TestSceneStandAloneChatDisplay : OsuTestScene
+ public class TestSceneStandAloneChatDisplay : OsuManualInputManagerTestScene
{
- private readonly Channel testChannel = new Channel();
-
private readonly User admin = new User
{
Username = "HappyStick",
@@ -46,92 +46,97 @@ namespace osu.Game.Tests.Visual.Online
[Cached]
private ChannelManager channelManager = new ChannelManager();
- private readonly TestStandAloneChatDisplay chatDisplay;
- private readonly TestStandAloneChatDisplay chatDisplay2;
+ private TestStandAloneChatDisplay chatDisplay;
+ private int messageIdSequence;
+
+ private Channel testChannel;
public TestSceneStandAloneChatDisplay()
{
Add(channelManager);
-
- Add(chatDisplay = new TestStandAloneChatDisplay
- {
- Anchor = Anchor.CentreLeft,
- Origin = Anchor.CentreLeft,
- Margin = new MarginPadding(20),
- Size = new Vector2(400, 80)
- });
-
- Add(chatDisplay2 = new TestStandAloneChatDisplay(true)
- {
- Anchor = Anchor.CentreRight,
- Origin = Anchor.CentreRight,
- Margin = new MarginPadding(20),
- Size = new Vector2(400, 150)
- });
}
- protected override void LoadComplete()
+ [SetUp]
+ public void SetUp() => Schedule(() =>
{
- base.LoadComplete();
+ messageIdSequence = 0;
+ channelManager.CurrentChannel.Value = testChannel = new Channel();
- channelManager.CurrentChannel.Value = testChannel;
+ Children = new[]
+ {
+ chatDisplay = new TestStandAloneChatDisplay
+ {
+ Anchor = Anchor.CentreLeft,
+ Origin = Anchor.CentreLeft,
+ Margin = new MarginPadding(20),
+ Size = new Vector2(400, 80),
+ Channel = { Value = testChannel },
+ },
+ new TestStandAloneChatDisplay(true)
+ {
+ Anchor = Anchor.CentreRight,
+ Origin = Anchor.CentreRight,
+ Margin = new MarginPadding(20),
+ Size = new Vector2(400, 150),
+ Channel = { Value = testChannel },
+ }
+ };
+ });
- chatDisplay.Channel.Value = testChannel;
- chatDisplay2.Channel.Value = testChannel;
-
- int sequence = 0;
-
- AddStep("message from admin", () => testChannel.AddNewMessages(new Message(sequence++)
+ [Test]
+ public void TestManyMessages()
+ {
+ AddStep("message from admin", () => testChannel.AddNewMessages(new Message(messageIdSequence++)
{
Sender = admin,
Content = "I am a wang!"
}));
- AddStep("message from team red", () => testChannel.AddNewMessages(new Message(sequence++)
+ AddStep("message from team red", () => testChannel.AddNewMessages(new Message(messageIdSequence++)
{
Sender = redUser,
Content = "I am team red."
}));
- AddStep("message from team red", () => testChannel.AddNewMessages(new Message(sequence++)
+ AddStep("message from team red", () => testChannel.AddNewMessages(new Message(messageIdSequence++)
{
Sender = redUser,
Content = "I plan to win!"
}));
- AddStep("message from team blue", () => testChannel.AddNewMessages(new Message(sequence++)
+ AddStep("message from team blue", () => testChannel.AddNewMessages(new Message(messageIdSequence++)
{
Sender = blueUser,
Content = "Not on my watch. Prepare to eat saaaaaaaaaand. Lots and lots of saaaaaaand."
}));
- AddStep("message from admin", () => testChannel.AddNewMessages(new Message(sequence++)
+ AddStep("message from admin", () => testChannel.AddNewMessages(new Message(messageIdSequence++)
{
Sender = admin,
Content = "Okay okay, calm down guys. Let's do this!"
}));
- AddStep("message from long username", () => testChannel.AddNewMessages(new Message(sequence++)
+ AddStep("message from long username", () => testChannel.AddNewMessages(new Message(messageIdSequence++)
{
Sender = longUsernameUser,
Content = "Hi guys, my new username is lit!"
}));
- AddStep("message with new date", () => testChannel.AddNewMessages(new Message(sequence++)
+ AddStep("message with new date", () => testChannel.AddNewMessages(new Message(messageIdSequence++)
{
Sender = longUsernameUser,
Content = "Message from the future!",
Timestamp = DateTimeOffset.Now
}));
- AddUntilStep("ensure still scrolled to bottom", () => chatDisplay.ScrolledToBottom);
+ checkScrolledToBottom();
const int messages_per_call = 10;
AddRepeatStep("add many messages", () =>
{
for (int i = 0; i < messages_per_call; i++)
{
- testChannel.AddNewMessages(new Message(sequence++)
+ testChannel.AddNewMessages(new Message(messageIdSequence++)
{
Sender = longUsernameUser,
Content = "Many messages! " + Guid.NewGuid(),
@@ -153,9 +158,133 @@ namespace osu.Game.Tests.Visual.Online
return true;
});
- AddUntilStep("ensure still scrolled to bottom", () => chatDisplay.ScrolledToBottom);
+ checkScrolledToBottom();
}
+ ///
+ /// Tests that when a message gets wrapped by the chat display getting contracted while scrolled to bottom, the chat will still keep scrolling down.
+ ///
+ [Test]
+ public void TestMessageWrappingKeepsAutoScrolling()
+ {
+ fillChat();
+
+ // send message with short words for text wrapping to occur when contracting chat.
+ sendMessage();
+
+ AddStep("contract chat", () => chatDisplay.Width -= 100);
+ checkScrolledToBottom();
+
+ AddStep("send another message", () => testChannel.AddNewMessages(new Message(messageIdSequence++)
+ {
+ Sender = admin,
+ Content = "As we were saying...",
+ }));
+
+ checkScrolledToBottom();
+ }
+
+ [Test]
+ public void TestUserScrollOverride()
+ {
+ fillChat();
+
+ sendMessage();
+ checkScrolledToBottom();
+
+ AddStep("User scroll up", () =>
+ {
+ InputManager.MoveMouseTo(chatDisplay.ScreenSpaceDrawQuad.Centre);
+ InputManager.PressButton(MouseButton.Left);
+ InputManager.MoveMouseTo(chatDisplay.ScreenSpaceDrawQuad.Centre + new Vector2(0, chatDisplay.ScreenSpaceDrawQuad.Height));
+ InputManager.ReleaseButton(MouseButton.Left);
+ });
+
+ checkNotScrolledToBottom();
+ sendMessage();
+ checkNotScrolledToBottom();
+
+ AddRepeatStep("User scroll to bottom", () =>
+ {
+ InputManager.MoveMouseTo(chatDisplay.ScreenSpaceDrawQuad.Centre);
+ InputManager.PressButton(MouseButton.Left);
+ InputManager.MoveMouseTo(chatDisplay.ScreenSpaceDrawQuad.Centre - new Vector2(0, chatDisplay.ScreenSpaceDrawQuad.Height));
+ InputManager.ReleaseButton(MouseButton.Left);
+ }, 5);
+
+ checkScrolledToBottom();
+ sendMessage();
+ checkScrolledToBottom();
+ }
+
+ [Test]
+ public void TestLocalEchoMessageResetsScroll()
+ {
+ fillChat();
+
+ sendMessage();
+ checkScrolledToBottom();
+
+ AddStep("User scroll up", () =>
+ {
+ InputManager.MoveMouseTo(chatDisplay.ScreenSpaceDrawQuad.Centre);
+ InputManager.PressButton(MouseButton.Left);
+ InputManager.MoveMouseTo(chatDisplay.ScreenSpaceDrawQuad.Centre + new Vector2(0, chatDisplay.ScreenSpaceDrawQuad.Height));
+ InputManager.ReleaseButton(MouseButton.Left);
+ });
+
+ checkNotScrolledToBottom();
+ sendMessage();
+ checkNotScrolledToBottom();
+
+ sendLocalMessage();
+ checkScrolledToBottom();
+
+ sendMessage();
+ checkScrolledToBottom();
+ }
+
+ private void fillChat()
+ {
+ AddStep("fill chat", () =>
+ {
+ for (int i = 0; i < 10; i++)
+ {
+ testChannel.AddNewMessages(new Message(messageIdSequence++)
+ {
+ Sender = longUsernameUser,
+ Content = $"some stuff {Guid.NewGuid()}",
+ });
+ }
+ });
+
+ checkScrolledToBottom();
+ }
+
+ private void sendMessage()
+ {
+ AddStep("send lorem ipsum", () => testChannel.AddNewMessages(new Message(messageIdSequence++)
+ {
+ Sender = longUsernameUser,
+ Content = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce et bibendum velit.",
+ }));
+ }
+
+ private void sendLocalMessage()
+ {
+ AddStep("send local echo", () => testChannel.AddLocalEcho(new LocalEchoMessage
+ {
+ Sender = longUsernameUser,
+ Content = "This is a local echo message.",
+ }));
+ }
+
+ private void checkScrolledToBottom() =>
+ AddUntilStep("is scrolled to bottom", () => chatDisplay.ScrolledToBottom);
+
+ private void checkNotScrolledToBottom() =>
+ AddUntilStep("not scrolled to bottom", () => !chatDisplay.ScrolledToBottom);
+
private class TestStandAloneChatDisplay : StandAloneChatDisplay
{
public TestStandAloneChatDisplay(bool textbox = false)
@@ -165,7 +294,7 @@ namespace osu.Game.Tests.Visual.Online
protected DrawableChannel DrawableChannel => InternalChildren.OfType().First();
- protected OsuScrollContainer ScrollContainer => (OsuScrollContainer)((Container)DrawableChannel.Child).Child;
+ protected UserTrackingScrollContainer ScrollContainer => (UserTrackingScrollContainer)((Container)DrawableChannel.Child).Child;
public FillFlowContainer FillFlow => (FillFlowContainer)ScrollContainer.Child;
diff --git a/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs b/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs
index 9bb29541ec..e9e826e62f 100644
--- a/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs
+++ b/osu.Game.Tests/Visual/Online/TestSceneVotePill.cs
@@ -7,6 +7,8 @@ using osu.Game.Overlays.Comments;
using osu.Game.Online.API.Requests.Responses;
using osu.Framework.Allocation;
using osu.Game.Overlays;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Graphics.Containers;
namespace osu.Game.Tests.Visual.Online
{
@@ -16,13 +18,33 @@ namespace osu.Game.Tests.Visual.Online
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue);
- private VotePill votePill;
+ [Cached]
+ private LoginOverlay login;
+
+ private TestPill votePill;
+ private readonly Container pillContainer;
+
+ public TestSceneVotePill()
+ {
+ AddRange(new Drawable[]
+ {
+ pillContainer = new Container
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ AutoSizeAxes = Axes.Both
+ },
+ login = new LoginOverlay()
+ });
+ }
[Test]
public void TestUserCommentPill()
{
+ AddStep("Hide login overlay", () => login.Hide());
AddStep("Log in", logIn);
AddStep("User comment", () => addVotePill(getUserComment()));
+ AddAssert("Background is transparent", () => votePill.Background.Alpha == 0);
AddStep("Click", () => votePill.Click());
AddAssert("Not loading", () => !votePill.IsLoading);
}
@@ -30,8 +52,10 @@ namespace osu.Game.Tests.Visual.Online
[Test]
public void TestRandomCommentPill()
{
+ AddStep("Hide login overlay", () => login.Hide());
AddStep("Log in", logIn);
AddStep("Random comment", () => addVotePill(getRandomComment()));
+ AddAssert("Background is visible", () => votePill.Background.Alpha == 1);
AddStep("Click", () => votePill.Click());
AddAssert("Loading", () => votePill.IsLoading);
}
@@ -39,10 +63,11 @@ namespace osu.Game.Tests.Visual.Online
[Test]
public void TestOfflineRandomCommentPill()
{
+ AddStep("Hide login overlay", () => login.Hide());
AddStep("Log out", API.Logout);
AddStep("Random comment", () => addVotePill(getRandomComment()));
AddStep("Click", () => votePill.Click());
- AddAssert("Not loading", () => !votePill.IsLoading);
+ AddAssert("Login overlay is visible", () => login.State.Value == Visibility.Visible);
}
private void logIn() => API.Login("localUser", "password");
@@ -63,12 +88,22 @@ namespace osu.Game.Tests.Visual.Online
private void addVotePill(Comment comment)
{
- Clear();
- Add(votePill = new VotePill(comment)
+ pillContainer.Clear();
+ pillContainer.Child = votePill = new TestPill(comment)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
- });
+ };
+ }
+
+ private class TestPill : VotePill
+ {
+ public new Box Background => base.Background;
+
+ public TestPill(Comment comment)
+ : base(comment)
+ {
+ }
}
}
}
diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs
index 008c862cc3..618447eae2 100644
--- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs
+++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs
@@ -4,7 +4,6 @@
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
-using osu.Framework.Graphics;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Game.Graphics.Containers;
@@ -28,12 +27,7 @@ namespace osu.Game.Tests.Visual.Playlists
{
base.SetUpSteps();
- AddStep("push screen", () => LoadScreen(loungeScreen = new PlaylistsLoungeSubScreen
- {
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- Width = 0.5f,
- }));
+ AddStep("push screen", () => LoadScreen(loungeScreen = new PlaylistsLoungeSubScreen()));
AddUntilStep("wait for present", () => loungeScreen.IsCurrentScreen());
}
diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs
new file mode 100644
index 0000000000..e7fa7d9235
--- /dev/null
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModIcon.cs
@@ -0,0 +1,21 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using NUnit.Framework;
+using osu.Game.Rulesets.Osu.Mods;
+using osu.Game.Rulesets.UI;
+
+namespace osu.Game.Tests.Visual.UserInterface
+{
+ public class TestSceneModIcon : OsuTestScene
+ {
+ [Test]
+ public void TestChangeModType()
+ {
+ ModIcon icon = null;
+
+ AddStep("create mod icon", () => Child = icon = new ModIcon(new OsuModDoubleTime()));
+ AddStep("change mod", () => icon.Mod = new OsuModEasy());
+ }
+ }
+}
diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs
index 0d0acbb8f4..37ebc72984 100644
--- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs
@@ -8,6 +8,7 @@ using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
@@ -38,27 +39,7 @@ namespace osu.Game.Tests.Visual.UserInterface
}
[SetUp]
- public void SetUp() => Schedule(() =>
- {
- Children = new Drawable[]
- {
- modSelect = new TestModSelectOverlay
- {
- Origin = Anchor.BottomCentre,
- Anchor = Anchor.BottomCentre,
- SelectedMods = { BindTarget = SelectedMods }
- },
-
- modDisplay = new ModDisplay
- {
- Anchor = Anchor.TopRight,
- Origin = Anchor.TopRight,
- AutoSizeAxes = Axes.Both,
- Position = new Vector2(-5, 25),
- Current = { BindTarget = modSelect.SelectedMods }
- }
- };
- });
+ public void SetUp() => Schedule(() => createDisplay(() => new TestModSelectOverlay()));
[SetUpSteps]
public void SetUpSteps()
@@ -66,6 +47,32 @@ namespace osu.Game.Tests.Visual.UserInterface
AddStep("show", () => modSelect.Show());
}
+ [Test]
+ public void TestAnimationFlushOnClose()
+ {
+ changeRuleset(0);
+
+ AddStep("Select all fun mods", () =>
+ {
+ modSelect.ModSectionsContainer
+ .Single(c => c.ModType == ModType.DifficultyIncrease)
+ .SelectAll();
+ });
+
+ AddUntilStep("many mods selected", () => modDisplay.Current.Value.Count >= 5);
+
+ AddStep("trigger deselect and close overlay", () =>
+ {
+ modSelect.ModSectionsContainer
+ .Single(c => c.ModType == ModType.DifficultyIncrease)
+ .DeselectAll();
+
+ modSelect.Hide();
+ });
+
+ AddAssert("all mods deselected", () => modDisplay.Current.Value.Count == 0);
+ }
+
[Test]
public void TestOsuMods()
{
@@ -134,6 +141,8 @@ namespace osu.Game.Tests.Visual.UserInterface
[Test]
public void TestExternallySetCustomizedMod()
{
+ changeRuleset(0);
+
AddStep("set customized mod externally", () => SelectedMods.Value = new[] { new OsuModDoubleTime { SpeedChange = { Value = 1.01 } } });
AddAssert("ensure button is selected and customized accordingly", () =>
@@ -143,6 +152,46 @@ namespace osu.Game.Tests.Visual.UserInterface
});
}
+ [Test]
+ public void TestNonStacked()
+ {
+ changeRuleset(0);
+
+ AddStep("create overlay", () => createDisplay(() => new TestNonStackedModSelectOverlay()));
+
+ AddStep("show", () => modSelect.Show());
+
+ AddAssert("ensure all buttons are spread out", () => modSelect.ChildrenOfType().All(m => m.Mods.Length <= 1));
+ }
+
+ [Test]
+ public void TestChangeIsValidChangesButtonVisibility()
+ {
+ changeRuleset(0);
+
+ AddAssert("double time visible", () => modSelect.ChildrenOfType().Any(b => b.Mods.Any(m => m is OsuModDoubleTime)));
+
+ AddStep("make double time invalid", () => modSelect.IsValidMod = m => !(m is OsuModDoubleTime));
+ AddUntilStep("double time not visible", () => modSelect.ChildrenOfType().All(b => !b.Mods.Any(m => m is OsuModDoubleTime)));
+ AddAssert("nightcore still visible", () => modSelect.ChildrenOfType().Any(b => b.Mods.Any(m => m is OsuModNightcore)));
+
+ AddStep("make double time valid again", () => modSelect.IsValidMod = m => true);
+ AddUntilStep("double time visible", () => modSelect.ChildrenOfType().Any(b => b.Mods.Any(m => m is OsuModDoubleTime)));
+ AddAssert("nightcore still visible", () => modSelect.ChildrenOfType().Any(b => b.Mods.Any(m => m is OsuModNightcore)));
+ }
+
+ [Test]
+ public void TestChangeIsValidPreservesSelection()
+ {
+ changeRuleset(0);
+
+ AddStep("select DT + HD", () => SelectedMods.Value = new Mod[] { new OsuModDoubleTime(), new OsuModHidden() });
+ AddAssert("DT + HD selected", () => modSelect.ChildrenOfType().Count(b => b.Selected) == 2);
+
+ AddStep("make NF invalid", () => modSelect.IsValidMod = m => !(m is ModNoFail));
+ AddAssert("DT + HD still selected", () => modSelect.ChildrenOfType().Count(b => b.Selected) == 2);
+ }
+
private void testSingleMod(Mod mod)
{
selectNext(mod);
@@ -262,12 +311,37 @@ namespace osu.Game.Tests.Visual.UserInterface
private void checkLabelColor(Func getColour) => AddAssert("check label has expected colour", () => modSelect.MultiplierLabel.Colour.AverageColour == getColour());
- private class TestModSelectOverlay : ModSelectOverlay
+ private void createDisplay(Func createOverlayFunc)
+ {
+ SelectedMods.Value = Array.Empty();
+ Children = new Drawable[]
+ {
+ modSelect = createOverlayFunc().With(d =>
+ {
+ d.Origin = Anchor.BottomCentre;
+ d.Anchor = Anchor.BottomCentre;
+ d.SelectedMods.BindTarget = SelectedMods;
+ }),
+ modDisplay = new ModDisplay
+ {
+ Anchor = Anchor.TopRight,
+ Origin = Anchor.TopRight,
+ AutoSizeAxes = Axes.Both,
+ Position = new Vector2(-5, 25),
+ Current = { BindTarget = modSelect.SelectedMods }
+ }
+ };
+ }
+
+ private class TestModSelectOverlay : LocalPlayerModSelectOverlay
{
public new Bindable> SelectedMods => base.SelectedMods;
public bool AllLoaded => ModSectionsContainer.Children.All(c => c.ModIconsLoaded);
+ public new FillFlowContainer ModSectionsContainer =>
+ base.ModSectionsContainer;
+
public ModButton GetModButton(Mod mod)
{
var section = ModSectionsContainer.Children.Single(s => s.ModType == mod.Type);
@@ -280,5 +354,10 @@ namespace osu.Game.Tests.Visual.UserInterface
public new Color4 LowMultiplierColour => base.LowMultiplierColour;
public new Color4 HighMultiplierColour => base.HighMultiplierColour;
}
+
+ private class TestNonStackedModSelectOverlay : TestModSelectOverlay
+ {
+ protected override bool Stacked => false;
+ }
}
}
diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs
index 8614700b15..89f9b7381b 100644
--- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs
@@ -151,7 +151,7 @@ namespace osu.Game.Tests.Visual.UserInterface
AddUntilStep("wait for ready", () => modSelect.State.Value == Visibility.Visible && modSelect.ButtonsLoaded);
}
- private class TestModSelectOverlay : ModSelectOverlay
+ private class TestModSelectOverlay : LocalPlayerModSelectOverlay
{
public new VisibilityContainer ModSettingsContainer => base.ModSettingsContainer;
public new TriangleButton CustomiseButton => base.CustomiseButton;
diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs
new file mode 100644
index 0000000000..5c2e6e457d
--- /dev/null
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs
@@ -0,0 +1,122 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Colour;
+using osu.Framework.Graphics.Shapes;
+using osu.Game.Graphics.Containers;
+using osuTK.Graphics;
+
+namespace osu.Game.Tests.Visual.UserInterface
+{
+ public class TestSceneSectionsContainer : OsuManualInputManagerTestScene
+ {
+ private readonly SectionsContainer container;
+ private float custom;
+ private const float header_height = 100;
+
+ public TestSceneSectionsContainer()
+ {
+ container = new SectionsContainer
+ {
+ RelativeSizeAxes = Axes.Y,
+ Width = 300,
+ Origin = Anchor.Centre,
+ Anchor = Anchor.Centre,
+ FixedHeader = new Box
+ {
+ Alpha = 0.5f,
+ Width = 300,
+ Height = header_height,
+ Colour = Color4.Red
+ }
+ };
+ container.SelectedSection.ValueChanged += section =>
+ {
+ if (section.OldValue != null)
+ section.OldValue.Selected = false;
+ if (section.NewValue != null)
+ section.NewValue.Selected = true;
+ };
+ Add(container);
+ }
+
+ [Test]
+ public void TestSelection()
+ {
+ AddStep("clear", () => container.Clear());
+ AddStep("add 1/8th", () => append(1 / 8.0f));
+ AddStep("add third", () => append(1 / 3.0f));
+ AddStep("add half", () => append(1 / 2.0f));
+ AddStep("add full", () => append(1));
+ AddSliderStep("set custom", 0.1f, 1.1f, 0.5f, i => custom = i);
+ AddStep("add custom", () => append(custom));
+ AddStep("scroll to previous", () => container.ScrollTo(
+ container.Children.Reverse().SkipWhile(s => s != container.SelectedSection.Value).Skip(1).FirstOrDefault() ?? container.Children.First()
+ ));
+ AddStep("scroll to next", () => container.ScrollTo(
+ container.Children.SkipWhile(s => s != container.SelectedSection.Value).Skip(1).FirstOrDefault() ?? container.Children.Last()
+ ));
+ AddStep("scroll up", () => triggerUserScroll(1));
+ AddStep("scroll down", () => triggerUserScroll(-1));
+ }
+
+ [Test]
+ public void TestCorrectSectionSelected()
+ {
+ const int sections_count = 11;
+ float[] alternating = { 0.07f, 0.33f, 0.16f, 0.33f };
+ AddStep("clear", () => container.Clear());
+ AddStep("fill with sections", () =>
+ {
+ for (int i = 0; i < sections_count; i++)
+ append(alternating[i % alternating.Length]);
+ });
+
+ void step(int scrollIndex)
+ {
+ AddStep($"scroll to section {scrollIndex + 1}", () => container.ScrollTo(container.Children[scrollIndex]));
+ AddUntilStep("correct section selected", () => container.SelectedSection.Value == container.Children[scrollIndex]);
+ }
+
+ for (int i = 1; i < sections_count; i++)
+ step(i);
+ for (int i = sections_count - 2; i >= 0; i--)
+ step(i);
+
+ AddStep("scroll almost to end", () => container.ScrollTo(container.Children[sections_count - 2]));
+ AddUntilStep("correct section selected", () => container.SelectedSection.Value == container.Children[sections_count - 2]);
+ AddStep("scroll down", () => triggerUserScroll(-1));
+ AddUntilStep("correct section selected", () => container.SelectedSection.Value == container.Children[sections_count - 1]);
+ }
+
+ private static readonly ColourInfo selected_colour = ColourInfo.GradientVertical(Color4.Yellow, Color4.Gold);
+ private static readonly ColourInfo default_colour = ColourInfo.GradientVertical(Color4.White, Color4.DarkGray);
+
+ private void append(float multiplier)
+ {
+ container.Add(new TestSection
+ {
+ Width = 300,
+ Height = (container.ChildSize.Y - header_height) * multiplier,
+ Colour = default_colour
+ });
+ }
+
+ private void triggerUserScroll(float direction)
+ {
+ InputManager.MoveMouseTo(container);
+ InputManager.ScrollVerticalBy(direction);
+ }
+
+ private class TestSection : Box
+ {
+ public bool Selected
+ {
+ set => Colour = value ? selected_colour : default_colour;
+ }
+ }
+ }
+}
diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj
index c0c0578391..d29ed94b5f 100644
--- a/osu.Game.Tests/osu.Game.Tests.csproj
+++ b/osu.Game.Tests/osu.Game.Tests.csproj
@@ -7,6 +7,7 @@
+
WinExe
diff --git a/osu.Game.Tournament.Tests/Components/TestSceneTournamentModDisplay.cs b/osu.Game.Tournament.Tests/Components/TestSceneTournamentModDisplay.cs
new file mode 100644
index 0000000000..b4d9fa4222
--- /dev/null
+++ b/osu.Game.Tournament.Tests/Components/TestSceneTournamentModDisplay.cs
@@ -0,0 +1,60 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Game.Beatmaps;
+using osu.Game.Online.API;
+using osu.Game.Online.API.Requests;
+using osu.Game.Online.API.Requests.Responses;
+using osu.Game.Rulesets;
+using osu.Game.Tournament.Components;
+
+namespace osu.Game.Tournament.Tests.Components
+{
+ public class TestSceneTournamentModDisplay : TournamentTestScene
+ {
+ [Resolved]
+ private IAPIProvider api { get; set; }
+
+ [Resolved]
+ private RulesetStore rulesets { get; set; }
+
+ private FillFlowContainer fillFlow;
+
+ private BeatmapInfo beatmap;
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ var req = new GetBeatmapRequest(new BeatmapInfo { OnlineBeatmapID = 490154 });
+ req.Success += success;
+ api.Queue(req);
+
+ Add(fillFlow = new FillFlowContainer
+ {
+ RelativeSizeAxes = Axes.Both,
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Direction = FillDirection.Full,
+ Spacing = new osuTK.Vector2(10)
+ });
+ }
+
+ private void success(APIBeatmap apiBeatmap)
+ {
+ beatmap = apiBeatmap.ToBeatmap(rulesets);
+ var mods = rulesets.GetRuleset(Ladder.Ruleset.Value.ID ?? 0).CreateInstance().GetAllMods();
+
+ foreach (var mod in mods)
+ {
+ fillFlow.Add(new TournamentBeatmapPanel(beatmap, mod.Acronym)
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre
+ });
+ }
+ }
+ }
+}
diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs
index b240ef3ae5..0da8d1eb4a 100644
--- a/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs
+++ b/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs
@@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System;
+using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Tournament.Components;
@@ -16,5 +18,23 @@ namespace osu.Game.Tournament.Tests.Screens
Add(new TourneyVideo("main") { RelativeSizeAxes = Axes.Both });
Add(new ScheduleScreen());
}
+
+ [Test]
+ public void TestCurrentMatchTime()
+ {
+ setMatchDate(TimeSpan.FromDays(-1));
+ setMatchDate(TimeSpan.FromSeconds(5));
+ setMatchDate(TimeSpan.FromMinutes(4));
+ setMatchDate(TimeSpan.FromHours(3));
+ }
+
+ private void setMatchDate(TimeSpan relativeTime)
+ // Humanizer cannot handle negative timespans.
+ => AddStep($"start time is {relativeTime}", () =>
+ {
+ var match = CreateSampleMatch();
+ match.Date.Value = DateTimeOffset.Now + relativeTime;
+ Ladder.CurrentMatch.Value = match;
+ });
}
}
diff --git a/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs
index 477bf4bd63..d1197b1a61 100644
--- a/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs
+++ b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs
@@ -9,7 +9,6 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
-using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Framework.Localisation;
using osu.Game.Beatmaps;
@@ -23,7 +22,7 @@ namespace osu.Game.Tournament.Components
public class TournamentBeatmapPanel : CompositeDrawable
{
public readonly BeatmapInfo Beatmap;
- private readonly string mods;
+ private readonly string mod;
private const float horizontal_padding = 10;
private const float vertical_padding = 10;
@@ -33,12 +32,12 @@ namespace osu.Game.Tournament.Components
private readonly Bindable currentMatch = new Bindable();
private Box flash;
- public TournamentBeatmapPanel(BeatmapInfo beatmap, string mods = null)
+ public TournamentBeatmapPanel(BeatmapInfo beatmap, string mod = null)
{
if (beatmap == null) throw new ArgumentNullException(nameof(beatmap));
Beatmap = beatmap;
- this.mods = mods;
+ this.mod = mod;
Width = 400;
Height = HEIGHT;
}
@@ -122,23 +121,15 @@ namespace osu.Game.Tournament.Components
},
});
- if (!string.IsNullOrEmpty(mods))
+ if (!string.IsNullOrEmpty(mod))
{
- AddInternal(new Container
+ AddInternal(new TournamentModIcon(mod)
{
- RelativeSizeAxes = Axes.Y,
- Width = 60,
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Margin = new MarginPadding(10),
- Child = new Sprite
- {
- FillMode = FillMode.Fit,
- RelativeSizeAxes = Axes.Both,
- Anchor = Anchor.CentreRight,
- Origin = Anchor.CentreRight,
- Texture = textures.Get($"mods/{mods}"),
- }
+ Width = 60,
+ RelativeSizeAxes = Axes.Y,
});
}
}
diff --git a/osu.Game.Tournament/Components/TournamentModIcon.cs b/osu.Game.Tournament/Components/TournamentModIcon.cs
new file mode 100644
index 0000000000..43ac92d285
--- /dev/null
+++ b/osu.Game.Tournament/Components/TournamentModIcon.cs
@@ -0,0 +1,65 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Sprites;
+using osu.Framework.Graphics.Textures;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.UI;
+using osu.Game.Tournament.Models;
+using osuTK;
+
+namespace osu.Game.Tournament.Components
+{
+ ///
+ /// Mod icon displayed in tournament usages, allowing user overridden graphics.
+ ///
+ public class TournamentModIcon : CompositeDrawable
+ {
+ private readonly string modAcronym;
+
+ [Resolved]
+ private RulesetStore rulesets { get; set; }
+
+ public TournamentModIcon(string modAcronym)
+ {
+ this.modAcronym = modAcronym;
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(TextureStore textures, LadderInfo ladderInfo)
+ {
+ var customTexture = textures.Get($"mods/{modAcronym}");
+
+ if (customTexture != null)
+ {
+ AddInternal(new Sprite
+ {
+ FillMode = FillMode.Fit,
+ RelativeSizeAxes = Axes.Both,
+ Anchor = Anchor.CentreRight,
+ Origin = Anchor.CentreRight,
+ Texture = customTexture
+ });
+
+ return;
+ }
+
+ var ruleset = rulesets.GetRuleset(ladderInfo.Ruleset.Value?.ID ?? 0);
+ var modIcon = ruleset?.CreateInstance().GetAllMods().FirstOrDefault(mod => mod.Acronym == modAcronym);
+
+ if (modIcon == null)
+ return;
+
+ AddInternal(new ModIcon(modIcon, false)
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Scale = new Vector2(0.5f)
+ });
+ }
+ }
+}
diff --git a/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs b/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs
index 88289ad6bd..c1d8c8ddd3 100644
--- a/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs
+++ b/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs
@@ -192,12 +192,7 @@ namespace osu.Game.Tournament.Screens.Schedule
Origin = Anchor.CentreLeft,
Children = new Drawable[]
{
- new TournamentSpriteText
- {
- Text = "Starting ",
- Font = OsuFont.Torus.With(size: 24, weight: FontWeight.Regular)
- },
- new DrawableDate(match.NewValue.Date.Value)
+ new ScheduleMatchDate(match.NewValue.Date.Value)
{
Font = OsuFont.Torus.With(size: 24, weight: FontWeight.Regular)
}
@@ -251,6 +246,18 @@ namespace osu.Game.Tournament.Screens.Schedule
}
}
+ public class ScheduleMatchDate : DrawableDate
+ {
+ public ScheduleMatchDate(DateTimeOffset date, float textSize = OsuFont.DEFAULT_FONT_SIZE, bool italic = true)
+ : base(date, textSize, italic)
+ {
+ }
+
+ protected override string Format() => Date < DateTimeOffset.Now
+ ? $"Started {base.Format()}"
+ : $"Starting {base.Format()}";
+ }
+
public class ScheduleContainer : Container
{
protected override Container Content => content;
diff --git a/osu.Game/Beatmaps/Beatmap.cs b/osu.Game/Beatmaps/Beatmap.cs
index be2006e67a..e5b6a4bc44 100644
--- a/osu.Game/Beatmaps/Beatmap.cs
+++ b/osu.Game/Beatmaps/Beatmap.cs
@@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System;
using osu.Game.Beatmaps.Timing;
using osu.Game.Rulesets.Objects;
using System.Collections.Generic;
@@ -48,17 +49,34 @@ namespace osu.Game.Beatmaps
public virtual IEnumerable GetStatistics() => Enumerable.Empty();
+ public double GetMostCommonBeatLength()
+ {
+ // The last playable time in the beatmap - the last timing point extends to this time.
+ // Note: This is more accurate and may present different results because osu-stable didn't have the ability to calculate slider durations in this context.
+ double lastTime = HitObjects.LastOrDefault()?.GetEndTime() ?? ControlPointInfo.TimingPoints.LastOrDefault()?.Time ?? 0;
+
+ var mostCommon =
+ // Construct a set of (beatLength, duration) tuples for each individual timing point.
+ ControlPointInfo.TimingPoints.Select((t, i) =>
+ {
+ if (t.Time > lastTime)
+ return (beatLength: t.BeatLength, 0);
+
+ var nextTime = i == ControlPointInfo.TimingPoints.Count - 1 ? lastTime : ControlPointInfo.TimingPoints[i + 1].Time;
+ return (beatLength: t.BeatLength, duration: nextTime - t.Time);
+ })
+ // Aggregate durations into a set of (beatLength, duration) tuples for each beat length
+ .GroupBy(t => Math.Round(t.beatLength * 1000) / 1000)
+ .Select(g => (beatLength: g.Key, duration: g.Sum(t => t.duration)))
+ // Get the most common one, or 0 as a suitable default
+ .OrderByDescending(i => i.duration).FirstOrDefault();
+
+ return mostCommon.beatLength;
+ }
+
IBeatmap IBeatmap.Clone() => Clone();
- public Beatmap Clone()
- {
- var clone = (Beatmap)MemberwiseClone();
-
- clone.ControlPointInfo = ControlPointInfo.CreateCopy();
- // todo: deep clone other elements as required.
-
- return clone;
- }
+ public Beatmap Clone() => (Beatmap)MemberwiseClone();
}
public class Beatmap : Beatmap
diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs
index 42418e532b..b934ac556d 100644
--- a/osu.Game/Beatmaps/BeatmapManager.cs
+++ b/osu.Game/Beatmaps/BeatmapManager.cs
@@ -451,7 +451,7 @@ namespace osu.Game.Beatmaps
// TODO: this should be done in a better place once we actually need to dynamically update it.
beatmap.BeatmapInfo.StarDifficulty = ruleset?.CreateInstance().CreateDifficultyCalculator(new DummyConversionBeatmap(beatmap)).Calculate().StarRating ?? 0;
beatmap.BeatmapInfo.Length = calculateLength(beatmap);
- beatmap.BeatmapInfo.BPM = beatmap.ControlPointInfo.BPMMode;
+ beatmap.BeatmapInfo.BPM = 60000 / beatmap.GetMostCommonBeatLength();
beatmapInfos.Add(beatmap.BeatmapInfo);
}
diff --git a/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs b/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs
index e90ccbb805..7c4b344c9e 100644
--- a/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs
+++ b/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs
@@ -7,7 +7,6 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
-using Dapper;
using Microsoft.Data.Sqlite;
using osu.Framework.Development;
using osu.Framework.IO.Network;
@@ -154,20 +153,31 @@ namespace osu.Game.Beatmaps
{
using (var db = new SqliteConnection(storage.GetDatabaseConnectionString("online")))
{
- var found = db.QuerySingleOrDefault(
- "SELECT * FROM osu_beatmaps WHERE checksum = @MD5Hash OR beatmap_id = @OnlineBeatmapID OR filename = @Path", beatmap);
+ db.Open();
- if (found != null)
+ using (var cmd = db.CreateCommand())
{
- var status = (BeatmapSetOnlineStatus)found.approved;
+ cmd.CommandText = "SELECT beatmapset_id, beatmap_id, approved FROM osu_beatmaps WHERE checksum = @MD5Hash OR beatmap_id = @OnlineBeatmapID OR filename = @Path";
- beatmap.Status = status;
- beatmap.BeatmapSet.Status = status;
- beatmap.BeatmapSet.OnlineBeatmapSetID = found.beatmapset_id;
- beatmap.OnlineBeatmapID = found.beatmap_id;
+ cmd.Parameters.Add(new SqliteParameter("@MD5Hash", beatmap.MD5Hash));
+ cmd.Parameters.Add(new SqliteParameter("@OnlineBeatmapID", beatmap.OnlineBeatmapID ?? (object)DBNull.Value));
+ cmd.Parameters.Add(new SqliteParameter("@Path", beatmap.Path));
- LogForModel(set, $"Cached local retrieval for {beatmap}.");
- return true;
+ using (var reader = cmd.ExecuteReader())
+ {
+ if (reader.Read())
+ {
+ var status = (BeatmapSetOnlineStatus)reader.GetByte(2);
+
+ beatmap.Status = status;
+ beatmap.BeatmapSet.Status = status;
+ beatmap.BeatmapSet.OnlineBeatmapSetID = reader.GetInt32(0);
+ beatmap.OnlineBeatmapID = reader.GetInt32(1);
+
+ LogForModel(set, $"Cached local retrieval for {beatmap}.");
+ return true;
+ }
+ }
}
}
}
diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs
index e8a91e4001..5cc60a5758 100644
--- a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs
+++ b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs
@@ -101,13 +101,6 @@ namespace osu.Game.Beatmaps.ControlPoints
public double BPMMinimum =>
60000 / (TimingPoints.OrderByDescending(c => c.BeatLength).FirstOrDefault() ?? TimingControlPoint.DEFAULT).BeatLength;
- ///
- /// Finds the mode BPM (most common BPM) represented by the control points.
- ///
- [JsonIgnore]
- public double BPMMode =>
- 60000 / (TimingPoints.GroupBy(c => c.BeatLength).OrderByDescending(grp => grp.Count()).FirstOrDefault()?.FirstOrDefault() ?? TimingControlPoint.DEFAULT).BeatLength;
-
///
/// Remove all s and return to a pristine state.
///
diff --git a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs
index 9a244c8bb2..b9bf6823b5 100644
--- a/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs
+++ b/osu.Game/Beatmaps/Formats/LegacyStoryboardDecoder.cs
@@ -139,7 +139,7 @@ namespace osu.Game.Beatmaps.Formats
// this is random as hell but taken straight from osu-stable.
frameDelay = Math.Round(0.015 * frameDelay) * 1.186 * (1000 / 60f);
- var loopType = split.Length > 8 ? (AnimationLoopType)Enum.Parse(typeof(AnimationLoopType), split[8]) : AnimationLoopType.LoopForever;
+ var loopType = split.Length > 8 ? parseAnimationLoopType(split[8]) : AnimationLoopType.LoopForever;
storyboardSprite = new StoryboardAnimation(path, origin, new Vector2(x, y), frameCount, frameDelay, loopType);
storyboard.GetLayer(layer).Add(storyboardSprite);
break;
@@ -341,6 +341,12 @@ namespace osu.Game.Beatmaps.Formats
}
}
+ private AnimationLoopType parseAnimationLoopType(string value)
+ {
+ var parsed = (AnimationLoopType)Enum.Parse(typeof(AnimationLoopType), value);
+ return Enum.IsDefined(typeof(AnimationLoopType), parsed) ? parsed : AnimationLoopType.LoopForever;
+ }
+
private void handleVariables(string line)
{
var pair = SplitKeyVal(line, '=');
diff --git a/osu.Game/Beatmaps/IBeatmap.cs b/osu.Game/Beatmaps/IBeatmap.cs
index 8f27e0b0e9..9847ea020a 100644
--- a/osu.Game/Beatmaps/IBeatmap.cs
+++ b/osu.Game/Beatmaps/IBeatmap.cs
@@ -24,7 +24,7 @@ namespace osu.Game.Beatmaps
///
/// The control points in this beatmap.
///
- ControlPointInfo ControlPointInfo { get; }
+ ControlPointInfo ControlPointInfo { get; set; }
///
/// The breaks in this beatmap.
@@ -47,6 +47,11 @@ namespace osu.Game.Beatmaps
///
IEnumerable GetStatistics();
+ ///
+ /// Finds the most common beat length represented by the control points in this beatmap.
+ ///
+ double GetMostCommonBeatLength();
+
///
/// Creates a shallow-clone of this beatmap and returns it.
///
diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs
index 9f69ad035f..9581edfc8d 100644
--- a/osu.Game/Database/ArchiveModelManager.cs
+++ b/osu.Game/Database/ArchiveModelManager.cs
@@ -308,7 +308,7 @@ namespace osu.Game.Database
/// The model to be imported.
/// An optional archive to use for model population.
/// An optional cancellation token.
- public async Task Import(TModel item, ArchiveReader archive = null, CancellationToken cancellationToken = default) => await Task.Factory.StartNew(async () =>
+ public virtual async Task Import(TModel item, ArchiveReader archive = null, CancellationToken cancellationToken = default) => await Task.Factory.StartNew(async () =>
{
cancellationToken.ThrowIfCancellationRequested();
diff --git a/osu.Game/Extensions/TaskExtensions.cs b/osu.Game/Extensions/TaskExtensions.cs
index 4138c2757a..76a76c0c52 100644
--- a/osu.Game/Extensions/TaskExtensions.cs
+++ b/osu.Game/Extensions/TaskExtensions.cs
@@ -4,33 +4,65 @@
#nullable enable
using System;
+using System.Threading;
using System.Threading.Tasks;
-using osu.Framework.Extensions.ExceptionExtensions;
-using osu.Framework.Logging;
namespace osu.Game.Extensions
{
public static class TaskExtensions
{
///
- /// Denote a task which is to be run without local error handling logic, where failure is not catastrophic.
- /// Avoids unobserved exceptions from being fired.
+ /// Add a continuation to be performed only after the attached task has completed.
///
- /// The task.
- ///
- /// Whether errors should be logged as errors visible to users, or as debug messages.
- /// Logging as debug will essentially silence the errors on non-release builds.
- ///
- public static void CatchUnobservedExceptions(this Task task, bool logAsError = false)
+ /// The previous task to be awaited on.
+ /// The action to run.
+ /// An optional cancellation token. Will only cancel the provided action, not the sequence.
+ /// A task representing the provided action.
+ public static Task ContinueWithSequential(this Task task, Action action, CancellationToken cancellationToken = default) =>
+ task.ContinueWithSequential(() => Task.Run(action, cancellationToken), cancellationToken);
+
+ ///
+ /// Add a continuation to be performed only after the attached task has completed.
+ ///
+ /// The previous task to be awaited on.
+ /// The continuation to run. Generally should be an async function.
+ /// An optional cancellation token. Will only cancel the provided action, not the sequence.
+ /// A task representing the provided action.
+ public static Task ContinueWithSequential(this Task task, Func continuationFunction, CancellationToken cancellationToken = default)
{
+ var tcs = new TaskCompletionSource();
+
task.ContinueWith(t =>
{
- Exception? exception = t.Exception?.AsSingular();
- if (logAsError)
- Logger.Error(exception, $"Error running task: {exception?.Message ?? "(unknown)"}", LoggingTarget.Runtime, true);
+ // the previous task has finished execution or been cancelled, so we can run the provided continuation.
+
+ if (cancellationToken.IsCancellationRequested)
+ {
+ tcs.SetCanceled();
+ }
else
- Logger.Log($"Error running task: {exception}", LoggingTarget.Runtime, LogLevel.Debug);
- }, TaskContinuationOptions.NotOnRanToCompletion);
+ {
+ continuationFunction().ContinueWith(continuationTask =>
+ {
+ if (cancellationToken.IsCancellationRequested || continuationTask.IsCanceled)
+ {
+ tcs.TrySetCanceled();
+ }
+ else if (continuationTask.IsFaulted)
+ {
+ tcs.TrySetException(continuationTask.Exception);
+ }
+ else
+ {
+ tcs.TrySetResult(true);
+ }
+ }, cancellationToken: default);
+ }
+ }, cancellationToken: default);
+
+ // importantly, we are not returning the continuation itself but rather a task which represents its status in sequential execution order.
+ // this will not be cancelled or completed until the previous task has also.
+ return tcs.Task;
}
}
}
diff --git a/osu.Game/Graphics/Containers/ParallaxContainer.cs b/osu.Game/Graphics/Containers/ParallaxContainer.cs
index 4cd3934cde..b501e68ba1 100644
--- a/osu.Game/Graphics/Containers/ParallaxContainer.cs
+++ b/osu.Game/Graphics/Containers/ParallaxContainer.cs
@@ -24,6 +24,10 @@ namespace osu.Game.Graphics.Containers
private Bindable parallaxEnabled;
+ private const float parallax_duration = 100;
+
+ private bool firstUpdate = true;
+
public ParallaxContainer()
{
RelativeSizeAxes = Axes.Both;
@@ -60,17 +64,27 @@ namespace osu.Game.Graphics.Containers
input = GetContainingInputManager();
}
- private bool firstUpdate = true;
-
protected override void Update()
{
base.Update();
if (parallaxEnabled.Value)
{
- Vector2 offset = (input.CurrentState.Mouse == null ? Vector2.Zero : ToLocalSpace(input.CurrentState.Mouse.Position) - DrawSize / 2) * ParallaxAmount;
+ Vector2 offset = Vector2.Zero;
- const float parallax_duration = 100;
+ if (input.CurrentState.Mouse != null)
+ {
+ var sizeDiv2 = DrawSize / 2;
+
+ Vector2 relativeAmount = ToLocalSpace(input.CurrentState.Mouse.Position) - sizeDiv2;
+
+ const float base_factor = 0.999f;
+
+ relativeAmount.X = (float)(Math.Sign(relativeAmount.X) * Interpolation.Damp(0, 1, base_factor, Math.Abs(relativeAmount.X)));
+ relativeAmount.Y = (float)(Math.Sign(relativeAmount.Y) * Interpolation.Damp(0, 1, base_factor, Math.Abs(relativeAmount.Y)));
+
+ offset = relativeAmount * sizeDiv2 * ParallaxAmount;
+ }
double elapsed = Math.Clamp(Clock.ElapsedFrameTime, 0, parallax_duration);
diff --git a/osu.Game/Graphics/Containers/SectionsContainer.cs b/osu.Game/Graphics/Containers/SectionsContainer.cs
index 81968de304..8ab146efe7 100644
--- a/osu.Game/Graphics/Containers/SectionsContainer.cs
+++ b/osu.Game/Graphics/Containers/SectionsContainer.cs
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
+using System.Diagnostics;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
@@ -9,6 +10,7 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Layout;
+using osu.Framework.Utils;
namespace osu.Game.Graphics.Containers
{
@@ -20,6 +22,7 @@ namespace osu.Game.Graphics.Containers
where T : Drawable
{
public Bindable SelectedSection { get; } = new Bindable();
+ private Drawable lastClickedSection;
public Drawable ExpandableHeader
{
@@ -36,7 +39,7 @@ namespace osu.Game.Graphics.Containers
if (value == null) return;
AddInternal(expandableHeader);
- lastKnownScroll = float.NaN;
+ lastKnownScroll = null;
}
}
@@ -52,7 +55,7 @@ namespace osu.Game.Graphics.Containers
if (value == null) return;
AddInternal(fixedHeader);
- lastKnownScroll = float.NaN;
+ lastKnownScroll = null;
}
}
@@ -71,7 +74,7 @@ namespace osu.Game.Graphics.Containers
footer.Anchor |= Anchor.y2;
footer.Origin |= Anchor.y2;
scrollContainer.Add(footer);
- lastKnownScroll = float.NaN;
+ lastKnownScroll = null;
}
}
@@ -89,21 +92,26 @@ namespace osu.Game.Graphics.Containers
headerBackgroundContainer.Add(headerBackground);
- lastKnownScroll = float.NaN;
+ lastKnownScroll = null;
}
}
protected override Container Content => scrollContentContainer;
- private readonly OsuScrollContainer scrollContainer;
+ private readonly UserTrackingScrollContainer scrollContainer;
private readonly Container headerBackgroundContainer;
private readonly MarginPadding originalSectionsMargin;
private Drawable expandableHeader, fixedHeader, footer, headerBackground;
private FlowContainer scrollContentContainer;
- private float headerHeight, footerHeight;
+ private float? headerHeight, footerHeight;
- private float lastKnownScroll;
+ private float? lastKnownScroll;
+
+ ///
+ /// The percentage of the container to consider the centre-point for deciding the active section (and scrolling to a requested section).
+ ///
+ private const float scroll_y_centre = 0.1f;
public SectionsContainer()
{
@@ -128,18 +136,24 @@ namespace osu.Game.Graphics.Containers
public override void Add(T drawable)
{
base.Add(drawable);
- lastKnownScroll = float.NaN;
- headerHeight = float.NaN;
- footerHeight = float.NaN;
+
+ Debug.Assert(drawable != null);
+
+ lastKnownScroll = null;
+ headerHeight = null;
+ footerHeight = null;
}
- public void ScrollTo(Drawable section) =>
- scrollContainer.ScrollTo(scrollContainer.GetChildPosInContent(section) - (FixedHeader?.BoundingBox.Height ?? 0));
+ public void ScrollTo(Drawable section)
+ {
+ lastClickedSection = section;
+ scrollContainer.ScrollTo(scrollContainer.GetChildPosInContent(section) - scrollContainer.DisplayableContent * scroll_y_centre - (FixedHeader?.BoundingBox.Height ?? 0));
+ }
public void ScrollToTop() => scrollContainer.ScrollTo(0);
[NotNull]
- protected virtual OsuScrollContainer CreateScrollContainer() => new OsuScrollContainer();
+ protected virtual UserTrackingScrollContainer CreateScrollContainer() => new UserTrackingScrollContainer();
[NotNull]
protected virtual FlowContainer CreateScrollContentContainer() =>
@@ -156,7 +170,7 @@ namespace osu.Game.Graphics.Containers
if (source == InvalidationSource.Child && (invalidation & Invalidation.DrawSize) != 0)
{
- lastKnownScroll = -1;
+ lastKnownScroll = null;
result = true;
}
@@ -167,7 +181,10 @@ namespace osu.Game.Graphics.Containers
{
base.UpdateAfterChildren();
- float headerH = (ExpandableHeader?.LayoutSize.Y ?? 0) + (FixedHeader?.LayoutSize.Y ?? 0);
+ float fixedHeaderSize = FixedHeader?.LayoutSize.Y ?? 0;
+ float expandableHeaderSize = ExpandableHeader?.LayoutSize.Y ?? 0;
+
+ float headerH = expandableHeaderSize + fixedHeaderSize;
float footerH = Footer?.LayoutSize.Y ?? 0;
if (headerH != headerHeight || footerH != footerHeight)
@@ -183,28 +200,39 @@ namespace osu.Game.Graphics.Containers
{
lastKnownScroll = currentScroll;
+ // reset last clicked section because user started scrolling themselves
+ if (scrollContainer.UserScrolling)
+ lastClickedSection = null;
+
if (ExpandableHeader != null && FixedHeader != null)
{
- float offset = Math.Min(ExpandableHeader.LayoutSize.Y, currentScroll);
+ float offset = Math.Min(expandableHeaderSize, currentScroll);
ExpandableHeader.Y = -offset;
- FixedHeader.Y = -offset + ExpandableHeader.LayoutSize.Y;
+ FixedHeader.Y = -offset + expandableHeaderSize;
}
- headerBackgroundContainer.Height = (ExpandableHeader?.LayoutSize.Y ?? 0) + (FixedHeader?.LayoutSize.Y ?? 0);
+ headerBackgroundContainer.Height = expandableHeaderSize + fixedHeaderSize;
headerBackgroundContainer.Y = ExpandableHeader?.Y ?? 0;
- float scrollOffset = FixedHeader?.LayoutSize.Y ?? 0;
- Func diff = section => scrollContainer.GetChildPosInContent(section) - currentScroll - scrollOffset;
+ var smallestSectionHeight = Children.Count > 0 ? Children.Min(d => d.Height) : 0;
- if (scrollContainer.IsScrolledToEnd())
- {
- SelectedSection.Value = Children.LastOrDefault();
- }
+ // scroll offset is our fixed header height if we have it plus 10% of content height
+ // plus 5% to fix floating point errors and to not have a section instantly unselect when scrolling upwards
+ // but the 5% can't be bigger than our smallest section height, otherwise it won't get selected correctly
+ float selectionLenienceAboveSection = Math.Min(smallestSectionHeight / 2.0f, scrollContainer.DisplayableContent * 0.05f);
+
+ float scrollCentre = fixedHeaderSize + scrollContainer.DisplayableContent * scroll_y_centre + selectionLenienceAboveSection;
+
+ if (Precision.AlmostBigger(0, scrollContainer.Current))
+ SelectedSection.Value = lastClickedSection as T ?? Children.FirstOrDefault();
+ else if (Precision.AlmostBigger(scrollContainer.Current, scrollContainer.ScrollableExtent))
+ SelectedSection.Value = lastClickedSection as T ?? Children.LastOrDefault();
else
{
- SelectedSection.Value = Children.TakeWhile(section => diff(section) <= 0).LastOrDefault()
- ?? Children.FirstOrDefault();
+ SelectedSection.Value = Children
+ .TakeWhile(section => scrollContainer.GetChildPosInContent(section) - currentScroll - scrollCentre <= 0)
+ .LastOrDefault() ?? Children.FirstOrDefault();
}
}
}
@@ -214,8 +242,9 @@ namespace osu.Game.Graphics.Containers
if (!Children.Any()) return;
var newMargin = originalSectionsMargin;
- newMargin.Top += headerHeight;
- newMargin.Bottom += footerHeight;
+
+ newMargin.Top += (headerHeight ?? 0);
+ newMargin.Bottom += (footerHeight ?? 0);
scrollContentContainer.Margin = newMargin;
}
diff --git a/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs b/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs
new file mode 100644
index 0000000000..17506ce0f5
--- /dev/null
+++ b/osu.Game/Graphics/Containers/UserTrackingScrollContainer.cs
@@ -0,0 +1,57 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Graphics;
+
+namespace osu.Game.Graphics.Containers
+{
+ public class UserTrackingScrollContainer : UserTrackingScrollContainer
+ {
+ public UserTrackingScrollContainer()
+ {
+ }
+
+ public UserTrackingScrollContainer(Direction direction)
+ : base(direction)
+ {
+ }
+ }
+
+ public class UserTrackingScrollContainer : OsuScrollContainer
+ where T : Drawable
+ {
+ ///
+ /// Whether the last scroll event was user triggered, directly on the scroll container.
+ ///
+ public bool UserScrolling { get; private set; }
+
+ public void CancelUserScroll() => UserScrolling = false;
+
+ public UserTrackingScrollContainer()
+ {
+ }
+
+ public UserTrackingScrollContainer(Direction direction)
+ : base(direction)
+ {
+ }
+
+ protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = default)
+ {
+ UserScrolling = true;
+ base.OnUserScroll(value, animated, distanceDecay);
+ }
+
+ public new void ScrollTo(float value, bool animated = true, double? distanceDecay = null)
+ {
+ UserScrolling = false;
+ base.ScrollTo(value, animated, distanceDecay);
+ }
+
+ public new void ScrollToEnd(bool animated = true, bool allowDuringDrag = false)
+ {
+ UserScrolling = false;
+ base.ScrollToEnd(animated, allowDuringDrag);
+ }
+ }
+}
diff --git a/osu.Game/Graphics/UserInterface/DownloadButton.cs b/osu.Game/Graphics/UserInterface/DownloadButton.cs
index 5168ff646b..7a8db158c1 100644
--- a/osu.Game/Graphics/UserInterface/DownloadButton.cs
+++ b/osu.Game/Graphics/UserInterface/DownloadButton.cs
@@ -4,54 +4,38 @@
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
-using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Game.Online;
using osuTK;
namespace osu.Game.Graphics.UserInterface
{
- public class DownloadButton : OsuAnimatedButton
+ public class DownloadButton : GrayButton
{
- public readonly Bindable State = new Bindable();
-
- private readonly SpriteIcon icon;
- private readonly SpriteIcon checkmark;
- private readonly Box background;
-
[Resolved]
private OsuColour colours { get; set; }
+ public readonly Bindable State = new Bindable();
+
+ private SpriteIcon checkmark;
+
public DownloadButton()
+ : base(FontAwesome.Solid.Download)
{
- Children = new Drawable[]
- {
- background = new Box
- {
- RelativeSizeAxes = Axes.Both,
- Depth = float.MaxValue
- },
- icon = new SpriteIcon
- {
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- Size = new Vector2(13),
- Icon = FontAwesome.Solid.Download,
- },
- checkmark = new SpriteIcon
- {
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- X = 8,
- Size = Vector2.Zero,
- Icon = FontAwesome.Solid.Check,
- }
- };
}
[BackgroundDependencyLoader]
private void load()
{
+ AddInternal(checkmark = new SpriteIcon
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ X = 8,
+ Size = Vector2.Zero,
+ Icon = FontAwesome.Solid.Check,
+ });
+
State.BindValueChanged(updateState, true);
}
@@ -60,27 +44,27 @@ namespace osu.Game.Graphics.UserInterface
switch (state.NewValue)
{
case DownloadState.NotDownloaded:
- background.FadeColour(colours.Gray4, 500, Easing.InOutExpo);
- icon.MoveToX(0, 500, Easing.InOutExpo);
+ Background.FadeColour(colours.Gray4, 500, Easing.InOutExpo);
+ Icon.MoveToX(0, 500, Easing.InOutExpo);
checkmark.ScaleTo(Vector2.Zero, 500, Easing.InOutExpo);
TooltipText = "Download";
break;
case DownloadState.Downloading:
- background.FadeColour(colours.Blue, 500, Easing.InOutExpo);
- icon.MoveToX(0, 500, Easing.InOutExpo);
+ Background.FadeColour(colours.Blue, 500, Easing.InOutExpo);
+ Icon.MoveToX(0, 500, Easing.InOutExpo);
checkmark.ScaleTo(Vector2.Zero, 500, Easing.InOutExpo);
TooltipText = "Downloading...";
break;
case DownloadState.Importing:
- background.FadeColour(colours.Yellow, 500, Easing.InOutExpo);
+ Background.FadeColour(colours.Yellow, 500, Easing.InOutExpo);
TooltipText = "Importing";
break;
case DownloadState.LocallyAvailable:
- background.FadeColour(colours.Green, 500, Easing.InOutExpo);
- icon.MoveToX(-8, 500, Easing.InOutExpo);
+ Background.FadeColour(colours.Green, 500, Easing.InOutExpo);
+ Icon.MoveToX(-8, 500, Easing.InOutExpo);
checkmark.ScaleTo(new Vector2(13), 500, Easing.InOutExpo);
break;
}
diff --git a/osu.Game/Graphics/UserInterface/GrayButton.cs b/osu.Game/Graphics/UserInterface/GrayButton.cs
new file mode 100644
index 0000000000..88c46f29e0
--- /dev/null
+++ b/osu.Game/Graphics/UserInterface/GrayButton.cs
@@ -0,0 +1,48 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Graphics.Sprites;
+using osuTK;
+
+namespace osu.Game.Graphics.UserInterface
+{
+ public class GrayButton : OsuAnimatedButton
+ {
+ protected SpriteIcon Icon { get; private set; }
+ protected Box Background { get; private set; }
+
+ private readonly IconUsage icon;
+
+ [Resolved]
+ private OsuColour colours { get; set; }
+
+ public GrayButton(IconUsage icon)
+ {
+ this.icon = icon;
+ }
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ Children = new Drawable[]
+ {
+ Background = new Box
+ {
+ Colour = colours.Gray4,
+ RelativeSizeAxes = Axes.Both,
+ Depth = float.MaxValue
+ },
+ Icon = new SpriteIcon
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Size = new Vector2(13),
+ Icon = icon,
+ },
+ };
+ }
+ }
+}
diff --git a/osu.Game/Graphics/UserInterface/Nub.cs b/osu.Game/Graphics/UserInterface/Nub.cs
index 82b09e0821..18d8b880ea 100644
--- a/osu.Game/Graphics/UserInterface/Nub.cs
+++ b/osu.Game/Graphics/UserInterface/Nub.cs
@@ -42,13 +42,7 @@ namespace osu.Game.Graphics.UserInterface
},
};
- Current.ValueChanged += filled =>
- {
- if (filled.NewValue)
- fill.FadeIn(200, Easing.OutQuint);
- else
- fill.FadeTo(0.01f, 200, Easing.OutQuint); //todo: remove once we figure why containers aren't drawing at all times
- };
+ Current.ValueChanged += filled => fill.FadeTo(filled.NewValue ? 1 : 0, 200, Easing.OutQuint);
}
[BackgroundDependencyLoader]
diff --git a/osu.Game/Graphics/UserInterface/OsuCheckbox.cs b/osu.Game/Graphics/UserInterface/OsuCheckbox.cs
index 6593531099..f6effa0834 100644
--- a/osu.Game/Graphics/UserInterface/OsuCheckbox.cs
+++ b/osu.Game/Graphics/UserInterface/OsuCheckbox.cs
@@ -5,6 +5,7 @@ using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Graphics;
+using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Game.Graphics.Containers;
@@ -18,6 +19,11 @@ namespace osu.Game.Graphics.UserInterface
public Color4 UncheckedColor { get; set; } = Color4.White;
public int FadeDuration { get; set; }
+ ///
+ /// Whether to play sounds when the state changes as a result of user interaction.
+ ///
+ protected virtual bool PlaySoundsOnUserChange => true;
+
public string LabelText
{
set
@@ -43,7 +49,7 @@ namespace osu.Game.Graphics.UserInterface
private SampleChannel sampleChecked;
private SampleChannel sampleUnchecked;
- public OsuCheckbox()
+ public OsuCheckbox(bool nubOnRight = true)
{
AutoSizeAxes = Axes.Y;
RelativeSizeAxes = Axes.X;
@@ -52,26 +58,42 @@ namespace osu.Game.Graphics.UserInterface
Children = new Drawable[]
{
- labelText = new OsuTextFlowContainer
+ labelText = new OsuTextFlowContainer(ApplyLabelParameters)
{
AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X,
- Padding = new MarginPadding { Right = Nub.EXPANDED_SIZE + nub_padding }
- },
- Nub = new Nub
- {
- Anchor = Anchor.CentreRight,
- Origin = Anchor.CentreRight,
- Margin = new MarginPadding { Right = nub_padding },
},
+ Nub = new Nub(),
new HoverClickSounds()
};
+ if (nubOnRight)
+ {
+ Nub.Anchor = Anchor.CentreRight;
+ Nub.Origin = Anchor.CentreRight;
+ Nub.Margin = new MarginPadding { Right = nub_padding };
+ labelText.Padding = new MarginPadding { Right = Nub.EXPANDED_SIZE + nub_padding * 2 };
+ }
+ else
+ {
+ Nub.Anchor = Anchor.CentreLeft;
+ Nub.Origin = Anchor.CentreLeft;
+ Nub.Margin = new MarginPadding { Left = nub_padding };
+ labelText.Padding = new MarginPadding { Left = Nub.EXPANDED_SIZE + nub_padding * 2 };
+ }
+
Nub.Current.BindTo(Current);
Current.DisabledChanged += disabled => labelText.Alpha = Nub.Alpha = disabled ? 0.3f : 1;
}
+ ///
+ /// A function which can be overridden to change the parameters of the label's text.
+ ///
+ protected virtual void ApplyLabelParameters(SpriteText text)
+ {
+ }
+
[BackgroundDependencyLoader]
private void load(AudioManager audio)
{
@@ -96,10 +118,14 @@ namespace osu.Game.Graphics.UserInterface
protected override void OnUserChange(bool value)
{
base.OnUserChange(value);
- if (value)
- sampleChecked?.Play();
- else
- sampleUnchecked?.Play();
+
+ if (PlaySoundsOnUserChange)
+ {
+ if (value)
+ sampleChecked?.Play();
+ else
+ sampleUnchecked?.Play();
+ }
}
}
}
diff --git a/osu.Game/Online/API/APIDownloadRequest.cs b/osu.Game/Online/API/APIDownloadRequest.cs
index 940b9b4803..62e22d8f88 100644
--- a/osu.Game/Online/API/APIDownloadRequest.cs
+++ b/osu.Game/Online/API/APIDownloadRequest.cs
@@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System;
using System.IO;
using osu.Framework.IO.Network;
@@ -28,13 +29,19 @@ namespace osu.Game.Online.API
private void request_Progress(long current, long total) => API.Schedule(() => Progressed?.Invoke(current, total));
- protected APIDownloadRequest()
+ protected void TriggerSuccess(string filename)
{
- base.Success += onSuccess;
+ if (this.filename != null)
+ throw new InvalidOperationException("Attempted to trigger success more than once");
+
+ this.filename = filename;
+
+ TriggerSuccess();
}
- private void onSuccess()
+ internal override void TriggerSuccess()
{
+ base.TriggerSuccess();
Success?.Invoke(filename);
}
diff --git a/osu.Game/Online/API/APIMod.cs b/osu.Game/Online/API/APIMod.cs
index c8b76b9685..bff08b0515 100644
--- a/osu.Game/Online/API/APIMod.cs
+++ b/osu.Game/Online/API/APIMod.cs
@@ -5,6 +5,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using Humanizer;
+using MessagePack;
using Newtonsoft.Json;
using osu.Framework.Bindables;
using osu.Game.Configuration;
@@ -13,16 +14,21 @@ using osu.Game.Rulesets.Mods;
namespace osu.Game.Online.API
{
+ [MessagePackObject]
public class APIMod : IMod
{
[JsonProperty("acronym")]
+ [Key(0)]
public string Acronym { get; set; }
[JsonProperty("settings")]
+ [Key(1)]
+ [MessagePackFormatter(typeof(ModSettingsDictionaryFormatter))]
public Dictionary Settings { get; set; } = new Dictionary();
[JsonConstructor]
- private APIMod()
+ [SerializationConstructor]
+ public APIMod()
{
}
diff --git a/osu.Game/Online/API/ArchiveDownloadRequest.cs b/osu.Game/Online/API/ArchiveDownloadRequest.cs
index f1966aeb2b..0bf238109e 100644
--- a/osu.Game/Online/API/ArchiveDownloadRequest.cs
+++ b/osu.Game/Online/API/ArchiveDownloadRequest.cs
@@ -10,7 +10,7 @@ namespace osu.Game.Online.API
{
public readonly TModel Model;
- public float Progress;
+ public float Progress { get; private set; }
public event Action DownloadProgressed;
@@ -18,7 +18,13 @@ namespace osu.Game.Online.API
{
Model = model;
- Progressed += (current, total) => DownloadProgressed?.Invoke(Progress = (float)current / total);
+ Progressed += (current, total) => SetProgress((float)current / total);
+ }
+
+ protected void SetProgress(float progress)
+ {
+ Progress = progress;
+ DownloadProgressed?.Invoke(progress);
}
}
}
diff --git a/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs b/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs
new file mode 100644
index 0000000000..99e87677fa
--- /dev/null
+++ b/osu.Game/Online/API/ModSettingsDictionaryFormatter.cs
@@ -0,0 +1,67 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Buffers;
+using System.Collections.Generic;
+using System.Text;
+using MessagePack;
+using MessagePack.Formatters;
+using osu.Framework.Bindables;
+
+namespace osu.Game.Online.API
+{
+ public class ModSettingsDictionaryFormatter : IMessagePackFormatter>
+ {
+ public void Serialize(ref MessagePackWriter writer, Dictionary value, MessagePackSerializerOptions options)
+ {
+ var primitiveFormatter = PrimitiveObjectFormatter.Instance;
+
+ writer.WriteArrayHeader(value.Count);
+
+ foreach (var kvp in value)
+ {
+ var stringBytes = new ReadOnlySequence(Encoding.UTF8.GetBytes(kvp.Key));
+ writer.WriteString(in stringBytes);
+
+ switch (kvp.Value)
+ {
+ case Bindable d:
+ primitiveFormatter.Serialize(ref writer, d.Value, options);
+ break;
+
+ case Bindable i:
+ primitiveFormatter.Serialize(ref writer, i.Value, options);
+ break;
+
+ case Bindable f:
+ primitiveFormatter.Serialize(ref writer, f.Value, options);
+ break;
+
+ case Bindable b:
+ primitiveFormatter.Serialize(ref writer, b.Value, options);
+ break;
+
+ default:
+ // fall back for non-bindable cases.
+ primitiveFormatter.Serialize(ref writer, kvp.Value, options);
+ break;
+ }
+ }
+ }
+
+ public Dictionary Deserialize(ref MessagePackReader reader, MessagePackSerializerOptions options)
+ {
+ var output = new Dictionary();
+
+ int itemCount = reader.ReadArrayHeader();
+
+ for (int i = 0; i < itemCount; i++)
+ {
+ output[reader.ReadString()] =
+ PrimitiveObjectFormatter.Instance.Deserialize(ref reader, options);
+ }
+
+ return output;
+ }
+ }
+}
diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs
index bd1800e9f7..45d9c9405f 100644
--- a/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs
+++ b/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs
@@ -81,7 +81,7 @@ namespace osu.Game.Online.API.Requests.Responses
[JsonProperty(@"beatmaps")]
private IEnumerable beatmaps { get; set; }
- public BeatmapSetInfo ToBeatmapSet(RulesetStore rulesets)
+ public virtual BeatmapSetInfo ToBeatmapSet(RulesetStore rulesets)
{
var beatmapSet = new BeatmapSetInfo
{
diff --git a/osu.Game/Online/Chat/ChannelManager.cs b/osu.Game/Online/Chat/ChannelManager.cs
index 62ae507419..036ec4d0f3 100644
--- a/osu.Game/Online/Chat/ChannelManager.cs
+++ b/osu.Game/Online/Chat/ChannelManager.cs
@@ -339,7 +339,7 @@ namespace osu.Game.Online.Chat
}
///
- /// Joins a channel if it has not already been joined.
+ /// Joins a channel if it has not already been joined. Must be called from the update thread.
///
/// The channel to join.
/// The joined channel. Note that this may not match the parameter channel as it is a backed object.
@@ -399,7 +399,11 @@ namespace osu.Game.Online.Chat
return channel;
}
- public void LeaveChannel(Channel channel)
+ ///
+ /// Leave the specified channel. Can be called from any thread.
+ ///
+ /// The channel to leave.
+ public void LeaveChannel(Channel channel) => Schedule(() =>
{
if (channel == null) return;
@@ -413,7 +417,7 @@ namespace osu.Game.Online.Chat
api.Queue(new LeaveChannelRequest(channel));
channel.Joined.Value = false;
}
- }
+ });
private long lastMessageId;
diff --git a/osu.Game/Online/DownloadTrackingComposite.cs b/osu.Game/Online/DownloadTrackingComposite.cs
index 7a64c9002d..52042c266b 100644
--- a/osu.Game/Online/DownloadTrackingComposite.cs
+++ b/osu.Game/Online/DownloadTrackingComposite.cs
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
+using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics.Containers;
@@ -20,7 +21,7 @@ namespace osu.Game.Online
protected readonly Bindable Model = new Bindable();
[Resolved(CanBeNull = true)]
- private TModelManager manager { get; set; }
+ protected TModelManager Manager { get; private set; }
///
/// Holds the current download state of the , whether is has already been downloaded, is in progress, or is not downloaded.
@@ -46,25 +47,41 @@ namespace osu.Game.Online
{
if (modelInfo.NewValue == null)
attachDownload(null);
- else if (manager?.IsAvailableLocally(modelInfo.NewValue) == true)
+ else if (IsModelAvailableLocally())
State.Value = DownloadState.LocallyAvailable;
else
- attachDownload(manager?.GetExistingDownload(modelInfo.NewValue));
+ attachDownload(Manager?.GetExistingDownload(modelInfo.NewValue));
}, true);
- if (manager == null)
+ if (Manager == null)
return;
- managerDownloadBegan = manager.DownloadBegan.GetBoundCopy();
+ managerDownloadBegan = Manager.DownloadBegan.GetBoundCopy();
managerDownloadBegan.BindValueChanged(downloadBegan);
- managerDownloadFailed = manager.DownloadFailed.GetBoundCopy();
+ managerDownloadFailed = Manager.DownloadFailed.GetBoundCopy();
managerDownloadFailed.BindValueChanged(downloadFailed);
- managedUpdated = manager.ItemUpdated.GetBoundCopy();
+ managedUpdated = Manager.ItemUpdated.GetBoundCopy();
managedUpdated.BindValueChanged(itemUpdated);
- managerRemoved = manager.ItemRemoved.GetBoundCopy();
+ managerRemoved = Manager.ItemRemoved.GetBoundCopy();
managerRemoved.BindValueChanged(itemRemoved);
}
+ ///
+ /// Checks that a database model matches the one expected to be downloaded.
+ ///
+ ///
+ /// For online play, this could be used to check that the databased model matches the online beatmap.
+ ///
+ /// The model in database.
+ protected virtual bool VerifyDatabasedModel([NotNull] TModel databasedModel) => true;
+
+ ///
+ /// Whether the given model is available in the database.
+ /// By default, this calls ,
+ /// but can be overriden to add additional checks for verifying the model in database.
+ ///
+ protected virtual bool IsModelAvailableLocally() => Manager?.IsAvailableLocally(Model.Value) == true;
+
private void downloadBegan(ValueChangedEvent>> weakRequest)
{
if (weakRequest.NewValue.TryGetTarget(out var request))
@@ -134,23 +151,35 @@ namespace osu.Game.Online
private void itemUpdated(ValueChangedEvent> weakItem)
{
if (weakItem.NewValue.TryGetTarget(out var item))
- setDownloadStateFromManager(item, DownloadState.LocallyAvailable);
+ {
+ Schedule(() =>
+ {
+ if (!item.Equals(Model.Value))
+ return;
+
+ if (!VerifyDatabasedModel(item))
+ {
+ State.Value = DownloadState.NotDownloaded;
+ return;
+ }
+
+ State.Value = DownloadState.LocallyAvailable;
+ });
+ }
}
private void itemRemoved(ValueChangedEvent> weakItem)
{
if (weakItem.NewValue.TryGetTarget(out var item))
- setDownloadStateFromManager(item, DownloadState.NotDownloaded);
+ {
+ Schedule(() =>
+ {
+ if (item.Equals(Model.Value))
+ State.Value = DownloadState.NotDownloaded;
+ });
+ }
}
- private void setDownloadStateFromManager(TModel s, DownloadState state) => Schedule(() =>
- {
- if (!s.Equals(Model.Value))
- return;
-
- State.Value = state;
- });
-
#region Disposal
protected override void Dispose(bool isDisposing)
diff --git a/osu.Game/Online/Multiplayer/IMultiplayerClient.cs b/osu.Game/Online/Multiplayer/IMultiplayerClient.cs
index 19dd473230..6d7b9d24d6 100644
--- a/osu.Game/Online/Multiplayer/IMultiplayerClient.cs
+++ b/osu.Game/Online/Multiplayer/IMultiplayerClient.cs
@@ -1,7 +1,9 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System.Collections.Generic;
using System.Threading.Tasks;
+using osu.Game.Online.API;
using osu.Game.Online.Rooms;
namespace osu.Game.Online.Multiplayer
@@ -55,6 +57,13 @@ namespace osu.Game.Online.Multiplayer
/// The new beatmap availability state of the user.
Task UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability);
+ ///
+ /// Signals that a user in this room changed their local mods.
+ ///
+ /// The ID of the user whose mods have changed.
+ /// The user's new local mods.
+ Task UserModsChanged(int userId, IEnumerable mods);
+
///
/// Signals that a match is to be started. This will *only* be sent to clients which are to begin loading at this point.
///
diff --git a/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs
index 09816974a7..3527ce6314 100644
--- a/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs
+++ b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs
@@ -1,7 +1,9 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System.Collections.Generic;
using System.Threading.Tasks;
+using osu.Game.Online.API;
using osu.Game.Online.Rooms;
namespace osu.Game.Online.Multiplayer
@@ -47,6 +49,12 @@ namespace osu.Game.Online.Multiplayer
/// The proposed new beatmap availability state.
Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability);
+ ///
+ /// Change the local user's mods in the currently joined room.
+ ///
+ /// The proposed new mods, excluding any required by the room itself.
+ Task ChangeUserMods(IEnumerable newMods);
+
///
/// As the host of a room, start the match.
///
diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs
index 50dc8f661c..493518ac80 100644
--- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs
+++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs
@@ -4,12 +4,13 @@
#nullable enable
using System;
-using System.Diagnostics;
+using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json;
+using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Logging;
@@ -25,11 +26,15 @@ namespace osu.Game.Online.Multiplayer
private readonly Bindable isConnected = new Bindable();
private readonly IBindable apiState = new Bindable();
+ private readonly SemaphoreSlim connectionLock = new SemaphoreSlim(1);
+
[Resolved]
private IAPIProvider api { get; set; } = null!;
private HubConnection? connection;
+ private CancellationTokenSource connectCancelSource = new CancellationTokenSource();
+
private readonly string endpoint;
public MultiplayerClient(EndpointConfiguration endpoints)
@@ -50,80 +55,67 @@ namespace osu.Game.Online.Multiplayer
{
case APIState.Failing:
case APIState.Offline:
- connection?.StopAsync();
- connection = null;
+ Task.Run(() => disconnect(true));
break;
case APIState.Online:
- Task.Run(Connect);
+ Task.Run(connect);
break;
}
}
- protected virtual async Task Connect()
+ private async Task connect()
{
- if (connection != null)
- return;
+ cancelExistingConnect();
- connection = new HubConnectionBuilder()
- .WithUrl(endpoint, options =>
- {
- options.Headers.Add("Authorization", $"Bearer {api.AccessToken}");
- })
- .AddNewtonsoftJsonProtocol(options => { options.PayloadSerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; })
- .Build();
+ if (!await connectionLock.WaitAsync(10000))
+ throw new TimeoutException("Could not obtain a lock to connect. A previous attempt is likely stuck.");
- // this is kind of SILLY
- // https://github.com/dotnet/aspnetcore/issues/15198
- connection.On(nameof(IMultiplayerClient.RoomStateChanged), ((IMultiplayerClient)this).RoomStateChanged);
- connection.On(nameof(IMultiplayerClient.UserJoined), ((IMultiplayerClient)this).UserJoined);
- connection.On(nameof(IMultiplayerClient.UserLeft), ((IMultiplayerClient)this).UserLeft);
- connection.On(nameof(IMultiplayerClient.HostChanged), ((IMultiplayerClient)this).HostChanged);
- connection.On(nameof(IMultiplayerClient.SettingsChanged), ((IMultiplayerClient)this).SettingsChanged);
- connection.On(nameof(IMultiplayerClient.UserStateChanged), ((IMultiplayerClient)this).UserStateChanged);
- connection.On(nameof(IMultiplayerClient.LoadRequested), ((IMultiplayerClient)this).LoadRequested);
- connection.On(nameof(IMultiplayerClient.MatchStarted), ((IMultiplayerClient)this).MatchStarted);
- connection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady);
-
- connection.Closed += async ex =>
+ try
{
- isConnected.Value = false;
-
- Logger.Log(ex != null
- ? $"Multiplayer client lost connection: {ex}"
- : "Multiplayer client disconnected", LoggingTarget.Network);
-
- if (connection != null)
- await tryUntilConnected();
- };
-
- await tryUntilConnected();
-
- async Task tryUntilConnected()
- {
- Logger.Log("Multiplayer client connecting...", LoggingTarget.Network);
-
while (api.State.Value == APIState.Online)
{
+ // ensure any previous connection was disposed.
+ // this will also create a new cancellation token source.
+ await disconnect(false);
+
+ // this token will be valid for the scope of this connection.
+ // if cancelled, we can be sure that a disconnect or reconnect is handled elsewhere.
+ var cancellationToken = connectCancelSource.Token;
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ Logger.Log("Multiplayer client connecting...", LoggingTarget.Network);
+
try
{
- Debug.Assert(connection != null);
+ // importantly, rebuild the connection each attempt to get an updated access token.
+ connection = createConnection(cancellationToken);
+
+ await connection.StartAsync(cancellationToken);
- // reconnect on any failure
- await connection.StartAsync();
Logger.Log("Multiplayer client connected!", LoggingTarget.Network);
-
- // Success.
isConnected.Value = true;
- break;
+ return;
+ }
+ catch (OperationCanceledException)
+ {
+ //connection process was cancelled.
+ throw;
}
catch (Exception e)
{
Logger.Log($"Multiplayer client connection error: {e}", LoggingTarget.Network);
- await Task.Delay(5000);
+
+ // retry on any failure.
+ await Task.Delay(5000, cancellationToken);
}
}
}
+ finally
+ {
+ connectionLock.Release();
+ }
}
protected override Task JoinRoom(long roomId)
@@ -134,20 +126,12 @@ namespace osu.Game.Online.Multiplayer
return connection.InvokeAsync(nameof(IMultiplayerServer.JoinRoom), roomId);
}
- public override async Task LeaveRoom()
+ protected override Task LeaveRoomInternal()
{
if (!isConnected.Value)
- {
- // even if not connected, make sure the local room state can be cleaned up.
- await base.LeaveRoom();
- return;
- }
+ return Task.FromCanceled(new CancellationToken(true));
- if (Room == null)
- return;
-
- await base.LeaveRoom();
- await connection.InvokeAsync(nameof(IMultiplayerServer.LeaveRoom));
+ return connection.InvokeAsync(nameof(IMultiplayerServer.LeaveRoom));
}
public override Task TransferHost(int userId)
@@ -182,6 +166,14 @@ namespace osu.Game.Online.Multiplayer
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeBeatmapAvailability), newBeatmapAvailability);
}
+ public override Task ChangeUserMods(IEnumerable newMods)
+ {
+ if (!isConnected.Value)
+ return Task.CompletedTask;
+
+ return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeUserMods), newMods);
+ }
+
public override Task StartMatch()
{
if (!isConnected.Value)
@@ -189,5 +181,85 @@ namespace osu.Game.Online.Multiplayer
return connection.InvokeAsync(nameof(IMultiplayerServer.StartMatch));
}
+
+ private async Task disconnect(bool takeLock)
+ {
+ cancelExistingConnect();
+
+ if (takeLock)
+ {
+ if (!await connectionLock.WaitAsync(10000))
+ throw new TimeoutException("Could not obtain a lock to disconnect. A previous attempt is likely stuck.");
+ }
+
+ try
+ {
+ if (connection != null)
+ await connection.DisposeAsync();
+ }
+ finally
+ {
+ connection = null;
+ if (takeLock)
+ connectionLock.Release();
+ }
+ }
+
+ private void cancelExistingConnect()
+ {
+ connectCancelSource.Cancel();
+ connectCancelSource = new CancellationTokenSource();
+ }
+
+ private HubConnection createConnection(CancellationToken cancellationToken)
+ {
+ var builder = new HubConnectionBuilder()
+ .WithUrl(endpoint, options => { options.Headers.Add("Authorization", $"Bearer {api.AccessToken}"); });
+
+ if (RuntimeInfo.SupportsJIT)
+ builder.AddMessagePackProtocol();
+ else
+ {
+ // eventually we will precompile resolvers for messagepack, but this isn't working currently
+ // see https://github.com/neuecc/MessagePack-CSharp/issues/780#issuecomment-768794308.
+ builder.AddNewtonsoftJsonProtocol(options => { options.PayloadSerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; });
+ }
+
+ var newConnection = builder.Build();
+
+ // this is kind of SILLY
+ // https://github.com/dotnet/aspnetcore/issues/15198
+ newConnection.On(nameof(IMultiplayerClient.RoomStateChanged), ((IMultiplayerClient)this).RoomStateChanged);
+ newConnection.On(nameof(IMultiplayerClient.UserJoined), ((IMultiplayerClient)this).UserJoined);
+ newConnection.On(nameof(IMultiplayerClient.UserLeft), ((IMultiplayerClient)this).UserLeft);
+ newConnection.On(nameof(IMultiplayerClient.HostChanged), ((IMultiplayerClient)this).HostChanged);
+ newConnection.On(nameof(IMultiplayerClient.SettingsChanged), ((IMultiplayerClient)this).SettingsChanged);
+ newConnection.On(nameof(IMultiplayerClient.UserStateChanged), ((IMultiplayerClient)this).UserStateChanged);
+ newConnection.On(nameof(IMultiplayerClient.LoadRequested), ((IMultiplayerClient)this).LoadRequested);
+ newConnection.On(nameof(IMultiplayerClient.MatchStarted), ((IMultiplayerClient)this).MatchStarted);
+ newConnection.On(nameof(IMultiplayerClient.ResultsReady), ((IMultiplayerClient)this).ResultsReady);
+ newConnection.On>(nameof(IMultiplayerClient.UserModsChanged), ((IMultiplayerClient)this).UserModsChanged);
+
+ newConnection.Closed += ex =>
+ {
+ isConnected.Value = false;
+
+ Logger.Log(ex != null ? $"Multiplayer client lost connection: {ex}" : "Multiplayer client disconnected", LoggingTarget.Network);
+
+ // make sure a disconnect wasn't triggered (and this is still the active connection).
+ if (!cancellationToken.IsCancellationRequested)
+ Task.Run(connect, default);
+
+ return Task.CompletedTask;
+ };
+ return newConnection;
+ }
+
+ protected override void Dispose(bool isDisposing)
+ {
+ base.Dispose(isDisposing);
+
+ cancelExistingConnect();
+ }
}
}
diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs
index 12fcf25ace..c5fa6253ed 100644
--- a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs
+++ b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs
@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
+using MessagePack;
using Newtonsoft.Json;
namespace osu.Game.Online.Multiplayer
@@ -13,35 +14,42 @@ namespace osu.Game.Online.Multiplayer
/// A multiplayer room.
///
[Serializable]
+ [MessagePackObject]
public class MultiplayerRoom
{
///
/// The ID of the room, used for database persistence.
///
+ [Key(0)]
public readonly long RoomID;
///
/// The current state of the room (ie. whether it is in progress or otherwise).
///
+ [Key(1)]
public MultiplayerRoomState State { get; set; }
///
/// All currently enforced game settings for this room.
///
+ [Key(2)]
public MultiplayerRoomSettings Settings { get; set; } = new MultiplayerRoomSettings();
///
/// All users currently in this room.
///
+ [Key(3)]
public List Users { get; set; } = new List();
///
/// The host of this room, in control of changing room settings.
///
+ [Key(4)]
public MultiplayerRoomUser? Host { get; set; }
[JsonConstructor]
- public MultiplayerRoom(in long roomId)
+ [SerializationConstructor]
+ public MultiplayerRoom(long roomId)
{
RoomID = roomId;
}
diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs
index 857b38ea60..4fb9d724b5 100644
--- a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs
+++ b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs
@@ -7,31 +7,47 @@ using System;
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
+using MessagePack;
using osu.Game.Online.API;
namespace osu.Game.Online.Multiplayer
{
[Serializable]
+ [MessagePackObject]
public class MultiplayerRoomSettings : IEquatable
{
+ [Key(0)]
public int BeatmapID { get; set; }
+ [Key(1)]
public int RulesetID { get; set; }
+ [Key(2)]
public string BeatmapChecksum { get; set; } = string.Empty;
+ [Key(3)]
public string Name { get; set; } = "Unnamed room";
[NotNull]
- public IEnumerable Mods { get; set; } = Enumerable.Empty();
+ [Key(4)]
+ public IEnumerable RequiredMods { get; set; } = Enumerable.Empty();
+
+ [NotNull]
+ [Key(5)]
+ public IEnumerable AllowedMods { get; set; } = Enumerable.Empty();
public bool Equals(MultiplayerRoomSettings other)
=> BeatmapID == other.BeatmapID
&& BeatmapChecksum == other.BeatmapChecksum
- && Mods.SequenceEqual(other.Mods)
+ && RequiredMods.SequenceEqual(other.RequiredMods)
+ && AllowedMods.SequenceEqual(other.AllowedMods)
&& RulesetID == other.RulesetID
&& Name.Equals(other.Name, StringComparison.Ordinal);
- public override string ToString() => $"Name:{Name} Beatmap:{BeatmapID} ({BeatmapChecksum}) Mods:{string.Join(',', Mods)} Ruleset:{RulesetID}";
+ public override string ToString() => $"Name:{Name}"
+ + $" Beatmap:{BeatmapID} ({BeatmapChecksum})"
+ + $" RequiredMods:{string.Join(',', RequiredMods)}"
+ + $" AllowedMods:{string.Join(',', AllowedMods)}"
+ + $" Ruleset:{RulesetID}";
}
}
diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs
index 2590acbc81..c654127b94 100644
--- a/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs
+++ b/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs
@@ -4,28 +4,45 @@
#nullable enable
using System;
+using System.Collections.Generic;
+using System.Linq;
+using JetBrains.Annotations;
+using MessagePack;
using Newtonsoft.Json;
+using osu.Game.Online.API;
using osu.Game.Online.Rooms;
using osu.Game.Users;
namespace osu.Game.Online.Multiplayer
{
[Serializable]
+ [MessagePackObject]
public class MultiplayerRoomUser : IEquatable
{
+ [Key(0)]
public readonly int UserID;
+ [Key(1)]
public MultiplayerUserState State { get; set; } = MultiplayerUserState.Idle;
///
/// The availability state of the current beatmap.
///
+ [Key(2)]
public BeatmapAvailability BeatmapAvailability { get; set; } = BeatmapAvailability.LocallyAvailable();
+ ///
+ /// Any mods applicable only to the local user.
+ ///
+ [Key(3)]
+ [NotNull]
+ public IEnumerable Mods { get; set; } = Enumerable.Empty();
+
+ [IgnoreMember]
public User? User { get; set; }
[JsonConstructor]
- public MultiplayerRoomUser(in int userId)
+ public MultiplayerRoomUser(int userId)
{
UserID = userId;
}
diff --git a/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs b/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs
index f0e11b2b8b..f454fe619b 100644
--- a/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs
+++ b/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs
@@ -7,6 +7,7 @@ using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
+using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
@@ -15,13 +16,13 @@ using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Game.Beatmaps;
using osu.Game.Database;
-using osu.Game.Extensions;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Rooms;
using osu.Game.Online.Rooms.RoomStatuses;
using osu.Game.Rulesets;
+using osu.Game.Rulesets.Mods;
using osu.Game.Users;
using osu.Game.Utils;
@@ -104,35 +105,48 @@ namespace osu.Game.Online.Multiplayer
if (!connected.NewValue && Room != null)
{
Logger.Log("Connection to multiplayer server was lost.", LoggingTarget.Runtime, LogLevel.Important);
- LeaveRoom().CatchUnobservedExceptions();
+ LeaveRoom();
}
});
}
+ private readonly TaskChain joinOrLeaveTaskChain = new TaskChain();
+ private CancellationTokenSource? joinCancellationSource;
+
///
/// Joins the for a given API .
///
/// The API .
public async Task JoinRoom(Room room)
{
- if (Room != null)
- throw new InvalidOperationException("Cannot join a multiplayer room while already in one.");
+ var cancellationSource = joinCancellationSource = new CancellationTokenSource();
- Debug.Assert(room.RoomID.Value != null);
+ await joinOrLeaveTaskChain.Add(async () =>
+ {
+ if (Room != null)
+ throw new InvalidOperationException("Cannot join a multiplayer room while already in one.");
- apiRoom = room;
- playlistItemId = room.Playlist.SingleOrDefault()?.ID ?? 0;
+ Debug.Assert(room.RoomID.Value != null);
- Room = await JoinRoom(room.RoomID.Value.Value);
+ // Join the server-side room.
+ var joinedRoom = await JoinRoom(room.RoomID.Value.Value);
+ Debug.Assert(joinedRoom != null);
- Debug.Assert(Room != null);
+ // Populate users.
+ Debug.Assert(joinedRoom.Users != null);
+ await Task.WhenAll(joinedRoom.Users.Select(PopulateUser));
- var users = await getRoomUsers();
- Debug.Assert(users != null);
+ // Update the stored room (must be done on update thread for thread-safety).
+ await scheduleAsync(() =>
+ {
+ Room = joinedRoom;
+ apiRoom = room;
+ playlistItemId = room.Playlist.SingleOrDefault()?.ID ?? 0;
+ }, cancellationSource.Token);
- await Task.WhenAll(users.Select(PopulateUser));
-
- updateLocalRoomSettings(Room.Settings);
+ // Update room settings.
+ await updateLocalRoomSettings(joinedRoom.Settings, cancellationSource.Token);
+ }, cancellationSource.Token);
}
///
@@ -142,23 +156,33 @@ namespace osu.Game.Online.Multiplayer
/// The joined .
protected abstract Task JoinRoom(long roomId);
- public virtual Task LeaveRoom()
+ public Task LeaveRoom()
{
- Scheduler.Add(() =>
- {
- if (Room == null)
- return;
+ // The join may have not completed yet, so certain tasks that either update the room or reference the room should be cancelled.
+ // This includes the setting of Room itself along with the initial update of the room settings on join.
+ joinCancellationSource?.Cancel();
+ // Leaving rooms is expected to occur instantaneously whilst the operation is finalised in the background.
+ // However a few members need to be reset immediately to prevent other components from entering invalid states whilst the operation hasn't yet completed.
+ // For example, if a room was left and the user immediately pressed the "create room" button, then the user could be taken into the lobby if the value of Room is not reset in time.
+ var scheduledReset = scheduleAsync(() =>
+ {
apiRoom = null;
Room = null;
CurrentMatchPlayingUserIds.Clear();
RoomUpdated?.Invoke();
- }, false);
+ });
- return Task.CompletedTask;
+ return joinOrLeaveTaskChain.Add(async () =>
+ {
+ await scheduledReset;
+ await LeaveRoomInternal();
+ });
}
+ protected abstract Task LeaveRoomInternal();
+
///
/// Change the current settings.
///
@@ -192,7 +216,8 @@ namespace osu.Game.Online.Multiplayer
BeatmapID = item.GetOr(existingPlaylistItem).BeatmapID,
BeatmapChecksum = item.GetOr(existingPlaylistItem).Beatmap.Value.MD5Hash,
RulesetID = item.GetOr(existingPlaylistItem).RulesetID,
- Mods = item.HasValue ? item.Value.AsNonNull().RequiredMods.Select(m => new APIMod(m)).ToList() : Room.Settings.Mods
+ RequiredMods = item.HasValue ? item.Value.AsNonNull().RequiredMods.Select(m => new APIMod(m)).ToList() : Room.Settings.RequiredMods,
+ AllowedMods = item.HasValue ? item.Value.AsNonNull().AllowedMods.Select(m => new APIMod(m)).ToList() : Room.Settings.AllowedMods
});
}
@@ -230,6 +255,14 @@ namespace osu.Game.Online.Multiplayer
public abstract Task ChangeBeatmapAvailability(BeatmapAvailability newBeatmapAvailability);
+ ///
+ /// Change the local user's mods in the currently joined room.
+ ///
+ /// The proposed new mods, excluding any required by the room itself.
+ public Task ChangeUserMods(IEnumerable newMods) => ChangeUserMods(newMods.Select(m => new APIMod(m)).ToList());
+
+ public abstract Task ChangeUserMods(IEnumerable newMods);
+
public abstract Task StartMatch();
Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state)
@@ -378,6 +411,27 @@ namespace osu.Game.Online.Multiplayer
return Task.CompletedTask;
}
+ public Task UserModsChanged(int userId, IEnumerable mods)
+ {
+ if (Room == null)
+ return Task.CompletedTask;
+
+ Scheduler.Add(() =>
+ {
+ var user = Room?.Users.SingleOrDefault(u => u.UserID == userId);
+
+ // errors here are not critical - user mods are mostly for display.
+ if (user == null)
+ return;
+
+ user.Mods = mods;
+
+ RoomUpdated?.Invoke();
+ }, false);
+
+ return Task.CompletedTask;
+ }
+
Task IMultiplayerClient.LoadRequested()
{
if (Room == null)
@@ -432,27 +486,6 @@ namespace osu.Game.Online.Multiplayer
/// The to populate.
protected async Task PopulateUser(MultiplayerRoomUser multiplayerUser) => multiplayerUser.User ??= await userLookupCache.GetUserAsync(multiplayerUser.UserID);
- ///
- /// Retrieve a copy of users currently in the joined in a thread-safe manner.
- /// This should be used whenever accessing users from outside of an Update thread context (ie. when not calling ).
- ///
- /// A copy of users in the current room, or null if unavailable.
- private Task?> getRoomUsers()
- {
- var tcs = new TaskCompletionSource?>();
-
- // at some point we probably want to replace all these schedule calls with Room.LockForUpdate.
- // for now, as this would require quite some consideration due to the number of accesses to the room instance,
- // let's just add a manual schedule for the non-scheduled usages instead.
- Scheduler.Add(() =>
- {
- var users = Room?.Users.ToList();
- tcs.SetResult(users);
- }, false);
-
- return tcs.Task;
- }
-
///
/// Updates the local room settings with the given .
///
@@ -460,34 +493,36 @@ namespace osu.Game.Online.Multiplayer
/// This updates both the joined and the respective API .
///
/// The new to update from.
- private void updateLocalRoomSettings(MultiplayerRoomSettings settings)
+ /// The to cancel the update.
+ private Task updateLocalRoomSettings(MultiplayerRoomSettings settings, CancellationToken cancellationToken = default) => scheduleAsync(() =>
{
if (Room == null)
return;
- Scheduler.Add(() =>
+ Debug.Assert(apiRoom != null);
+
+ // Update a few properties of the room instantaneously.
+ Room.Settings = settings;
+ apiRoom.Name.Value = Room.Settings.Name;
+
+ // The playlist update is delayed until an online beatmap lookup (below) succeeds.
+ // In-order for the client to not display an outdated beatmap, the playlist is forcefully cleared here.
+ apiRoom.Playlist.Clear();
+
+ RoomUpdated?.Invoke();
+
+ var req = new GetBeatmapSetRequest(settings.BeatmapID, BeatmapSetLookupType.BeatmapId);
+
+ req.Success += res =>
{
- if (Room == null)
+ if (cancellationToken.IsCancellationRequested)
return;
- Debug.Assert(apiRoom != null);
+ updatePlaylist(settings, res);
+ };
- // Update a few properties of the room instantaneously.
- Room.Settings = settings;
- apiRoom.Name.Value = Room.Settings.Name;
-
- // The playlist update is delayed until an online beatmap lookup (below) succeeds.
- // In-order for the client to not display an outdated beatmap, the playlist is forcefully cleared here.
- apiRoom.Playlist.Clear();
-
- RoomUpdated?.Invoke();
-
- var req = new GetBeatmapSetRequest(settings.BeatmapID, BeatmapSetLookupType.BeatmapId);
- req.Success += res => updatePlaylist(settings, res);
-
- api.Queue(req);
- }, false);
- }
+ api.Queue(req);
+ }, cancellationToken);
private void updatePlaylist(MultiplayerRoomSettings settings, APIBeatmapSet onlineSet)
{
@@ -501,7 +536,8 @@ namespace osu.Game.Online.Multiplayer
beatmap.MD5Hash = settings.BeatmapChecksum;
var ruleset = rulesets.GetRuleset(settings.RulesetID).CreateInstance();
- var mods = settings.Mods.Select(m => m.ToMod(ruleset));
+ var mods = settings.RequiredMods.Select(m => m.ToMod(ruleset));
+ var allowedMods = settings.AllowedMods.Select(m => m.ToMod(ruleset));
PlaylistItem playlistItem = new PlaylistItem
{
@@ -511,6 +547,7 @@ namespace osu.Game.Online.Multiplayer
};
playlistItem.RequiredMods.AddRange(mods);
+ playlistItem.AllowedMods.AddRange(allowedMods);
apiRoom.Playlist.Clear(); // Clearing should be unnecessary, but here for sanity.
apiRoom.Playlist.Add(playlistItem);
@@ -534,5 +571,31 @@ namespace osu.Game.Online.Multiplayer
else
CurrentMatchPlayingUserIds.Remove(userId);
}
+
+ private Task scheduleAsync(Action action, CancellationToken cancellationToken = default)
+ {
+ var tcs = new TaskCompletionSource();
+
+ Scheduler.Add(() =>
+ {
+ if (cancellationToken.IsCancellationRequested)
+ {
+ tcs.SetCanceled();
+ return;
+ }
+
+ try
+ {
+ action();
+ tcs.SetResult(true);
+ }
+ catch (Exception ex)
+ {
+ tcs.SetException(ex);
+ }
+ });
+
+ return tcs.Task;
+ }
}
}
diff --git a/osu.Game/Online/Rooms/BeatmapAvailability.cs b/osu.Game/Online/Rooms/BeatmapAvailability.cs
index e7dbc5f436..a83327aad5 100644
--- a/osu.Game/Online/Rooms/BeatmapAvailability.cs
+++ b/osu.Game/Online/Rooms/BeatmapAvailability.cs
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
+using MessagePack;
using Newtonsoft.Json;
namespace osu.Game.Online.Rooms
@@ -9,27 +10,30 @@ namespace osu.Game.Online.Rooms
///
/// The local availability information about a certain beatmap for the client.
///
+ [MessagePackObject]
public class BeatmapAvailability : IEquatable
{
///
/// The beatmap's availability state.
///
+ [Key(0)]
public readonly DownloadState State;
///
/// The beatmap's downloading progress, null when not in state.
///
- public readonly double? DownloadProgress;
+ [Key(1)]
+ public readonly float? DownloadProgress;
[JsonConstructor]
- private BeatmapAvailability(DownloadState state, double? downloadProgress = null)
+ public BeatmapAvailability(DownloadState state, float? downloadProgress = null)
{
State = state;
DownloadProgress = downloadProgress;
}
public static BeatmapAvailability NotDownloaded() => new BeatmapAvailability(DownloadState.NotDownloaded);
- public static BeatmapAvailability Downloading(double progress) => new BeatmapAvailability(DownloadState.Downloading, progress);
+ public static BeatmapAvailability Downloading(float progress) => new BeatmapAvailability(DownloadState.Downloading, progress);
public static BeatmapAvailability Importing() => new BeatmapAvailability(DownloadState.Importing);
public static BeatmapAvailability LocallyAvailable() => new BeatmapAvailability(DownloadState.LocallyAvailable);
diff --git a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailablilityTracker.cs b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailablilityTracker.cs
new file mode 100644
index 0000000000..ad4b3c5151
--- /dev/null
+++ b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailablilityTracker.cs
@@ -0,0 +1,92 @@
+// 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.Bindables;
+using osu.Framework.Logging;
+using osu.Game.Beatmaps;
+
+namespace osu.Game.Online.Rooms
+{
+ ///
+ /// Represent a checksum-verifying beatmap availability tracker usable for online play screens.
+ ///
+ /// This differs from a regular download tracking composite as this accounts for the
+ /// databased beatmap set's checksum, to disallow from playing with an altered version of the beatmap.
+ ///
+ public class OnlinePlayBeatmapAvailablilityTracker : DownloadTrackingComposite
+ {
+ public readonly IBindable SelectedItem = new Bindable();
+
+ ///
+ /// The availability state of the currently selected playlist item.
+ ///
+ public IBindable Availability => availability;
+
+ private readonly Bindable availability = new Bindable();
+
+ public OnlinePlayBeatmapAvailablilityTracker()
+ {
+ State.BindValueChanged(_ => updateAvailability());
+ Progress.BindValueChanged(_ => updateAvailability(), true);
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ SelectedItem.BindValueChanged(item => Model.Value = item.NewValue?.Beatmap.Value.BeatmapSet, true);
+ }
+
+ protected override bool VerifyDatabasedModel(BeatmapSetInfo databasedSet)
+ {
+ int? beatmapId = SelectedItem.Value.Beatmap.Value.OnlineBeatmapID;
+ string checksum = SelectedItem.Value.Beatmap.Value.MD5Hash;
+
+ var matchingBeatmap = databasedSet.Beatmaps.FirstOrDefault(b => b.OnlineBeatmapID == beatmapId && b.MD5Hash == checksum);
+
+ if (matchingBeatmap == null)
+ {
+ Logger.Log("The imported beatmap set does not match the online version.", LoggingTarget.Runtime, LogLevel.Important);
+ return false;
+ }
+
+ return true;
+ }
+
+ protected override bool IsModelAvailableLocally()
+ {
+ int? beatmapId = SelectedItem.Value.Beatmap.Value.OnlineBeatmapID;
+ string checksum = SelectedItem.Value.Beatmap.Value.MD5Hash;
+
+ var beatmap = Manager.QueryBeatmap(b => b.OnlineBeatmapID == beatmapId && b.MD5Hash == checksum);
+ return beatmap?.BeatmapSet.DeletePending == false;
+ }
+
+ private void updateAvailability()
+ {
+ switch (State.Value)
+ {
+ case DownloadState.NotDownloaded:
+ availability.Value = BeatmapAvailability.NotDownloaded();
+ break;
+
+ case DownloadState.Downloading:
+ availability.Value = BeatmapAvailability.Downloading((float)Progress.Value);
+ break;
+
+ case DownloadState.Importing:
+ availability.Value = BeatmapAvailability.Importing();
+ break;
+
+ case DownloadState.LocallyAvailable:
+ availability.Value = BeatmapAvailability.LocallyAvailable();
+ break;
+
+ default:
+ throw new ArgumentOutOfRangeException(nameof(State));
+ }
+ }
+ }
+}
diff --git a/osu.Game/Online/Spectator/FrameDataBundle.cs b/osu.Game/Online/Spectator/FrameDataBundle.cs
index a8d0434324..0e59cdf4ce 100644
--- a/osu.Game/Online/Spectator/FrameDataBundle.cs
+++ b/osu.Game/Online/Spectator/FrameDataBundle.cs
@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
+using MessagePack;
using Newtonsoft.Json;
using osu.Game.Replays.Legacy;
using osu.Game.Scoring;
@@ -12,10 +13,13 @@ using osu.Game.Scoring;
namespace osu.Game.Online.Spectator
{
[Serializable]
+ [MessagePackObject]
public class FrameDataBundle
{
+ [Key(0)]
public FrameHeader Header { get; set; }
+ [Key(1)]
public IEnumerable Frames { get; set; }
public FrameDataBundle(ScoreInfo score, IEnumerable frames)
diff --git a/osu.Game/Online/Spectator/FrameHeader.cs b/osu.Game/Online/Spectator/FrameHeader.cs
index 135b356eda..adfcbcd95a 100644
--- a/osu.Game/Online/Spectator/FrameHeader.cs
+++ b/osu.Game/Online/Spectator/FrameHeader.cs
@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
+using MessagePack;
using Newtonsoft.Json;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
@@ -12,31 +13,37 @@ using osu.Game.Scoring;
namespace osu.Game.Online.Spectator
{
[Serializable]
+ [MessagePackObject]
public class FrameHeader
{
///
/// The current accuracy of the score.
///
+ [Key(0)]
public double Accuracy { get; set; }
///
/// The current combo of the score.
///
+ [Key(1)]
public int Combo { get; set; }
///
/// The maximum combo achieved up to the current point in time.
///
+ [Key(2)]
public int MaxCombo { get; set; }
///
/// Cumulative hit statistics.
///
+ [Key(3)]
public Dictionary Statistics { get; set; }
///
/// The time at which this frame was received by the server.
///
+ [Key(4)]
public DateTimeOffset ReceivedTime { get; set; }
///
@@ -54,7 +61,8 @@ namespace osu.Game.Online.Spectator
}
[JsonConstructor]
- public FrameHeader(int combo, int maxCombo, double accuracy, Dictionary statistics, DateTimeOffset receivedTime)
+ [SerializationConstructor]
+ public FrameHeader(double accuracy, int combo, int maxCombo, Dictionary statistics, DateTimeOffset receivedTime)
{
Combo = combo;
MaxCombo = maxCombo;
diff --git a/osu.Game/Online/Spectator/SpectatorState.cs b/osu.Game/Online/Spectator/SpectatorState.cs
index 101ce3d5d5..96a875bc14 100644
--- a/osu.Game/Online/Spectator/SpectatorState.cs
+++ b/osu.Game/Online/Spectator/SpectatorState.cs
@@ -5,18 +5,23 @@ using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
+using MessagePack;
using osu.Game.Online.API;
namespace osu.Game.Online.Spectator
{
[Serializable]
+ [MessagePackObject]
public class SpectatorState : IEquatable
{
+ [Key(0)]
public int? BeatmapID { get; set; }
+ [Key(1)]
public int? RulesetID { get; set; }
[NotNull]
+ [Key(2)]
public IEnumerable Mods { get; set; } = Enumerable.Empty();
public bool Equals(SpectatorState other) => BeatmapID == other?.BeatmapID && Mods.SequenceEqual(other?.Mods) && RulesetID == other?.RulesetID;
diff --git a/osu.Game/Online/Spectator/SpectatorStreamingClient.cs b/osu.Game/Online/Spectator/SpectatorStreamingClient.cs
index 344b73f3d9..b95e3f1297 100644
--- a/osu.Game/Online/Spectator/SpectatorStreamingClient.cs
+++ b/osu.Game/Online/Spectator/SpectatorStreamingClient.cs
@@ -10,6 +10,7 @@ using JetBrains.Annotations;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json;
+using osu.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
@@ -116,14 +117,19 @@ namespace osu.Game.Online.Spectator
if (connection != null)
return;
- connection = new HubConnectionBuilder()
- .WithUrl(endpoint, options =>
- {
- options.Headers.Add("Authorization", $"Bearer {api.AccessToken}");
- })
- .AddNewtonsoftJsonProtocol(options => { options.PayloadSerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; })
- .Build();
+ var builder = new HubConnectionBuilder()
+ .WithUrl(endpoint, options => { options.Headers.Add("Authorization", $"Bearer {api.AccessToken}"); });
+ if (RuntimeInfo.SupportsJIT)
+ builder.AddMessagePackProtocol();
+ else
+ {
+ // eventually we will precompile resolvers for messagepack, but this isn't working currently
+ // see https://github.com/neuecc/MessagePack-CSharp/issues/780#issuecomment-768794308.
+ builder.AddNewtonsoftJsonProtocol(options => { options.PayloadSerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore; });
+ }
+
+ connection = builder.Build();
// until strong typed client support is added, each method must be manually bound (see https://github.com/dotnet/aspnetcore/issues/15198)
connection.On(nameof(ISpectatorClient.UserBeganPlaying), ((ISpectatorClient)this).UserBeganPlaying);
connection.On(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames);
diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs
index 5acd6bc73d..1a1f7bd233 100644
--- a/osu.Game/OsuGame.cs
+++ b/osu.Game/OsuGame.cs
@@ -468,6 +468,16 @@ namespace osu.Game
private void modsChanged(ValueChangedEvent> mods)
{
updateModDefaults();
+
+ // a lease may be taken on the mods bindable, at which point we can't really ensure valid mods.
+ if (SelectedMods.Disabled)
+ return;
+
+ if (!ModUtils.CheckValidForGameplay(mods.NewValue, out var invalid))
+ {
+ // ensure we always have a valid set of mods.
+ SelectedMods.Value = mods.NewValue.Except(invalid).ToArray();
+ }
}
private void updateModDefaults()
diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs b/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs
index b429a5277b..01bcbd3244 100644
--- a/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs
+++ b/osu.Game/Overlays/BeatmapListing/BeatmapSearchFilterRow.cs
@@ -12,7 +12,7 @@ using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osuTK;
using Humanizer;
-using osu.Game.Utils;
+using osu.Framework.Extensions.EnumExtensions;
namespace osu.Game.Overlays.BeatmapListing
{
@@ -80,7 +80,7 @@ namespace osu.Game.Overlays.BeatmapListing
if (typeof(T).IsEnum)
{
- foreach (var val in OrderAttributeUtils.GetValuesInOrder())
+ foreach (var val in EnumExtensions.GetValuesInOrder())
AddItem(val);
}
}
diff --git a/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs b/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs
index eee5d8f7e1..015cee8ce3 100644
--- a/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs
+++ b/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs
@@ -1,7 +1,7 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-using osu.Game.Utils;
+using osu.Framework.Utils;
namespace osu.Game.Overlays.BeatmapListing
{
diff --git a/osu.Game/Overlays/BeatmapListingOverlay.cs b/osu.Game/Overlays/BeatmapListingOverlay.cs
index eafb7e95d5..cfa0ff00bc 100644
--- a/osu.Game/Overlays/BeatmapListingOverlay.cs
+++ b/osu.Game/Overlays/BeatmapListingOverlay.cs
@@ -160,23 +160,34 @@ namespace osu.Game.Overlays
Loading.Hide();
lastFetchDisplayedTime = Time.Current;
+ if (content == currentContent)
+ return;
+
var lastContent = currentContent;
if (lastContent != null)
{
- lastContent.FadeOut(100, Easing.OutQuint).Expire();
+ var transform = lastContent.FadeOut(100, Easing.OutQuint);
- // Consider the case when the new content is smaller than the last content.
- // If the auto-size computation is delayed until fade out completes, the background remain high for too long making the resulting transition to the smaller height look weird.
- // At the same time, if the last content's height is bypassed immediately, there is a period where the new content is at Alpha = 0 when the auto-sized height will be 0.
- // To resolve both of these issues, the bypass is delayed until a point when the content transitions (fade-in and fade-out) overlap and it looks good to do so.
- lastContent.Delay(25).Schedule(() => lastContent.BypassAutoSizeAxes = Axes.Y).Then().Schedule(() => panelTarget.Remove(lastContent));
+ if (lastContent == notFoundContent)
+ {
+ // not found display may be used multiple times, so don't expire/dispose it.
+ transform.Schedule(() => panelTarget.Remove(lastContent));
+ }
+ else
+ {
+ // Consider the case when the new content is smaller than the last content.
+ // If the auto-size computation is delayed until fade out completes, the background remain high for too long making the resulting transition to the smaller height look weird.
+ // At the same time, if the last content's height is bypassed immediately, there is a period where the new content is at Alpha = 0 when the auto-sized height will be 0.
+ // To resolve both of these issues, the bypass is delayed until a point when the content transitions (fade-in and fade-out) overlap and it looks good to do so.
+ lastContent.Delay(25).Schedule(() => lastContent.BypassAutoSizeAxes = Axes.Y).Then().Schedule(() => lastContent.Expire());
+ }
}
if (!content.IsAlive)
panelTarget.Add(content);
- content.FadeIn(200, Easing.OutQuint);
+ content.FadeInFromZero(200, Easing.OutQuint);
currentContent = content;
}
@@ -186,7 +197,7 @@ namespace osu.Game.Overlays
base.Dispose(isDisposing);
}
- private class NotFoundDrawable : CompositeDrawable
+ public class NotFoundDrawable : CompositeDrawable
{
public NotFoundDrawable()
{
diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs
index 324299ccba..ddd1dfa6cd 100644
--- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs
+++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs
@@ -7,6 +7,7 @@ using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Extensions;
+using osu.Framework.Extensions.EnumExtensions;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
@@ -15,7 +16,6 @@ using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Users.Drawables;
-using osu.Game.Utils;
using osuTK;
using osuTK.Graphics;
@@ -105,7 +105,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
var ruleset = scores.First().Ruleset.CreateInstance();
- foreach (var result in OrderAttributeUtils.GetValuesInOrder())
+ foreach (var result in EnumExtensions.GetValuesInOrder())
{
if (!allScoreStatistics.Contains(result))
continue;
diff --git a/osu.Game/Overlays/Chat/ChatLine.cs b/osu.Game/Overlays/Chat/ChatLine.cs
index 4eb348ae33..f43420e35e 100644
--- a/osu.Game/Overlays/Chat/ChatLine.cs
+++ b/osu.Game/Overlays/Chat/ChatLine.cs
@@ -190,13 +190,13 @@ namespace osu.Game.Overlays.Chat
}
}
};
-
- updateMessageContent();
}
protected override void LoadComplete()
{
base.LoadComplete();
+
+ updateMessageContent();
FinishTransforms(true);
}
diff --git a/osu.Game/Overlays/Chat/DrawableChannel.cs b/osu.Game/Overlays/Chat/DrawableChannel.cs
index 5926d11c03..86ce724390 100644
--- a/osu.Game/Overlays/Chat/DrawableChannel.cs
+++ b/osu.Game/Overlays/Chat/DrawableChannel.cs
@@ -16,6 +16,7 @@ using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics.Sprites;
+using osu.Framework.Utils;
using osu.Game.Graphics.Sprites;
namespace osu.Game.Overlays.Chat
@@ -24,7 +25,7 @@ namespace osu.Game.Overlays.Chat
{
public readonly Channel Channel;
protected FillFlowContainer ChatLineFlow;
- private OsuScrollContainer scroll;
+ private ChannelScrollContainer scroll;
private bool scrollbarVisible = true;
@@ -56,7 +57,7 @@ namespace osu.Game.Overlays.Chat
{
RelativeSizeAxes = Axes.Both,
Masking = true,
- Child = scroll = new OsuScrollContainer
+ Child = scroll = new ChannelScrollContainer
{
ScrollbarVisible = scrollbarVisible,
RelativeSizeAxes = Axes.Both,
@@ -80,12 +81,6 @@ namespace osu.Game.Overlays.Chat
Channel.PendingMessageResolved += pendingMessageResolved;
}
- protected override void LoadComplete()
- {
- base.LoadComplete();
- scrollToEnd();
- }
-
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
@@ -113,8 +108,6 @@ namespace osu.Game.Overlays.Chat
ChatLineFlow.Clear();
}
- bool shouldScrollToEnd = scroll.IsScrolledToEnd(10) || !chatLines.Any() || newMessages.Any(m => m is LocalMessage);
-
// Add up to last Channel.MAX_HISTORY messages
var displayMessages = newMessages.Skip(Math.Max(0, newMessages.Count() - Channel.MAX_HISTORY));
@@ -153,8 +146,10 @@ namespace osu.Game.Overlays.Chat
}
}
- if (shouldScrollToEnd)
- scrollToEnd();
+ // due to the scroll adjusts from old messages removal above, a scroll-to-end must be enforced,
+ // to avoid making the container think the user has scrolled back up and unwantedly disable auto-scrolling.
+ if (newMessages.Any(m => m is LocalMessage))
+ scroll.ScrollToEnd();
});
private void pendingMessageResolved(Message existing, Message updated) => Schedule(() =>
@@ -178,8 +173,6 @@ namespace osu.Game.Overlays.Chat
private IEnumerable chatLines => ChatLineFlow.Children.OfType();
- private void scrollToEnd() => ScheduleAfterChildren(() => scroll.ScrollToEnd());
-
public class DaySeparator : Container
{
public float TextSize
@@ -243,5 +236,51 @@ namespace osu.Game.Overlays.Chat
};
}
}
+
+ ///
+ /// An with functionality to automatically scroll whenever the maximum scrollable distance increases.
+ ///
+ private class ChannelScrollContainer : UserTrackingScrollContainer
+ {
+ ///
+ /// The chat will be automatically scrolled to end if and only if
+ /// the distance between the current scroll position and the end of the scroll
+ /// is less than this value.
+ ///
+ private const float auto_scroll_leniency = 10f;
+
+ private float? lastExtent;
+
+ protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = default)
+ {
+ base.OnUserScroll(value, animated, distanceDecay);
+ lastExtent = null;
+ }
+
+ protected override void Update()
+ {
+ base.Update();
+
+ // If the user has scrolled to the bottom of the container, we should resume tracking new content.
+ if (UserScrolling && IsScrolledToEnd(auto_scroll_leniency))
+ CancelUserScroll();
+
+ // If the user hasn't overridden our behaviour and there has been new content added to the container, we should update our scroll position to track it.
+ bool requiresScrollUpdate = !UserScrolling && (lastExtent == null || Precision.AlmostBigger(ScrollableExtent, lastExtent.Value));
+
+ if (requiresScrollUpdate)
+ {
+ // Schedule required to allow FillFlow to be the correct size.
+ Schedule(() =>
+ {
+ if (!UserScrolling)
+ {
+ ScrollToEnd();
+ lastExtent = ScrollableExtent;
+ }
+ });
+ }
+ }
+ }
}
}
diff --git a/osu.Game/Overlays/Comments/VotePill.cs b/osu.Game/Overlays/Comments/VotePill.cs
index aa9723ea85..cf3c470f96 100644
--- a/osu.Game/Overlays/Comments/VotePill.cs
+++ b/osu.Game/Overlays/Comments/VotePill.cs
@@ -33,11 +33,16 @@ namespace osu.Game.Overlays.Comments
[Resolved]
private IAPIProvider api { get; set; }
+ [Resolved(canBeNull: true)]
+ private LoginOverlay login { get; set; }
+
[Resolved]
private OverlayColourProvider colourProvider { get; set; }
+ protected Box Background { get; private set; }
+
private readonly Comment comment;
- private Box background;
+
private Box hoverLayer;
private CircularContainer borderContainer;
private SpriteText sideNumber;
@@ -62,8 +67,12 @@ namespace osu.Game.Overlays.Comments
AccentColour = borderContainer.BorderColour = sideNumber.Colour = colours.GreenLight;
hoverLayer.Colour = Color4.Black.Opacity(0.5f);
- if (api.IsLoggedIn && api.LocalUser.Value.Id != comment.UserId)
+ var ownComment = api.LocalUser.Value.Id == comment.UserId;
+
+ if (!ownComment)
Action = onAction;
+
+ Background.Alpha = ownComment ? 0 : 1;
}
protected override void LoadComplete()
@@ -71,12 +80,18 @@ namespace osu.Game.Overlays.Comments
base.LoadComplete();
isVoted.Value = comment.IsVoted;
votesCount.Value = comment.VotesCount;
- isVoted.BindValueChanged(voted => background.Colour = voted.NewValue ? AccentColour : colourProvider.Background6, true);
+ isVoted.BindValueChanged(voted => Background.Colour = voted.NewValue ? AccentColour : colourProvider.Background6, true);
votesCount.BindValueChanged(count => votesCounter.Text = $"+{count.NewValue}", true);
}
private void onAction()
{
+ if (!api.IsLoggedIn)
+ {
+ login?.Show();
+ return;
+ }
+
request = new CommentVoteRequest(comment.Id, isVoted.Value ? CommentVoteAction.UnVote : CommentVoteAction.Vote);
request.Success += onSuccess;
api.Queue(request);
@@ -102,7 +117,7 @@ namespace osu.Game.Overlays.Comments
Masking = true,
Children = new Drawable[]
{
- background = new Box
+ Background = new Box
{
RelativeSizeAxes = Axes.Both
},
diff --git a/osu.Game/Overlays/Mods/LocalPlayerModSelectOverlay.cs b/osu.Game/Overlays/Mods/LocalPlayerModSelectOverlay.cs
new file mode 100644
index 0000000000..78cd9bdae5
--- /dev/null
+++ b/osu.Game/Overlays/Mods/LocalPlayerModSelectOverlay.cs
@@ -0,0 +1,18 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Game.Rulesets.Mods;
+
+namespace osu.Game.Overlays.Mods
+{
+ public class LocalPlayerModSelectOverlay : ModSelectOverlay
+ {
+ protected override void OnModSelected(Mod mod)
+ {
+ base.OnModSelected(mod);
+
+ foreach (var section in ModSectionsContainer.Children)
+ section.DeselectTypes(mod.IncompatibleMods, true);
+ }
+ }
+}
diff --git a/osu.Game/Overlays/Mods/ModButton.cs b/osu.Game/Overlays/Mods/ModButton.cs
index ab8efdabcc..8e0d1f5bbd 100644
--- a/osu.Game/Overlays/Mods/ModButton.cs
+++ b/osu.Game/Overlays/Mods/ModButton.cs
@@ -236,13 +236,13 @@ namespace osu.Game.Overlays.Mods
{
iconsContainer.AddRange(new[]
{
- backgroundIcon = new PassThroughTooltipModIcon(Mods[1])
+ backgroundIcon = new ModIcon(Mods[1], false)
{
Origin = Anchor.BottomRight,
Anchor = Anchor.BottomRight,
Position = new Vector2(1.5f),
},
- foregroundIcon = new PassThroughTooltipModIcon(Mods[0])
+ foregroundIcon = new ModIcon(Mods[0], false)
{
Origin = Anchor.BottomRight,
Anchor = Anchor.BottomRight,
@@ -252,7 +252,7 @@ namespace osu.Game.Overlays.Mods
}
else
{
- iconsContainer.Add(foregroundIcon = new PassThroughTooltipModIcon(Mod)
+ iconsContainer.Add(foregroundIcon = new ModIcon(Mod, false)
{
Origin = Anchor.Centre,
Anchor = Anchor.Centre,
@@ -297,15 +297,5 @@ namespace osu.Game.Overlays.Mods
Mod = mod;
}
-
- private class PassThroughTooltipModIcon : ModIcon
- {
- public override string TooltipText => null;
-
- public PassThroughTooltipModIcon(Mod mod)
- : base(mod)
- {
- }
- }
}
}
diff --git a/osu.Game/Overlays/Mods/ModSection.cs b/osu.Game/Overlays/Mods/ModSection.cs
index 573d1e5355..ecbcba7ad3 100644
--- a/osu.Game/Overlays/Mods/ModSection.cs
+++ b/osu.Game/Overlays/Mods/ModSection.cs
@@ -11,31 +11,30 @@ using System;
using System.Linq;
using System.Collections.Generic;
using System.Threading;
+using Humanizer;
using osu.Framework.Input.Events;
using osu.Game.Graphics;
namespace osu.Game.Overlays.Mods
{
- public abstract class ModSection : Container
+ public class ModSection : CompositeDrawable
{
- private readonly OsuSpriteText headerLabel;
+ private readonly Drawable header;
public FillFlowContainer ButtonsContainer { get; }
public Action Action;
- protected abstract Key[] ToggleKeys { get; }
- public abstract ModType ModType { get; }
- public string Header
- {
- get => headerLabel.Text;
- set => headerLabel.Text = value;
- }
+ public Key[] ToggleKeys;
+
+ public readonly ModType ModType;
public IEnumerable SelectedMods => buttons.Select(b => b.SelectedMod).Where(m => m != null);
private CancellationTokenSource modsLoadCts;
+ protected bool SelectionAnimationRunning => pendingSelectionOperations.Count > 0;
+
///
/// True when all mod icons have completed loading.
///
@@ -52,7 +51,11 @@ namespace osu.Game.Overlays.Mods
return new ModButton(m)
{
- SelectionChanged = Action,
+ SelectionChanged = mod =>
+ {
+ ModButtonStateChanged(mod);
+ Action?.Invoke(mod);
+ },
};
}).ToArray();
@@ -61,7 +64,7 @@ namespace osu.Game.Overlays.Mods
if (modContainers.Length == 0)
{
ModIconsLoaded = true;
- headerLabel.Hide();
+ header.Hide();
Hide();
return;
}
@@ -76,11 +79,15 @@ namespace osu.Game.Overlays.Mods
buttons = modContainers.OfType().ToArray();
- headerLabel.FadeIn(200);
+ header.FadeIn(200);
this.FadeIn(200);
}
}
+ protected virtual void ModButtonStateChanged(Mod mod)
+ {
+ }
+
private ModButton[] buttons = Array.Empty();
protected override bool OnKeyDown(KeyDownEvent e)
@@ -97,30 +104,75 @@ namespace osu.Game.Overlays.Mods
return base.OnKeyDown(e);
}
- public void DeselectAll() => DeselectTypes(buttons.Select(b => b.SelectedMod?.GetType()).Where(t => t != null));
+ private const double initial_multiple_selection_delay = 120;
+
+ private double selectionDelay = initial_multiple_selection_delay;
+ private double lastSelection;
+
+ private readonly Queue pendingSelectionOperations = new Queue();
+
+ protected override void Update()
+ {
+ base.Update();
+
+ if (selectionDelay == initial_multiple_selection_delay || Time.Current - lastSelection >= selectionDelay)
+ {
+ if (pendingSelectionOperations.TryDequeue(out var dequeuedAction))
+ {
+ dequeuedAction();
+
+ // each time we play an animation, we decrease the time until the next animation (to ramp the visual and audible elements).
+ selectionDelay = Math.Max(30, selectionDelay * 0.8f);
+ lastSelection = Time.Current;
+ }
+ else
+ {
+ // reset the selection delay after all animations have been completed.
+ // this will cause the next action to be immediately performed.
+ selectionDelay = initial_multiple_selection_delay;
+ }
+ }
+ }
+
+ ///
+ /// Selects all mods.
+ ///
+ public void SelectAll()
+ {
+ pendingSelectionOperations.Clear();
+
+ foreach (var button in buttons.Where(b => !b.Selected))
+ pendingSelectionOperations.Enqueue(() => button.SelectAt(0));
+ }
+
+ ///
+ /// Deselects all mods.
+ ///
+ public void DeselectAll()
+ {
+ pendingSelectionOperations.Clear();
+ DeselectTypes(buttons.Select(b => b.SelectedMod?.GetType()).Where(t => t != null));
+ }
///
/// Deselect one or more mods in this section.
///
/// The types of s which should be deselected.
- /// Set to true to bypass animations and update selections immediately.
+ /// Whether the deselection should happen immediately. Should only be used when required to ensure correct selection flow.
public void DeselectTypes(IEnumerable modTypes, bool immediate = false)
{
- int delay = 0;
-
foreach (var button in buttons)
{
- Mod selected = button.SelectedMod;
- if (selected == null) continue;
+ if (button.SelectedMod == null) continue;
foreach (var type in modTypes)
{
- if (type.IsInstanceOfType(selected))
+ if (type.IsInstanceOfType(button.SelectedMod))
{
if (immediate)
button.Deselect();
else
- Scheduler.AddDelayed(button.Deselect, delay += 50);
+ pendingSelectionOperations.Enqueue(button.Deselect);
}
}
}
@@ -130,13 +182,13 @@ namespace osu.Game.Overlays.Mods
/// Updates all buttons with the given list of selected mods.
///
/// The new list of selected mods to select.
- public void UpdateSelectedMods(IReadOnlyList newSelectedMods)
+ public void UpdateSelectedButtons(IReadOnlyList newSelectedMods)
{
foreach (var button in buttons)
- updateButtonMods(button, newSelectedMods);
+ updateButtonSelection(button, newSelectedMods);
}
- private void updateButtonMods(ModButton button, IReadOnlyList newSelectedMods)
+ private void updateButtonSelection(ModButton button, IReadOnlyList newSelectedMods)
{
foreach (var mod in newSelectedMods)
{
@@ -153,23 +205,19 @@ namespace osu.Game.Overlays.Mods
button.Deselect();
}
- protected ModSection()
+ public ModSection(ModType type)
{
+ ModType = type;
+
AutoSizeAxes = Axes.Y;
RelativeSizeAxes = Axes.X;
Origin = Anchor.TopCentre;
Anchor = Anchor.TopCentre;
- Children = new Drawable[]
+ InternalChildren = new[]
{
- headerLabel = new OsuSpriteText
- {
- Origin = Anchor.TopLeft,
- Anchor = Anchor.TopLeft,
- Position = new Vector2(0f, 0f),
- Font = OsuFont.GetFont(weight: FontWeight.Bold)
- },
+ header = CreateHeader(type.Humanize(LetterCasing.Title)),
ButtonsContainer = new FillFlowContainer
{
AutoSizeAxes = Axes.Y,
@@ -185,5 +233,20 @@ namespace osu.Game.Overlays.Mods
},
};
}
+
+ protected virtual Drawable CreateHeader(string text) => new OsuSpriteText
+ {
+ Font = OsuFont.GetFont(weight: FontWeight.Bold),
+ Text = text
+ };
+
+ ///
+ /// Play out all remaining animations immediately to leave mods in a good (final) state.
+ ///
+ public void FlushAnimation()
+ {
+ while (pendingSelectionOperations.TryDequeue(out var dequeuedAction))
+ dequeuedAction();
+ }
}
}
diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs
index b93602116b..93fe693937 100644
--- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs
+++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs
@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
@@ -19,30 +20,54 @@ using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
-using osu.Game.Overlays.Mods.Sections;
using osu.Game.Rulesets.Mods;
using osu.Game.Screens;
+using osu.Game.Utils;
using osuTK;
using osuTK.Graphics;
using osuTK.Input;
namespace osu.Game.Overlays.Mods
{
- public class ModSelectOverlay : WaveOverlayContainer
+ public abstract class ModSelectOverlay : WaveOverlayContainer
{
- private readonly Func isValidMod;
public const float HEIGHT = 510;
protected readonly TriangleButton DeselectAllButton;
protected readonly TriangleButton CustomiseButton;
protected readonly TriangleButton CloseButton;
+ protected readonly Drawable MultiplierSection;
protected readonly OsuSpriteText MultiplierLabel;
+ protected readonly FillFlowContainer FooterContainer;
+
protected override bool BlockNonPositionalInput => false;
protected override bool DimMainContent => false;
+ ///
+ /// Whether s underneath the same instance should appear as stacked buttons.
+ ///
+ protected virtual bool Stacked => true;
+
+ [NotNull]
+ private Func isValidMod = m => true;
+
+ ///
+ /// A function that checks whether a given mod is selectable.
+ ///
+ [NotNull]
+ public Func IsValidMod
+ {
+ get => isValidMod;
+ set
+ {
+ isValidMod = value ?? throw new ArgumentNullException(nameof(value));
+ updateAvailableMods();
+ }
+ }
+
protected readonly FillFlowContainer ModSectionsContainer;
protected readonly ModSettingsContainer ModSettingsContainer;
@@ -57,14 +82,10 @@ namespace osu.Game.Overlays.Mods
private const float content_width = 0.8f;
private const float footer_button_spacing = 20;
- private readonly FillFlowContainer footerContainer;
-
private SampleChannel sampleOn, sampleOff;
- public ModSelectOverlay(Func isValidMod = null)
+ protected ModSelectOverlay()
{
- this.isValidMod = isValidMod ?? (m => true);
-
Waves.FirstWaveColour = Color4Extensions.FromHex(@"19b0e2");
Waves.SecondWaveColour = Color4Extensions.FromHex(@"2280a2");
Waves.ThirdWaveColour = Color4Extensions.FromHex(@"005774");
@@ -190,13 +211,31 @@ namespace osu.Game.Overlays.Mods
Width = content_width,
LayoutDuration = 200,
LayoutEasing = Easing.OutQuint,
- Children = new ModSection[]
+ Children = new[]
{
- new DifficultyReductionSection { Action = modButtonPressed },
- new DifficultyIncreaseSection { Action = modButtonPressed },
- new AutomationSection { Action = modButtonPressed },
- new ConversionSection { Action = modButtonPressed },
- new FunSection { Action = modButtonPressed },
+ CreateModSection(ModType.DifficultyReduction).With(s =>
+ {
+ s.ToggleKeys = new[] { Key.Q, Key.W, Key.E, Key.R, Key.T, Key.Y, Key.U, Key.I, Key.O, Key.P };
+ s.Action = modButtonPressed;
+ }),
+ CreateModSection(ModType.DifficultyIncrease).With(s =>
+ {
+ s.ToggleKeys = new[] { Key.A, Key.S, Key.D, Key.F, Key.G, Key.H, Key.J, Key.K, Key.L };
+ s.Action = modButtonPressed;
+ }),
+ CreateModSection(ModType.Automation).With(s =>
+ {
+ s.ToggleKeys = new[] { Key.Z, Key.X, Key.C, Key.V, Key.B, Key.N, Key.M };
+ s.Action = modButtonPressed;
+ }),
+ CreateModSection(ModType.Conversion).With(s =>
+ {
+ s.Action = modButtonPressed;
+ }),
+ CreateModSection(ModType.Fun).With(s =>
+ {
+ s.Action = modButtonPressed;
+ }),
}
},
}
@@ -216,9 +255,9 @@ namespace osu.Game.Overlays.Mods
},
new Drawable[]
{
- // Footer
new Container
{
+ Name = "Footer content",
RelativeSizeAxes = Axes.X,
AutoSizeAxes = Axes.Y,
Origin = Anchor.TopCentre,
@@ -231,22 +270,21 @@ namespace osu.Game.Overlays.Mods
Colour = new Color4(172, 20, 116, 255),
Alpha = 0.5f,
},
- footerContainer = new FillFlowContainer
+ FooterContainer = new FillFlowContainer
{
Origin = Anchor.BottomCentre,
Anchor = Anchor.BottomCentre,
AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X,
+ RelativePositionAxes = Axes.X,
Width = content_width,
Spacing = new Vector2(footer_button_spacing, footer_button_spacing / 2),
- LayoutDuration = 100,
- LayoutEasing = Easing.OutQuint,
Padding = new MarginPadding
{
Vertical = 15,
Horizontal = OsuScreen.HORIZONTAL_OVERFLOW_PADDING
},
- Children = new Drawable[]
+ Children = new[]
{
DeselectAllButton = new TriangleButton
{
@@ -273,7 +311,7 @@ namespace osu.Game.Overlays.Mods
Origin = Anchor.CentreLeft,
Anchor = Anchor.CentreLeft,
},
- new FillFlowContainer
+ MultiplierSection = new FillFlowContainer
{
AutoSizeAxes = Axes.Both,
Spacing = new Vector2(footer_button_spacing / 2, 0),
@@ -329,33 +367,25 @@ namespace osu.Game.Overlays.Mods
refreshSelectedMods();
}
- ///
- /// Deselect one or more mods.
- ///
- /// The types of s which should be deselected.
- /// Set to true to bypass animations and update selections immediately.
- private void deselectTypes(Type[] modTypes, bool immediate = false)
- {
- if (modTypes.Length == 0) return;
-
- foreach (var section in ModSectionsContainer.Children)
- section.DeselectTypes(modTypes, immediate);
- }
-
protected override void LoadComplete()
{
base.LoadComplete();
- availableMods.BindValueChanged(availableModsChanged, true);
- SelectedMods.BindValueChanged(selectedModsChanged, true);
+ availableMods.BindValueChanged(_ => updateAvailableMods(), true);
+ SelectedMods.BindValueChanged(_ => updateSelectedButtons(), true);
}
protected override void PopOut()
{
base.PopOut();
- footerContainer.MoveToX(footerContainer.DrawSize.X, WaveContainer.DISAPPEAR_DURATION, Easing.InSine);
- footerContainer.FadeOut(WaveContainer.DISAPPEAR_DURATION, Easing.InSine);
+ foreach (var section in ModSectionsContainer)
+ {
+ section.FlushAnimation();
+ }
+
+ FooterContainer.MoveToX(content_width, WaveContainer.DISAPPEAR_DURATION, Easing.InSine);
+ FooterContainer.FadeOut(WaveContainer.DISAPPEAR_DURATION, Easing.InSine);
foreach (var section in ModSectionsContainer.Children)
{
@@ -369,8 +399,8 @@ namespace osu.Game.Overlays.Mods
{
base.PopIn();
- footerContainer.MoveToX(0, WaveContainer.APPEAR_DURATION, Easing.OutQuint);
- footerContainer.FadeIn(WaveContainer.APPEAR_DURATION, Easing.OutQuint);
+ FooterContainer.MoveToX(0, WaveContainer.APPEAR_DURATION, Easing.OutQuint);
+ FooterContainer.FadeIn(WaveContainer.APPEAR_DURATION, Easing.OutQuint);
foreach (var section in ModSectionsContainer.Children)
{
@@ -401,18 +431,53 @@ namespace osu.Game.Overlays.Mods
public override bool OnPressed(GlobalAction action) => false; // handled by back button
- private void availableModsChanged(ValueChangedEvent>> mods)
+ private void updateAvailableMods()
{
- if (mods.NewValue == null) return;
+ if (availableMods?.Value == null)
+ return;
foreach (var section in ModSectionsContainer.Children)
- section.Mods = mods.NewValue[section.ModType].Where(isValidMod);
+ {
+ IEnumerable modEnumeration = availableMods.Value[section.ModType];
+
+ if (!Stacked)
+ modEnumeration = ModUtils.FlattenMods(modEnumeration);
+
+ section.Mods = modEnumeration.Select(getValidModOrNull).Where(m => m != null);
+ }
+
+ updateSelectedButtons();
}
- private void selectedModsChanged(ValueChangedEvent> mods)
+ ///
+ /// Returns a valid form of a given if possible, or null otherwise.
+ ///
+ ///
+ /// This is a recursive process during which any invalid mods are culled while preserving structures where possible.
+ ///
+ /// The to check.
+ /// A valid form of if exists, or null otherwise.
+ [CanBeNull]
+ private Mod getValidModOrNull([NotNull] Mod mod)
{
+ if (!(mod is MultiMod multi))
+ return IsValidMod(mod) ? mod : null;
+
+ var validSubset = multi.Mods.Select(getValidModOrNull).Where(m => m != null).ToArray();
+
+ if (validSubset.Length == 0)
+ return null;
+
+ return validSubset.Length == 1 ? validSubset[0] : new MultiMod(validSubset);
+ }
+
+ private void updateSelectedButtons()
+ {
+ // Enumeration below may update the bindable list.
+ var selectedMods = SelectedMods.Value.ToList();
+
foreach (var section in ModSectionsContainer.Children)
- section.UpdateSelectedMods(mods.NewValue);
+ section.UpdateSelectedButtons(selectedMods);
updateMods();
}
@@ -439,22 +504,42 @@ namespace osu.Game.Overlays.Mods
{
if (selectedMod != null)
{
- if (State.Value == Visibility.Visible) sampleOn?.Play();
+ if (State.Value == Visibility.Visible)
+ Scheduler.AddOnce(playSelectedSound);
- deselectTypes(selectedMod.IncompatibleMods, true);
+ OnModSelected(selectedMod);
if (selectedMod.RequiresConfiguration) ModSettingsContainer.Show();
}
else
{
- if (State.Value == Visibility.Visible) sampleOff?.Play();
+ if (State.Value == Visibility.Visible)
+ Scheduler.AddOnce(playDeselectedSound);
}
refreshSelectedMods();
}
+ private void playSelectedSound() => sampleOn?.Play();
+ private void playDeselectedSound() => sampleOff?.Play();
+
+ ///
+ /// Invoked when a new has been selected.
+ ///
+ /// The that has been selected.
+ protected virtual void OnModSelected(Mod mod)
+ {
+ }
+
private void refreshSelectedMods() => SelectedMods.Value = ModSectionsContainer.Children.SelectMany(s => s.SelectedMods).ToArray();
+ ///
+ /// Creates a that groups s with the same .
+ ///
+ /// The of s in the section.
+ /// The .
+ protected virtual ModSection CreateModSection(ModType type) => new ModSection(type);
+
#region Disposal
protected override void Dispose(bool isDisposing)
diff --git a/osu.Game/Overlays/Mods/Sections/AutomationSection.cs b/osu.Game/Overlays/Mods/Sections/AutomationSection.cs
deleted file mode 100644
index a2d7fec15f..0000000000
--- a/osu.Game/Overlays/Mods/Sections/AutomationSection.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using osu.Game.Rulesets.Mods;
-using osuTK.Input;
-
-namespace osu.Game.Overlays.Mods.Sections
-{
- public class AutomationSection : ModSection
- {
- protected override Key[] ToggleKeys => new[] { Key.Z, Key.X, Key.C, Key.V, Key.B, Key.N, Key.M };
- public override ModType ModType => ModType.Automation;
-
- public AutomationSection()
- {
- Header = @"Automation";
- }
- }
-}
diff --git a/osu.Game/Overlays/Mods/Sections/ConversionSection.cs b/osu.Game/Overlays/Mods/Sections/ConversionSection.cs
deleted file mode 100644
index 24fd8c30dd..0000000000
--- a/osu.Game/Overlays/Mods/Sections/ConversionSection.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using osu.Game.Rulesets.Mods;
-using osuTK.Input;
-
-namespace osu.Game.Overlays.Mods.Sections
-{
- public class ConversionSection : ModSection
- {
- protected override Key[] ToggleKeys => null;
- public override ModType ModType => ModType.Conversion;
-
- public ConversionSection()
- {
- Header = @"Conversion";
- }
- }
-}
diff --git a/osu.Game/Overlays/Mods/Sections/DifficultyIncreaseSection.cs b/osu.Game/Overlays/Mods/Sections/DifficultyIncreaseSection.cs
deleted file mode 100644
index 0b7ccd1f25..0000000000
--- a/osu.Game/Overlays/Mods/Sections/DifficultyIncreaseSection.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using osu.Game.Rulesets.Mods;
-using osuTK.Input;
-
-namespace osu.Game.Overlays.Mods.Sections
-{
- public class DifficultyIncreaseSection : ModSection
- {
- protected override Key[] ToggleKeys => new[] { Key.A, Key.S, Key.D, Key.F, Key.G, Key.H, Key.J, Key.K, Key.L };
- public override ModType ModType => ModType.DifficultyIncrease;
-
- public DifficultyIncreaseSection()
- {
- Header = @"Difficulty Increase";
- }
- }
-}
diff --git a/osu.Game/Overlays/Mods/Sections/DifficultyReductionSection.cs b/osu.Game/Overlays/Mods/Sections/DifficultyReductionSection.cs
deleted file mode 100644
index 508e92508b..0000000000
--- a/osu.Game/Overlays/Mods/Sections/DifficultyReductionSection.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using osu.Game.Rulesets.Mods;
-using osuTK.Input;
-
-namespace osu.Game.Overlays.Mods.Sections
-{
- public class DifficultyReductionSection : ModSection
- {
- protected override Key[] ToggleKeys => new[] { Key.Q, Key.W, Key.E, Key.R, Key.T, Key.Y, Key.U, Key.I, Key.O, Key.P };
- public override ModType ModType => ModType.DifficultyReduction;
-
- public DifficultyReductionSection()
- {
- Header = @"Difficulty Reduction";
- }
- }
-}
diff --git a/osu.Game/Overlays/Mods/Sections/FunSection.cs b/osu.Game/Overlays/Mods/Sections/FunSection.cs
deleted file mode 100644
index af1f5836b1..0000000000
--- a/osu.Game/Overlays/Mods/Sections/FunSection.cs
+++ /dev/null
@@ -1,19 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using osu.Game.Rulesets.Mods;
-using osuTK.Input;
-
-namespace osu.Game.Overlays.Mods.Sections
-{
- public class FunSection : ModSection
- {
- protected override Key[] ToggleKeys => null;
- public override ModType ModType => ModType.Fun;
-
- public FunSection()
- {
- Header = @"Fun";
- }
- }
-}
diff --git a/osu.Game/Overlays/OverlayScrollContainer.cs b/osu.Game/Overlays/OverlayScrollContainer.cs
index b67d5db1a4..0004719b87 100644
--- a/osu.Game/Overlays/OverlayScrollContainer.cs
+++ b/osu.Game/Overlays/OverlayScrollContainer.cs
@@ -17,9 +17,9 @@ using osuTK.Graphics;
namespace osu.Game.Overlays
{
///
- /// which provides . Mostly used in .
+ /// which provides . Mostly used in .
///
- public class OverlayScrollContainer : OsuScrollContainer
+ public class OverlayScrollContainer : UserTrackingScrollContainer
{
///
/// Scroll position at which the will be shown.
diff --git a/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs
index 658cdb8ce3..04a1040e06 100644
--- a/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs
+++ b/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs
@@ -49,9 +49,12 @@ namespace osu.Game.Overlays.Profile.Header
Spacing = new Vector2(10, 0),
Children = new Drawable[]
{
- new AddFriendButton
+ new FollowersButton
+ {
+ User = { BindTarget = User }
+ },
+ new MappingSubscribersButton
{
- RelativeSizeAxes = Axes.Y,
User = { BindTarget = User }
},
new MessageUserButton
@@ -69,7 +72,6 @@ namespace osu.Game.Overlays.Profile.Header
Width = UserProfileOverlay.CONTENT_X_MARGIN,
Child = new ExpandDetailsButton
{
- RelativeSizeAxes = Axes.Y,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
DetailsVisible = { BindTarget = DetailsVisible }
diff --git a/osu.Game/Overlays/Profile/Header/Components/AddFriendButton.cs b/osu.Game/Overlays/Profile/Header/Components/AddFriendButton.cs
deleted file mode 100644
index 6c2b2dc16a..0000000000
--- a/osu.Game/Overlays/Profile/Header/Components/AddFriendButton.cs
+++ /dev/null
@@ -1,60 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using osu.Framework.Allocation;
-using osu.Framework.Bindables;
-using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
-using osu.Framework.Graphics.Sprites;
-using osu.Game.Graphics;
-using osu.Game.Graphics.Sprites;
-using osu.Game.Users;
-using osuTK;
-
-namespace osu.Game.Overlays.Profile.Header.Components
-{
- public class AddFriendButton : ProfileHeaderButton
- {
- public readonly Bindable User = new Bindable();
-
- public override string TooltipText => "friends";
-
- private OsuSpriteText followerText;
-
- [BackgroundDependencyLoader]
- private void load()
- {
- Child = new FillFlowContainer
- {
- AutoSizeAxes = Axes.Both,
- Anchor = Anchor.CentreLeft,
- Origin = Anchor.CentreLeft,
- Direction = FillDirection.Horizontal,
- Padding = new MarginPadding { Right = 10 },
- Children = new Drawable[]
- {
- new SpriteIcon
- {
- Anchor = Anchor.CentreLeft,
- Origin = Anchor.CentreLeft,
- Icon = FontAwesome.Solid.User,
- FillMode = FillMode.Fit,
- Size = new Vector2(50, 14)
- },
- followerText = new OsuSpriteText
- {
- Anchor = Anchor.CentreLeft,
- Origin = Anchor.CentreLeft,
- Font = OsuFont.GetFont(weight: FontWeight.Bold)
- }
- }
- };
-
- // todo: when friending/unfriending is implemented, the APIAccess.Friends list should be updated accordingly.
-
- User.BindValueChanged(user => updateFollowers(user.NewValue), true);
- }
-
- private void updateFollowers(User user) => followerText.Text = user?.FollowerCount.ToString("#,##0");
- }
-}
diff --git a/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs b/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs
new file mode 100644
index 0000000000..bd8aa7b3bd
--- /dev/null
+++ b/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs
@@ -0,0 +1,26 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics.Sprites;
+using osu.Game.Users;
+
+namespace osu.Game.Overlays.Profile.Header.Components
+{
+ public class FollowersButton : ProfileHeaderStatisticsButton
+ {
+ public readonly Bindable User = new Bindable();
+
+ public override string TooltipText => "followers";
+
+ protected override IconUsage Icon => FontAwesome.Solid.User;
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ // todo: when friending/unfriending is implemented, the APIAccess.Friends list should be updated accordingly.
+ User.BindValueChanged(user => SetValue(user.NewValue?.FollowerCount ?? 0), true);
+ }
+ }
+}
diff --git a/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs b/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs
new file mode 100644
index 0000000000..b4d7c9a05c
--- /dev/null
+++ b/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs
@@ -0,0 +1,25 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics.Sprites;
+using osu.Game.Users;
+
+namespace osu.Game.Overlays.Profile.Header.Components
+{
+ public class MappingSubscribersButton : ProfileHeaderStatisticsButton
+ {
+ public readonly Bindable User = new Bindable();
+
+ public override string TooltipText => "mapping subscribers";
+
+ protected override IconUsage Icon => FontAwesome.Solid.Bell;
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ User.BindValueChanged(user => SetValue(user.NewValue?.MappingFollowerCount ?? 0), true);
+ }
+ }
+}
diff --git a/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs b/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs
index cc6edcdd6a..228765ee1a 100644
--- a/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs
+++ b/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs
@@ -33,7 +33,6 @@ namespace osu.Game.Overlays.Profile.Header.Components
public MessageUserButton()
{
Content.Alpha = 0;
- RelativeSizeAxes = Axes.Y;
Child = new SpriteIcon
{
diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs
index e14d73dd98..cea63574cf 100644
--- a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs
+++ b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderButton.cs
@@ -22,6 +22,7 @@ namespace osu.Game.Overlays.Profile.Header.Components
protected ProfileHeaderButton()
{
AutoSizeAxes = Axes.X;
+ Height = 40;
base.Content.Add(new CircularContainer
{
diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs
new file mode 100644
index 0000000000..b65d5e2329
--- /dev/null
+++ b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs
@@ -0,0 +1,51 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Sprites;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Sprites;
+using osuTK;
+
+namespace osu.Game.Overlays.Profile.Header.Components
+{
+ public abstract class ProfileHeaderStatisticsButton : ProfileHeaderButton
+ {
+ private readonly OsuSpriteText drawableText;
+
+ protected ProfileHeaderStatisticsButton()
+ {
+ Child = new FillFlowContainer
+ {
+ AutoSizeAxes = Axes.X,
+ RelativeSizeAxes = Axes.Y,
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Direction = FillDirection.Horizontal,
+ Children = new Drawable[]
+ {
+ new SpriteIcon
+ {
+ Anchor = Anchor.CentreLeft,
+ Origin = Anchor.CentreLeft,
+ Icon = Icon,
+ FillMode = FillMode.Fit,
+ Size = new Vector2(50, 14)
+ },
+ drawableText = new OsuSpriteText
+ {
+ Anchor = Anchor.CentreLeft,
+ Origin = Anchor.CentreLeft,
+ Margin = new MarginPadding { Right = 10 },
+ Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold)
+ }
+ }
+ };
+ }
+
+ protected abstract IconUsage Icon { get; }
+
+ protected void SetValue(int value) => drawableText.Text = value.ToString("#,##0");
+ }
+}
diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs
index 4cfd801caf..7c8309fd56 100644
--- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs
+++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs
@@ -108,7 +108,7 @@ namespace osu.Game.Overlays.Settings.Sections
if (skinDropdown.Items.All(s => s.ID != configBindable.Value))
configBindable.Value = 0;
- configBindable.BindValueChanged(id => dropdownBindable.Value = skinDropdown.Items.Single(s => s.ID == id.NewValue), true);
+ configBindable.BindValueChanged(id => Scheduler.AddOnce(updateSelectedSkinFromConfig), true);
dropdownBindable.BindValueChanged(skin =>
{
if (skin.NewValue == random_skin_info)
@@ -121,6 +121,23 @@ namespace osu.Game.Overlays.Settings.Sections
});
}
+ private void updateSelectedSkinFromConfig()
+ {
+ int id = configBindable.Value;
+
+ var skin = skinDropdown.Items.FirstOrDefault(s => s.ID == id);
+
+ if (skin == null)
+ {
+ // there may be a thread race condition where an item is selected that hasn't yet been added to the dropdown.
+ // to avoid adding complexity, let's just ensure the item is added so we can perform the selection.
+ skin = skins.Query(s => s.ID == id);
+ addItem(skin);
+ }
+
+ dropdownBindable.Value = skin;
+ }
+
private void updateItems()
{
skinItems = skins.GetAllUsableSkins();
@@ -132,14 +149,14 @@ namespace osu.Game.Overlays.Settings.Sections
private void itemUpdated(ValueChangedEvent> weakItem)
{
if (weakItem.NewValue.TryGetTarget(out var item))
- {
- Schedule(() =>
- {
- List newDropdownItems = skinDropdown.Items.Where(i => !i.Equals(item)).Append(item).ToList();
- sortUserSkins(newDropdownItems);
- skinDropdown.Items = newDropdownItems;
- });
- }
+ Schedule(() => addItem(item));
+ }
+
+ private void addItem(SkinInfo item)
+ {
+ List newDropdownItems = skinDropdown.Items.Where(i => !i.Equals(item)).Append(item).ToList();
+ sortUserSkins(newDropdownItems);
+ skinDropdown.Items = newDropdownItems;
}
private void itemRemoved(ValueChangedEvent> weakItem)
diff --git a/osu.Game/Overlays/UserProfileOverlay.cs b/osu.Game/Overlays/UserProfileOverlay.cs
index c29df72501..ccd9c291c4 100644
--- a/osu.Game/Overlays/UserProfileOverlay.cs
+++ b/osu.Game/Overlays/UserProfileOverlay.cs
@@ -201,7 +201,7 @@ namespace osu.Game.Overlays
RelativeSizeAxes = Axes.Both;
}
- protected override OsuScrollContainer CreateScrollContainer() => new OverlayScrollContainer();
+ protected override UserTrackingScrollContainer CreateScrollContainer() => new OverlayScrollContainer();
protected override FlowContainer CreateScrollContentContainer() => new FillFlowContainer
{
diff --git a/osu.Game/Replays/Legacy/LegacyReplayFrame.cs b/osu.Game/Replays/Legacy/LegacyReplayFrame.cs
index 74bacae9e1..ab9ccda9b9 100644
--- a/osu.Game/Replays/Legacy/LegacyReplayFrame.cs
+++ b/osu.Game/Replays/Legacy/LegacyReplayFrame.cs
@@ -1,38 +1,51 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using MessagePack;
using Newtonsoft.Json;
using osu.Game.Rulesets.Replays;
using osuTK;
namespace osu.Game.Replays.Legacy
{
+ [MessagePackObject]
public class LegacyReplayFrame : ReplayFrame
{
[JsonIgnore]
+ [IgnoreMember]
public Vector2 Position => new Vector2(MouseX ?? 0, MouseY ?? 0);
+ [Key(1)]
public float? MouseX;
+
+ [Key(2)]
public float? MouseY;
[JsonIgnore]
+ [IgnoreMember]
public bool MouseLeft => MouseLeft1 || MouseLeft2;
[JsonIgnore]
+ [IgnoreMember]
public bool MouseRight => MouseRight1 || MouseRight2;
[JsonIgnore]
+ [IgnoreMember]
public bool MouseLeft1 => ButtonState.HasFlag(ReplayButtonState.Left1);
[JsonIgnore]
+ [IgnoreMember]
public bool MouseRight1 => ButtonState.HasFlag(ReplayButtonState.Right1);
[JsonIgnore]
+ [IgnoreMember]
public bool MouseLeft2 => ButtonState.HasFlag(ReplayButtonState.Left2);
[JsonIgnore]
+ [IgnoreMember]
public bool MouseRight2 => ButtonState.HasFlag(ReplayButtonState.Right2);
+ [Key(3)]
public ReplayButtonState ButtonState;
public LegacyReplayFrame(double time, float? mouseX, float? mouseY, ReplayButtonState buttonState)
diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs
index 35852f60ea..e927951d0a 100644
--- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs
+++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs
@@ -332,7 +332,7 @@ namespace osu.Game.Rulesets.Edit
EditorBeatmap.Add(hitObject);
if (EditorClock.CurrentTime < hitObject.StartTime)
- EditorClock.SeekTo(hitObject.StartTime);
+ EditorClock.SeekSmoothlyTo(hitObject.StartTime);
}
}
diff --git a/osu.Game/Rulesets/Mods/ModHardRock.cs b/osu.Game/Rulesets/Mods/ModHardRock.cs
index 0e589735c1..4edcb0b074 100644
--- a/osu.Game/Rulesets/Mods/ModHardRock.cs
+++ b/osu.Game/Rulesets/Mods/ModHardRock.cs
@@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Mods
{
}
- public void ApplyToDifficulty(BeatmapDifficulty difficulty)
+ public virtual void ApplyToDifficulty(BeatmapDifficulty difficulty)
{
const float ratio = 1.4f;
difficulty.CircleSize = Math.Min(difficulty.CircleSize * 1.3f, 10.0f); // CS uses a custom 1.3 ratio.
diff --git a/osu.Game/Rulesets/Mods/ModTimeRamp.cs b/osu.Game/Rulesets/Mods/ModTimeRamp.cs
index 4d43ae73d3..b6916c838e 100644
--- a/osu.Game/Rulesets/Mods/ModTimeRamp.cs
+++ b/osu.Game/Rulesets/Mods/ModTimeRamp.cs
@@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Mods
///
/// The point in the beatmap at which the final ramping rate should be reached.
///
- private const double final_rate_progress = 0.75f;
+ public const double FINAL_RATE_PROGRESS = 0.75f;
[SettingSource("Initial rate", "The starting speed of the track")]
public abstract BindableNumber InitialRate { get; }
@@ -66,17 +66,18 @@ namespace osu.Game.Rulesets.Mods
public virtual void ApplyToBeatmap(IBeatmap beatmap)
{
- HitObject lastObject = beatmap.HitObjects.LastOrDefault();
-
SpeedChange.SetDefault();
- beginRampTime = beatmap.HitObjects.FirstOrDefault()?.StartTime ?? 0;
- finalRateTime = final_rate_progress * (lastObject?.GetEndTime() ?? 0);
+ double firstObjectStart = beatmap.HitObjects.FirstOrDefault()?.StartTime ?? 0;
+ double lastObjectEnd = beatmap.HitObjects.LastOrDefault()?.GetEndTime() ?? 0;
+
+ beginRampTime = firstObjectStart;
+ finalRateTime = firstObjectStart + FINAL_RATE_PROGRESS * (lastObjectEnd - firstObjectStart);
}
public virtual void Update(Playfield playfield)
{
- applyRateAdjustment((track.CurrentTime - beginRampTime) / finalRateTime);
+ applyRateAdjustment((track.CurrentTime - beginRampTime) / Math.Max(1, finalRateTime - beginRampTime));
}
///
diff --git a/osu.Game/Rulesets/Replays/ReplayFrame.cs b/osu.Game/Rulesets/Replays/ReplayFrame.cs
index 85e068ae79..7de53211a2 100644
--- a/osu.Game/Rulesets/Replays/ReplayFrame.cs
+++ b/osu.Game/Rulesets/Replays/ReplayFrame.cs
@@ -1,10 +1,14 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using MessagePack;
+
namespace osu.Game.Rulesets.Replays
{
+ [MessagePackObject]
public class ReplayFrame
{
+ [Key(0)]
public double Time;
public ReplayFrame()
diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs
index b3b3d11ab3..dbc2bd4d01 100644
--- a/osu.Game/Rulesets/Ruleset.cs
+++ b/osu.Game/Rulesets/Ruleset.cs
@@ -24,9 +24,9 @@ using osu.Game.Skinning;
using osu.Game.Users;
using JetBrains.Annotations;
using osu.Framework.Extensions;
+using osu.Framework.Extensions.EnumExtensions;
using osu.Framework.Testing;
using osu.Game.Screens.Ranking.Statistics;
-using osu.Game.Utils;
namespace osu.Game.Rulesets
{
@@ -272,7 +272,7 @@ namespace osu.Game.Rulesets
var validResults = GetValidHitResults();
// enumerate over ordered list to guarantee return order is stable.
- foreach (var result in OrderAttributeUtils.GetValuesInOrder())
+ foreach (var result in EnumExtensions.GetValuesInOrder())
{
switch (result)
{
@@ -298,7 +298,7 @@ namespace osu.Game.Rulesets
///
/// is implicitly included. Special types like are ignored even when specified.
///
- protected virtual IEnumerable GetValidHitResults() => OrderAttributeUtils.GetValuesInOrder();
+ protected virtual IEnumerable GetValidHitResults() => EnumExtensions.GetValuesInOrder();
///
/// Get a display friendly name for the specified result type.
diff --git a/osu.Game/Rulesets/Scoring/HitResult.cs b/osu.Game/Rulesets/Scoring/HitResult.cs
index 6a3a034fc1..eaa1f95744 100644
--- a/osu.Game/Rulesets/Scoring/HitResult.cs
+++ b/osu.Game/Rulesets/Scoring/HitResult.cs
@@ -3,7 +3,7 @@
using System.ComponentModel;
using System.Diagnostics;
-using osu.Game.Utils;
+using osu.Framework.Utils;
namespace osu.Game.Rulesets.Scoring
{
diff --git a/osu.Game/Rulesets/UI/HitObjectContainer.cs b/osu.Game/Rulesets/UI/HitObjectContainer.cs
index 1972043ccb..11312a46df 100644
--- a/osu.Game/Rulesets/UI/HitObjectContainer.cs
+++ b/osu.Game/Rulesets/UI/HitObjectContainer.cs
@@ -17,19 +17,10 @@ using osu.Game.Rulesets.Objects.Drawables;
namespace osu.Game.Rulesets.UI
{
- public class HitObjectContainer : LifetimeManagementContainer
+ public class HitObjectContainer : LifetimeManagementContainer, IHitObjectContainer
{
- ///
- /// All currently in-use s.
- ///
public IEnumerable Objects => InternalChildren.Cast().OrderBy(h => h.HitObject.StartTime);
- ///
- /// All currently in-use s that are alive.
- ///
- ///
- /// If this uses pooled objects, this is equivalent to .
- ///
public IEnumerable AliveObjects => AliveInternalChildren.Cast().OrderBy(h => h.HitObject.StartTime);
///
diff --git a/osu.Game/Rulesets/UI/IHitObjectContainer.cs b/osu.Game/Rulesets/UI/IHitObjectContainer.cs
new file mode 100644
index 0000000000..4c784132e8
--- /dev/null
+++ b/osu.Game/Rulesets/UI/IHitObjectContainer.cs
@@ -0,0 +1,24 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using osu.Game.Rulesets.Objects.Drawables;
+
+namespace osu.Game.Rulesets.UI
+{
+ public interface IHitObjectContainer
+ {
+ ///
+ /// All currently in-use s.
+ ///
+ IEnumerable Objects { get; }
+
+ ///
+ /// All currently in-use s that are alive.
+ ///
+ ///
+ /// If this uses pooled objects, this is equivalent to .
+ ///
+ IEnumerable AliveObjects { get; }
+ }
+}
diff --git a/osu.Game/Rulesets/UI/ModIcon.cs b/osu.Game/Rulesets/UI/ModIcon.cs
index 8ea6c74349..cae5da3d16 100644
--- a/osu.Game/Rulesets/UI/ModIcon.cs
+++ b/osu.Game/Rulesets/UI/ModIcon.cs
@@ -16,6 +16,9 @@ using osu.Framework.Bindables;
namespace osu.Game.Rulesets.UI
{
+ ///
+ /// Display the specified mod at a fixed size.
+ ///
public class ModIcon : Container, IHasTooltip
{
public readonly BindableBool Selected = new BindableBool();
@@ -26,11 +29,10 @@ namespace osu.Game.Rulesets.UI
private const float size = 80;
- private readonly ModType type;
-
- public virtual string TooltipText => mod.IconTooltip;
+ public virtual string TooltipText => showTooltip ? mod.IconTooltip : null;
private Mod mod;
+ private readonly bool showTooltip;
public Mod Mod
{
@@ -38,15 +40,27 @@ namespace osu.Game.Rulesets.UI
set
{
mod = value;
- updateMod(value);
+
+ if (IsLoaded)
+ updateMod(value);
}
}
- public ModIcon(Mod mod)
+ [Resolved]
+ private OsuColour colours { get; set; }
+
+ private Color4 backgroundColour;
+ private Color4 highlightedColour;
+
+ ///
+ /// Construct a new instance.
+ ///
+ /// The mod to be displayed
+ /// Whether a tooltip describing the mod should display on hover.
+ public ModIcon(Mod mod, bool showTooltip = true)
{
this.mod = mod ?? throw new ArgumentNullException(nameof(mod));
-
- type = mod.Type;
+ this.showTooltip = showTooltip;
Size = new Vector2(size);
@@ -79,6 +93,13 @@ namespace osu.Game.Rulesets.UI
Icon = FontAwesome.Solid.Question
},
};
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ Selected.BindValueChanged(_ => updateColour());
updateMod(mod);
}
@@ -92,20 +113,14 @@ namespace osu.Game.Rulesets.UI
{
modIcon.FadeOut();
modAcronym.FadeIn();
- return;
+ }
+ else
+ {
+ modIcon.FadeIn();
+ modAcronym.FadeOut();
}
- modIcon.FadeIn();
- modAcronym.FadeOut();
- }
-
- private Color4 backgroundColour;
- private Color4 highlightedColour;
-
- [BackgroundDependencyLoader]
- private void load(OsuColour colours)
- {
- switch (type)
+ switch (value.Type)
{
default:
case ModType.DifficultyIncrease:
@@ -139,12 +154,13 @@ namespace osu.Game.Rulesets.UI
modIcon.Colour = colours.Yellow;
break;
}
+
+ updateColour();
}
- protected override void LoadComplete()
+ private void updateColour()
{
- base.LoadComplete();
- Selected.BindValueChanged(selected => background.Colour = selected.NewValue ? highlightedColour : backgroundColour, true);
+ background.Colour = Selected.Value ? highlightedColour : backgroundColour;
}
}
}
diff --git a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs
index 0955f32790..6ffdad211b 100644
--- a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs
+++ b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs
@@ -91,7 +91,11 @@ namespace osu.Game.Rulesets.UI.Scrolling
scrollingInfo = new LocalScrollingInfo();
scrollingInfo.Direction.BindTo(Direction);
scrollingInfo.TimeRange.BindTo(TimeRange);
+ }
+ [BackgroundDependencyLoader]
+ private void load()
+ {
switch (VisualisationMethod)
{
case ScrollVisualisationMethod.Sequential:
@@ -106,11 +110,7 @@ namespace osu.Game.Rulesets.UI.Scrolling
scrollingInfo.Algorithm = new ConstantScrollAlgorithm();
break;
}
- }
- [BackgroundDependencyLoader]
- private void load()
- {
double lastObjectTime = Objects.LastOrDefault()?.GetEndTime() ?? double.MaxValue;
double baseBeatLength = TimingControlPoint.DEFAULT_BEAT_LENGTH;
diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BookmarkPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BookmarkPart.cs
index 103e39e78a..8298cf4773 100644
--- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BookmarkPart.cs
+++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BookmarkPart.cs
@@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
-using osu.Game.Beatmaps;
using osu.Game.Graphics;
using osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations;
@@ -13,7 +12,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts
///
public class BookmarkPart : TimelinePart
{
- protected override void LoadBeatmap(WorkingBeatmap beatmap)
+ protected override void LoadBeatmap(EditorBeatmap beatmap)
{
base.LoadBeatmap(beatmap);
foreach (int bookmark in beatmap.BeatmapInfo.Bookmarks)
diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BreakPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BreakPart.cs
index ceccbffc9c..e8a4b5c8c7 100644
--- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BreakPart.cs
+++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/BreakPart.cs
@@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
-using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Timing;
using osu.Game.Graphics;
using osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations;
@@ -14,10 +13,10 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts
///
public class BreakPart : TimelinePart
{
- protected override void LoadBeatmap(WorkingBeatmap beatmap)
+ protected override void LoadBeatmap(EditorBeatmap beatmap)
{
base.LoadBeatmap(beatmap);
- foreach (var breakPeriod in beatmap.Beatmap.Breaks)
+ foreach (var breakPeriod in beatmap.Breaks)
Add(new BreakVisualisation(breakPeriod));
}
diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointPart.cs
index e76ab71e54..70afc1e308 100644
--- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointPart.cs
+++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/ControlPointPart.cs
@@ -4,7 +4,6 @@
using System.Collections.Specialized;
using System.Linq;
using osu.Framework.Bindables;
-using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts
@@ -16,12 +15,12 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts
{
private readonly IBindableList controlPointGroups = new BindableList();
- protected override void LoadBeatmap(WorkingBeatmap beatmap)
+ protected override void LoadBeatmap(EditorBeatmap beatmap)
{
base.LoadBeatmap(beatmap);
controlPointGroups.UnbindAll();
- controlPointGroups.BindTo(beatmap.Beatmap.ControlPointInfo.Groups);
+ controlPointGroups.BindTo(beatmap.ControlPointInfo.Groups);
controlPointGroups.BindCollectionChanged((sender, args) =>
{
switch (args.Action)
diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs
index 9e9ac93d23..d551333616 100644
--- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs
+++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/MarkerPart.cs
@@ -2,15 +2,14 @@
// See the LICENCE file in the repository root for full licence text.
using System;
-using osuTK;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Framework.Threading;
-using osu.Game.Beatmaps;
using osu.Game.Graphics;
+using osuTK;
namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts
{
@@ -54,11 +53,8 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts
scheduledSeek?.Cancel();
scheduledSeek = Schedule(() =>
{
- if (Beatmap.Value == null)
- return;
-
float markerPos = Math.Clamp(ToLocalSpace(screenPosition).X, 0, DrawWidth);
- editorClock.SeekTo(markerPos / DrawWidth * editorClock.TrackLength);
+ editorClock.SeekSmoothlyTo(markerPos / DrawWidth * editorClock.TrackLength);
});
}
@@ -68,7 +64,7 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts
marker.X = (float)editorClock.CurrentTime;
}
- protected override void LoadBeatmap(WorkingBeatmap beatmap)
+ protected override void LoadBeatmap(EditorBeatmap beatmap)
{
// block base call so we don't clear our marker (can be reused on beatmap change).
}
diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs
index 5b8f7c747b..5aba81aa7d 100644
--- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs
+++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/TimelinePart.cs
@@ -21,7 +21,10 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts
///
public class TimelinePart : Container where T : Drawable
{
- protected readonly IBindable Beatmap = new Bindable();
+ private readonly IBindable beatmap = new Bindable();
+
+ [Resolved]
+ protected EditorBeatmap EditorBeatmap { get; private set; }
protected readonly IBindable