diff --git a/osu.Android.props b/osu.Android.props
index cd57d7478e..171a0862a1 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -52,7 +52,7 @@
-
+
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSlidersTest.cs b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSlidersTest.cs
new file mode 100644
index 0000000000..2eab5a4ce6
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSlidersTest.cs
@@ -0,0 +1,145 @@
+// 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.Game.Beatmaps;
+using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Osu.Edit.Checks;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Tests.Beatmaps;
+using osuTK;
+
+namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks
+{
+ [TestFixture]
+ public class CheckTooShortSlidersTest
+ {
+ private CheckTooShortSliders check;
+
+ [SetUp]
+ public void Setup()
+ {
+ check = new CheckTooShortSliders();
+ }
+
+ [Test]
+ public void TestLongSlider()
+ {
+ Slider slider = new Slider
+ {
+ StartTime = 0,
+ RepeatCount = 0,
+ Path = new SliderPath(new[]
+ {
+ new PathControlPoint(new Vector2(0, 0)),
+ new PathControlPoint(new Vector2(100, 0))
+ })
+ };
+
+ slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
+
+ assertOk(new List { slider });
+ }
+
+ [Test]
+ public void TestShortSlider()
+ {
+ Slider slider = new Slider
+ {
+ StartTime = 0,
+ RepeatCount = 0,
+ Path = new SliderPath(new[]
+ {
+ new PathControlPoint(new Vector2(0, 0)),
+ new PathControlPoint(new Vector2(25, 0))
+ })
+ };
+
+ slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
+
+ assertOk(new List { slider });
+ }
+
+ [Test]
+ public void TestTooShortSliderExpert()
+ {
+ Slider slider = new Slider
+ {
+ StartTime = 0,
+ RepeatCount = 0,
+ Path = new SliderPath(new[]
+ {
+ new PathControlPoint(new Vector2(0, 0)),
+ new PathControlPoint(new Vector2(10, 0))
+ })
+ };
+
+ slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
+
+ assertOk(new List { slider }, DifficultyRating.Expert);
+ }
+
+ [Test]
+ public void TestTooShortSlider()
+ {
+ Slider slider = new Slider
+ {
+ StartTime = 0,
+ RepeatCount = 0,
+ Path = new SliderPath(new[]
+ {
+ new PathControlPoint(new Vector2(0, 0)),
+ new PathControlPoint(new Vector2(10, 0))
+ })
+ };
+
+ slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
+
+ assertTooShort(new List { slider });
+ }
+
+ [Test]
+ public void TestTooShortSliderWithRepeats()
+ {
+ // Would be ok if we looked at the duration, but not if we look at the span duration.
+ Slider slider = new Slider
+ {
+ StartTime = 0,
+ RepeatCount = 2,
+ Path = new SliderPath(new[]
+ {
+ new PathControlPoint(new Vector2(0, 0)),
+ new PathControlPoint(new Vector2(10, 0))
+ })
+ };
+
+ slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
+
+ assertTooShort(new List { slider });
+ }
+
+ private void assertOk(List hitObjects, DifficultyRating difficultyRating = DifficultyRating.Easy)
+ {
+ Assert.That(check.Run(getContext(hitObjects, difficultyRating)), Is.Empty);
+ }
+
+ private void assertTooShort(List hitObjects, DifficultyRating difficultyRating = DifficultyRating.Easy)
+ {
+ var issues = check.Run(getContext(hitObjects, difficultyRating)).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.First().Template is CheckTooShortSliders.IssueTemplateTooShort);
+ }
+
+ private BeatmapVerifierContext getContext(List hitObjects, DifficultyRating difficultyRating)
+ {
+ var beatmap = new Beatmap { HitObjects = hitObjects };
+
+ return new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), difficultyRating);
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSpinnersTest.cs b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSpinnersTest.cs
new file mode 100644
index 0000000000..6a3f168ee1
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/Checks/CheckTooShortSpinnersTest.cs
@@ -0,0 +1,116 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using System.Linq;
+using NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Osu.Edit.Checks;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Tests.Beatmaps;
+
+namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks
+{
+ [TestFixture]
+ public class CheckTooShortSpinnersTest
+ {
+ private CheckTooShortSpinners check;
+ private BeatmapDifficulty difficulty;
+
+ [SetUp]
+ public void Setup()
+ {
+ check = new CheckTooShortSpinners();
+ difficulty = new BeatmapDifficulty();
+ }
+
+ [Test]
+ public void TestLongSpinner()
+ {
+ Spinner spinner = new Spinner { StartTime = 0, Duration = 4000 };
+ spinner.ApplyDefaults(new ControlPointInfo(), difficulty);
+
+ assertOk(new List { spinner }, difficulty);
+ }
+
+ [Test]
+ public void TestShortSpinner()
+ {
+ Spinner spinner = new Spinner { StartTime = 0, Duration = 750 };
+ spinner.ApplyDefaults(new ControlPointInfo(), difficulty);
+
+ assertOk(new List { spinner }, difficulty);
+ }
+
+ [Test]
+ public void TestVeryShortSpinner()
+ {
+ // Spinners at a certain duration only get 1000 points if approached by auto at a certain angle, making it difficult to determine.
+ Spinner spinner = new Spinner { StartTime = 0, Duration = 475 };
+ spinner.ApplyDefaults(new ControlPointInfo(), difficulty);
+
+ assertVeryShort(new List { spinner }, difficulty);
+ }
+
+ [Test]
+ public void TestTooShortSpinner()
+ {
+ Spinner spinner = new Spinner { StartTime = 0, Duration = 400 };
+ spinner.ApplyDefaults(new ControlPointInfo(), difficulty);
+
+ assertTooShort(new List { spinner }, difficulty);
+ }
+
+ [Test]
+ public void TestTooShortSpinnerVaryingOd()
+ {
+ const double duration = 450;
+
+ var difficultyLowOd = new BeatmapDifficulty { OverallDifficulty = 1 };
+ Spinner spinnerLowOd = new Spinner { StartTime = 0, Duration = duration };
+ spinnerLowOd.ApplyDefaults(new ControlPointInfo(), difficultyLowOd);
+
+ var difficultyHighOd = new BeatmapDifficulty { OverallDifficulty = 10 };
+ Spinner spinnerHighOd = new Spinner { StartTime = 0, Duration = duration };
+ spinnerHighOd.ApplyDefaults(new ControlPointInfo(), difficultyHighOd);
+
+ assertOk(new List { spinnerLowOd }, difficultyLowOd);
+ assertTooShort(new List { spinnerHighOd }, difficultyHighOd);
+ }
+
+ private void assertOk(List hitObjects, BeatmapDifficulty beatmapDifficulty)
+ {
+ Assert.That(check.Run(getContext(hitObjects, beatmapDifficulty)), Is.Empty);
+ }
+
+ private void assertVeryShort(List hitObjects, BeatmapDifficulty beatmapDifficulty)
+ {
+ var issues = check.Run(getContext(hitObjects, beatmapDifficulty)).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.First().Template is CheckTooShortSpinners.IssueTemplateVeryShort);
+ }
+
+ private void assertTooShort(List hitObjects, BeatmapDifficulty beatmapDifficulty)
+ {
+ var issues = check.Run(getContext(hitObjects, beatmapDifficulty)).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.First().Template is CheckTooShortSpinners.IssueTemplateTooShort);
+ }
+
+ private BeatmapVerifierContext getContext(List hitObjects, BeatmapDifficulty beatmapDifficulty)
+ {
+ var beatmap = new Beatmap
+ {
+ HitObjects = hitObjects,
+ BeatmapInfo = new BeatmapInfo { BaseDifficulty = beatmapDifficulty }
+ };
+
+ return new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu/Edit/Checks/CheckTooShortSliders.cs b/osu.Game.Rulesets.Osu/Edit/Checks/CheckTooShortSliders.cs
new file mode 100644
index 0000000000..159498c479
--- /dev/null
+++ b/osu.Game.Rulesets.Osu/Edit/Checks/CheckTooShortSliders.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 System.Collections.Generic;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Edit.Checks.Components;
+using osu.Game.Rulesets.Osu.Objects;
+
+namespace osu.Game.Rulesets.Osu.Edit.Checks
+{
+ public class CheckTooShortSliders : ICheck
+ {
+ ///
+ /// The shortest acceptable duration between the head and tail of the slider (so ignoring repeats).
+ ///
+ private const double span_duration_threshold = 125; // 240 BPM 1/2
+
+ public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Spread, "Too short sliders");
+
+ public IEnumerable PossibleTemplates => new IssueTemplate[]
+ {
+ new IssueTemplateTooShort(this)
+ };
+
+ public IEnumerable Run(BeatmapVerifierContext context)
+ {
+ if (context.InterpretedDifficulty > DifficultyRating.Easy)
+ yield break;
+
+ foreach (var hitObject in context.Beatmap.HitObjects)
+ {
+ if (hitObject is Slider slider && slider.SpanDuration < span_duration_threshold)
+ yield return new IssueTemplateTooShort(this).Create(slider);
+ }
+ }
+
+ public class IssueTemplateTooShort : IssueTemplate
+ {
+ public IssueTemplateTooShort(ICheck check)
+ : base(check, IssueType.Problem, "This slider is too short ({0:0} ms), expected at least {1:0} ms.")
+ {
+ }
+
+ public Issue Create(Slider slider) => new Issue(slider, this, slider.SpanDuration, span_duration_threshold);
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu/Edit/Checks/CheckTooShortSpinners.cs b/osu.Game.Rulesets.Osu/Edit/Checks/CheckTooShortSpinners.cs
new file mode 100644
index 0000000000..0d0c3d9e69
--- /dev/null
+++ b/osu.Game.Rulesets.Osu/Edit/Checks/CheckTooShortSpinners.cs
@@ -0,0 +1,61 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Edit.Checks.Components;
+using osu.Game.Rulesets.Osu.Objects;
+
+namespace osu.Game.Rulesets.Osu.Edit.Checks
+{
+ public class CheckTooShortSpinners : ICheck
+ {
+ public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Spread, "Too short spinners");
+
+ public IEnumerable PossibleTemplates => new IssueTemplate[]
+ {
+ new IssueTemplateTooShort(this)
+ };
+
+ public IEnumerable Run(BeatmapVerifierContext context)
+ {
+ double od = context.Beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty;
+
+ // These are meant to reflect the duration necessary for auto to score at least 1000 points on the spinner.
+ // It's difficult to eliminate warnings here, as auto achieving 1000 points depends on the approach angle on some spinners.
+ double warningThreshold = 500 + (od < 5 ? (5 - od) * -21.8 : (od - 5) * 20); // Anything above this is always ok.
+ double problemThreshold = 450 + (od < 5 ? (5 - od) * -17 : (od - 5) * 17); // Anything below this is never ok.
+
+ foreach (var hitObject in context.Beatmap.HitObjects)
+ {
+ if (!(hitObject is Spinner spinner))
+ continue;
+
+ if (spinner.Duration < problemThreshold)
+ yield return new IssueTemplateTooShort(this).Create(spinner);
+ else if (spinner.Duration < warningThreshold)
+ yield return new IssueTemplateVeryShort(this).Create(spinner);
+ }
+ }
+
+ public class IssueTemplateTooShort : IssueTemplate
+ {
+ public IssueTemplateTooShort(ICheck check)
+ : base(check, IssueType.Problem, "This spinner is too short. Auto cannot achieve 1000 points on this.")
+ {
+ }
+
+ public Issue Create(Spinner spinner) => new Issue(spinner, this);
+ }
+
+ public class IssueTemplateVeryShort : IssueTemplate
+ {
+ public IssueTemplateVeryShort(ICheck check)
+ : base(check, IssueType.Warning, "This spinner may be too short. Ensure auto can achieve 1000 points on this.")
+ {
+ }
+
+ public Issue Create(Spinner spinner) => new Issue(spinner, this);
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs b/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs
index 896e904f3f..221723e4cd 100644
--- a/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs
+++ b/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs
@@ -15,10 +15,12 @@ namespace osu.Game.Rulesets.Osu.Edit
{
// Compose
new CheckOffscreenObjects(),
+ new CheckTooShortSpinners(),
// Spread
new CheckTimeDistanceEquality(),
- new CheckLowDiffOverlaps()
+ new CheckLowDiffOverlaps(),
+ new CheckTooShortSliders(),
};
public IEnumerable Run(BeatmapVerifierContext context)
diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs
index d1212096bf..1a2e5d92b4 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs
@@ -4,7 +4,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
-using osu.Framework.Graphics;
+using osu.Framework.Graphics.Primitives;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mods;
@@ -26,6 +26,11 @@ namespace osu.Game.Rulesets.Osu.Mods
private static readonly float playfield_diagonal = OsuPlayfield.BASE_SIZE.LengthFast;
+ ///
+ /// Number of previous hitobjects to be shifted together when another object is being moved.
+ ///
+ private const int preceding_hitobjects_to_shift = 10;
+
private Random rng;
public void ApplyToBeatmap(IBeatmap beatmap)
@@ -49,8 +54,9 @@ namespace osu.Game.Rulesets.Osu.Mods
var current = new RandomObjectInfo(hitObject);
- // rateOfChangeMultiplier only changes every i iterations to prevent shaky-line-shaped streams
- if (i % 3 == 0)
+ // rateOfChangeMultiplier only changes every 5 iterations in a combo
+ // to prevent shaky-line-shaped streams
+ if (hitObject.IndexInCurrentCombo % 5 == 0)
rateOfChangeMultiplier = (float)rng.NextDouble() * 2 - 1;
if (hitObject is Spinner)
@@ -61,13 +67,35 @@ namespace osu.Game.Rulesets.Osu.Mods
applyRandomisation(rateOfChangeMultiplier, previous, current);
- hitObject.Position = current.PositionRandomised;
+ // Move hit objects back into the playfield if they are outside of it
+ Vector2 shift = Vector2.Zero;
- // update end position as it may have changed as a result of the position update.
- current.EndPositionRandomised = current.PositionRandomised;
+ switch (hitObject)
+ {
+ case HitCircle circle:
+ shift = clampHitCircleToPlayfield(circle, current);
+ break;
- if (hitObject is Slider slider)
- moveSliderIntoPlayfield(slider, current);
+ case Slider slider:
+ shift = clampSliderToPlayfield(slider, current);
+ break;
+ }
+
+ if (shift != Vector2.Zero)
+ {
+ var toBeShifted = new List();
+
+ for (int j = i - 1; j >= i - preceding_hitobjects_to_shift && j >= 0; j--)
+ {
+ // only shift hit circles
+ if (!(hitObjects[j] is HitCircle)) break;
+
+ toBeShifted.Add(hitObjects[j]);
+ }
+
+ if (toBeShifted.Count > 0)
+ applyDecreasingShift(toBeShifted, shift);
+ }
previous = current;
}
@@ -94,7 +122,9 @@ namespace osu.Game.Rulesets.Osu.Mods
// The max. angle (relative to the angle of the vector pointing from the 2nd last to the last hit object)
// is proportional to the distance between the last and the current hit object
// to allow jumps and prevent too sharp turns during streams.
- var randomAngleRad = rateOfChangeMultiplier * 2 * Math.PI * distanceToPrev / playfield_diagonal;
+
+ // Allow maximum jump angle when jump distance is more than half of playfield diagonal length
+ var randomAngleRad = rateOfChangeMultiplier * 2 * Math.PI * Math.Min(1f, distanceToPrev / (playfield_diagonal * 0.5f));
current.AngleRad = (float)randomAngleRad + previous.AngleRad;
if (current.AngleRad < 0)
@@ -109,56 +139,120 @@ namespace osu.Game.Rulesets.Osu.Mods
current.AngleRad = (float)Math.Atan2(posRelativeToPrev.Y, posRelativeToPrev.X);
- var position = previous.EndPositionRandomised + posRelativeToPrev;
+ current.PositionRandomised = previous.EndPositionRandomised + posRelativeToPrev;
+ }
- // Move hit objects back into the playfield if they are outside of it,
- // which would sometimes happen during big jumps otherwise.
- position.X = MathHelper.Clamp(position.X, 0, OsuPlayfield.BASE_SIZE.X);
- position.Y = MathHelper.Clamp(position.Y, 0, OsuPlayfield.BASE_SIZE.Y);
+ ///
+ /// Move the randomised position of a hit circle so that it fits inside the playfield.
+ ///
+ /// The deviation from the original randomised position in order to fit within the playfield.
+ private Vector2 clampHitCircleToPlayfield(HitCircle circle, RandomObjectInfo objectInfo)
+ {
+ var previousPosition = objectInfo.PositionRandomised;
+ objectInfo.EndPositionRandomised = objectInfo.PositionRandomised = clampToPlayfieldWithPadding(
+ objectInfo.PositionRandomised,
+ (float)circle.Radius
+ );
- current.PositionRandomised = position;
+ circle.Position = objectInfo.PositionRandomised;
+
+ return objectInfo.PositionRandomised - previousPosition;
}
///
/// Moves the and all necessary nested s into the if they aren't already.
///
- private void moveSliderIntoPlayfield(Slider slider, RandomObjectInfo currentObjectInfo)
+ /// The deviation from the original randomised position in order to fit within the playfield.
+ private Vector2 clampSliderToPlayfield(Slider slider, RandomObjectInfo objectInfo)
{
- var minMargin = getMinSliderMargin(slider);
+ var possibleMovementBounds = calculatePossibleMovementBounds(slider);
- slider.Position = new Vector2(
- Math.Clamp(slider.Position.X, minMargin.Left, OsuPlayfield.BASE_SIZE.X - minMargin.Right),
- Math.Clamp(slider.Position.Y, minMargin.Top, OsuPlayfield.BASE_SIZE.Y - minMargin.Bottom)
- );
+ var previousPosition = objectInfo.PositionRandomised;
- currentObjectInfo.PositionRandomised = slider.Position;
- currentObjectInfo.EndPositionRandomised = slider.EndPosition;
+ // Clamp slider position to the placement area
+ // If the slider is larger than the playfield, force it to stay at the original position
+ var newX = possibleMovementBounds.Width < 0
+ ? objectInfo.PositionOriginal.X
+ : Math.Clamp(previousPosition.X, possibleMovementBounds.Left, possibleMovementBounds.Right);
- shiftNestedObjects(slider, currentObjectInfo.PositionRandomised - currentObjectInfo.PositionOriginal);
+ var newY = possibleMovementBounds.Height < 0
+ ? objectInfo.PositionOriginal.Y
+ : Math.Clamp(previousPosition.Y, possibleMovementBounds.Top, possibleMovementBounds.Bottom);
+
+ slider.Position = objectInfo.PositionRandomised = new Vector2(newX, newY);
+ objectInfo.EndPositionRandomised = slider.EndPosition;
+
+ shiftNestedObjects(slider, objectInfo.PositionRandomised - objectInfo.PositionOriginal);
+
+ return objectInfo.PositionRandomised - previousPosition;
}
///
- /// Calculates the min. distances from the 's position to the playfield border for the slider to be fully inside of the playfield.
+ /// Decreasingly shift a list of s by a specified amount.
+ /// The first item in the list is shifted by the largest amount, while the last item is shifted by the smallest amount.
///
- private MarginPadding getMinSliderMargin(Slider slider)
+ /// The list of hit objects to be shifted.
+ /// The amount to be shifted.
+ private void applyDecreasingShift(IList hitObjects, Vector2 shift)
+ {
+ for (int i = 0; i < hitObjects.Count; i++)
+ {
+ var hitObject = hitObjects[i];
+ // The first object is shifted by a vector slightly smaller than shift
+ // The last object is shifted by a vector slightly larger than zero
+ Vector2 position = hitObject.Position + shift * ((hitObjects.Count - i) / (float)(hitObjects.Count + 1));
+
+ hitObject.Position = clampToPlayfieldWithPadding(position, (float)hitObject.Radius);
+ }
+ }
+
+ ///
+ /// Calculates a which contains all of the possible movements of the slider (in relative X/Y coordinates)
+ /// such that the entire slider is inside the playfield.
+ ///
+ ///
+ /// If the slider is larger than the playfield, the returned may have negative width/height.
+ ///
+ private RectangleF calculatePossibleMovementBounds(Slider slider)
{
var pathPositions = new List();
slider.Path.GetPathToProgress(pathPositions, 0, 1);
- var minMargin = new MarginPadding();
+ float minX = float.PositiveInfinity;
+ float maxX = float.NegativeInfinity;
+ float minY = float.PositiveInfinity;
+ float maxY = float.NegativeInfinity;
+
+ // Compute the bounding box of the slider.
foreach (var pos in pathPositions)
{
- minMargin.Left = Math.Max(minMargin.Left, -pos.X);
- minMargin.Right = Math.Max(minMargin.Right, pos.X);
- minMargin.Top = Math.Max(minMargin.Top, -pos.Y);
- minMargin.Bottom = Math.Max(minMargin.Bottom, pos.Y);
+ minX = MathF.Min(minX, pos.X);
+ maxX = MathF.Max(maxX, pos.X);
+
+ minY = MathF.Min(minY, pos.Y);
+ maxY = MathF.Max(maxY, pos.Y);
}
- minMargin.Left = Math.Min(minMargin.Left, OsuPlayfield.BASE_SIZE.X - minMargin.Right);
- minMargin.Top = Math.Min(minMargin.Top, OsuPlayfield.BASE_SIZE.Y - minMargin.Bottom);
+ // Take the circle radius into account.
+ var radius = (float)slider.Radius;
- return minMargin;
+ minX -= radius;
+ minY -= radius;
+
+ maxX += radius;
+ maxY += radius;
+
+ // Given the bounding box of the slider (via min/max X/Y),
+ // the amount that the slider can move to the left is minX (with the sign flipped, since positive X is to the right),
+ // and the amount that it can move to the right is WIDTH - maxX.
+ // Same calculation applies for the Y axis.
+ float left = -minX;
+ float right = OsuPlayfield.BASE_SIZE.X - maxX;
+ float top = -minY;
+ float bottom = OsuPlayfield.BASE_SIZE.Y - maxY;
+
+ return new RectangleF(left, top, right - left, bottom - top);
}
///
@@ -177,6 +271,20 @@ namespace osu.Game.Rulesets.Osu.Mods
}
}
+ ///
+ /// Clamp a position to playfield, keeping a specified distance from the edges.
+ ///
+ /// The position to be clamped.
+ /// The minimum distance allowed from playfield edges.
+ /// The clamped position.
+ private Vector2 clampToPlayfieldWithPadding(Vector2 position, float padding)
+ {
+ return new Vector2(
+ Math.Clamp(position.X, padding, OsuPlayfield.BASE_SIZE.X - padding),
+ Math.Clamp(position.Y, padding, OsuPlayfield.BASE_SIZE.Y - padding)
+ );
+ }
+
private class RandomObjectInfo
{
public float AngleRad { get; set; }
diff --git a/osu.Game.Tests/Editing/Checks/CheckZeroLengthObjectsTest.cs b/osu.Game.Tests/Editing/Checks/CheckZeroLengthObjectsTest.cs
new file mode 100644
index 0000000000..93b20cd166
--- /dev/null
+++ b/osu.Game.Tests/Editing/Checks/CheckZeroLengthObjectsTest.cs
@@ -0,0 +1,94 @@
+// 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 Moq;
+using NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Edit.Checks;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Types;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Tests.Beatmaps;
+using osuTK;
+
+namespace osu.Game.Tests.Editing.Checks
+{
+ [TestFixture]
+ public class CheckZeroLengthObjectsTest
+ {
+ private CheckZeroLengthObjects check;
+
+ [SetUp]
+ public void Setup()
+ {
+ check = new CheckZeroLengthObjects();
+ }
+
+ [Test]
+ public void TestCircle()
+ {
+ assertOk(new List
+ {
+ new HitCircle { StartTime = 1000, Position = new Vector2(0, 0) }
+ });
+ }
+
+ [Test]
+ public void TestRegularSlider()
+ {
+ assertOk(new List
+ {
+ getSliderMock(1000).Object
+ });
+ }
+
+ [Test]
+ public void TestZeroLengthSlider()
+ {
+ assertZeroLength(new List
+ {
+ getSliderMock(0).Object
+ });
+ }
+
+ [Test]
+ public void TestNegativeLengthSlider()
+ {
+ assertZeroLength(new List
+ {
+ getSliderMock(-1000).Object
+ });
+ }
+
+ private Mock getSliderMock(double duration)
+ {
+ var mockSlider = new Mock();
+ mockSlider.As().Setup(d => d.Duration).Returns(duration);
+
+ return mockSlider;
+ }
+
+ private void assertOk(List hitObjects)
+ {
+ Assert.That(check.Run(getContext(hitObjects)), Is.Empty);
+ }
+
+ private void assertZeroLength(List hitObjects)
+ {
+ var issues = check.Run(getContext(hitObjects)).ToList();
+
+ Assert.That(issues, Has.Count.EqualTo(1));
+ Assert.That(issues.First().Template is CheckZeroLengthObjects.IssueTemplateZeroLength);
+ }
+
+ private BeatmapVerifierContext getContext(List hitObjects)
+ {
+ var beatmap = new Beatmap { HitObjects = hitObjects };
+
+ return new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap));
+ }
+ }
+}
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs
index 2bb3129f68..7673efb78f 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs
@@ -21,6 +21,7 @@ using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Screens;
using osu.Game.Screens.OnlinePlay.Components;
+using osu.Game.Screens.OnlinePlay.Lounge;
using osu.Game.Screens.OnlinePlay.Match.Components;
using osu.Game.Screens.OnlinePlay.Multiplayer;
using osu.Game.Screens.OnlinePlay.Multiplayer.Match;
@@ -184,6 +185,26 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("play started", () => !multiplayerScreen.IsCurrentScreen());
}
+ [Test]
+ public void TestSubScreenExitedWhenDisconnectedFromMultiplayerServer()
+ {
+ createRoom(() => new Room
+ {
+ Name = { Value = "Test Room" },
+ Playlist =
+ {
+ new PlaylistItem
+ {
+ Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo },
+ Ruleset = { Value = new OsuRuleset().RulesetInfo },
+ }
+ }
+ });
+
+ AddStep("disconnect", () => client.Disconnect());
+ AddUntilStep("back in lounge", () => this.ChildrenOfType().FirstOrDefault()?.IsCurrentScreen() == true);
+ }
+
[Test]
public void TestLeaveNavigation()
{
diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMarkdownContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMarkdownContainer.cs
index 931af7bc95..82e26cb87d 100644
--- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMarkdownContainer.cs
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuMarkdownContainer.cs
@@ -95,6 +95,15 @@ _**italic with underscore, bold with asterisk**_";
});
}
+ [Test]
+ public void TestAutoLink()
+ {
+ AddStep("Add autolink", () =>
+ {
+ markdownContainer.Text = "";
+ });
+ }
+
[Test]
public void TestInlineCode()
{
diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuPopover.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuPopover.cs
new file mode 100644
index 0000000000..1848cf6a5e
--- /dev/null
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuPopover.cs
@@ -0,0 +1,103 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Extensions;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Cursor;
+using osu.Framework.Graphics.UserInterface;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Sprites;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Graphics.UserInterfaceV2;
+using osu.Game.Overlays;
+using osuTK;
+
+namespace osu.Game.Tests.Visual.UserInterface
+{
+ public class TestSceneOsuPopover : OsuGridTestScene
+ {
+ public TestSceneOsuPopover()
+ : base(1, 2)
+ {
+ Cell(0, 0).Child = new PopoverContainer
+ {
+ RelativeSizeAxes = Axes.Both,
+ Children = new Drawable[]
+ {
+ new OsuSpriteText
+ {
+ Text = @"No OverlayColourProvider",
+ Font = OsuFont.Default.With(size: 40)
+ },
+ new TriangleButtonWithPopover()
+ }
+ };
+
+ Cell(0, 1).Child = new ColourProvidingContainer(OverlayColourScheme.Orange)
+ {
+ RelativeSizeAxes = Axes.Both,
+ Child = new PopoverContainer
+ {
+ RelativeSizeAxes = Axes.Both,
+ Children = new Drawable[]
+ {
+ new OsuSpriteText
+ {
+ Text = @"With OverlayColourProvider (orange)",
+ Font = OsuFont.Default.With(size: 40)
+ },
+ new TriangleButtonWithPopover()
+ }
+ }
+ };
+ }
+
+ private class TriangleButtonWithPopover : TriangleButton, IHasPopover
+ {
+ public TriangleButtonWithPopover()
+ {
+ Width = 100;
+ Height = 30;
+ Anchor = Anchor.Centre;
+ Origin = Anchor.Centre;
+ Text = @"open";
+ Action = this.ShowPopover;
+ }
+
+ public Popover GetPopover() => new OsuPopover
+ {
+ Child = new FillFlowContainer
+ {
+ AutoSizeAxes = Axes.Both,
+ Direction = FillDirection.Vertical,
+ Spacing = new Vector2(10),
+ Children = new Drawable[]
+ {
+ new OsuSpriteText
+ {
+ Text = @"sample text"
+ },
+ new OsuTextBox
+ {
+ Width = 150,
+ Height = 30
+ }
+ }
+ }
+ };
+ }
+
+ private class ColourProvidingContainer : Container
+ {
+ [Cached]
+ private OverlayColourProvider provider { get; }
+
+ public ColourProvidingContainer(OverlayColourScheme colourScheme)
+ {
+ provider = new OverlayColourProvider(colourScheme);
+ }
+ }
+ }
+}
diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownLinkText.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownLinkText.cs
index f91a0e40e3..82e556f653 100644
--- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownLinkText.cs
+++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownLinkText.cs
@@ -26,6 +26,12 @@ namespace osu.Game.Graphics.Containers.Markdown
title = linkInline.Title;
}
+ public OsuMarkdownLinkText(AutolinkInline autolinkInline)
+ : base(autolinkInline)
+ {
+ text = autolinkInline.Url;
+ }
+
[BackgroundDependencyLoader]
private void load()
{
diff --git a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs
index 36b48b7769..a7cd6b3905 100644
--- a/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs
+++ b/osu.Game/Graphics/Containers/Markdown/OsuMarkdownTextFlowContainer.cs
@@ -17,6 +17,9 @@ namespace osu.Game.Graphics.Containers.Markdown
protected override void AddLinkText(string text, LinkInline linkInline)
=> AddDrawable(new OsuMarkdownLinkText(text, linkInline));
+ protected override void AddAutoLink(AutolinkInline autolinkInline)
+ => AddDrawable(new OsuMarkdownLinkText(autolinkInline));
+
protected override void AddImage(LinkInline linkInline) => AddDrawable(new OsuMarkdownImage(linkInline));
// TODO : Change font to monospace
diff --git a/osu.Game/Graphics/OsuColour.cs b/osu.Game/Graphics/OsuColour.cs
index a44c28eaa6..c0bc8fdb76 100644
--- a/osu.Game/Graphics/OsuColour.cs
+++ b/osu.Game/Graphics/OsuColour.cs
@@ -3,6 +3,7 @@
using osu.Framework.Extensions.Color4Extensions;
using osu.Game.Beatmaps;
+using osu.Game.Overlays;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osuTK.Graphics;
@@ -198,8 +199,14 @@ namespace osu.Game.Graphics
public readonly Color4 GrayE = Color4Extensions.FromHex(@"eee");
public readonly Color4 GrayF = Color4Extensions.FromHex(@"fff");
- // in latest editor design logic, need to figure out where these sit...
+ ///
+ /// Equivalent to 's .
+ ///
public readonly Color4 Lime1 = Color4Extensions.FromHex(@"b2ff66");
+
+ ///
+ /// Equivalent to 's .
+ ///
public readonly Color4 Orange1 = Color4Extensions.FromHex(@"ffd966");
// Content Background
diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs b/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs
new file mode 100644
index 0000000000..c07a5de1e4
--- /dev/null
+++ b/osu.Game/Graphics/UserInterfaceV2/OsuPopover.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 JetBrains.Annotations;
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Effects;
+using osu.Framework.Graphics.UserInterface;
+using osu.Game.Overlays;
+using osuTK;
+
+namespace osu.Game.Graphics.UserInterfaceV2
+{
+ public class OsuPopover : Popover
+ {
+ private const float fade_duration = 250;
+ private const double scale_duration = 500;
+
+ public OsuPopover(bool withPadding = true)
+ {
+ Content.Padding = withPadding ? new MarginPadding(20) : new MarginPadding();
+
+ Body.Masking = true;
+ Body.CornerRadius = 10;
+ Body.Margin = new MarginPadding(10);
+ Body.EdgeEffect = new EdgeEffectParameters
+ {
+ Type = EdgeEffectType.Shadow,
+ Offset = new Vector2(0, 2),
+ Radius = 5,
+ Colour = Colour4.Black.Opacity(0.3f)
+ };
+ }
+
+ [BackgroundDependencyLoader(true)]
+ private void load([CanBeNull] OverlayColourProvider colourProvider, OsuColour colours)
+ {
+ Background.Colour = Arrow.Colour = colourProvider?.Background4 ?? colours.GreySeafoamDarker;
+ }
+
+ protected override Drawable CreateArrow() => Empty();
+
+ protected override void PopIn()
+ {
+ this.ScaleTo(1, scale_duration, Easing.OutElasticHalf);
+ this.FadeIn(fade_duration, Easing.OutQuint);
+ }
+
+ protected override void PopOut()
+ {
+ this.ScaleTo(0.7f, scale_duration, Easing.OutQuint);
+ this.FadeOut(fade_duration, Easing.OutQuint);
+ }
+ }
+}
diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs
index c25b520892..8119df43ac 100644
--- a/osu.Game/OsuGame.cs
+++ b/osu.Game/OsuGame.cs
@@ -525,16 +525,11 @@ namespace osu.Game
private void beatmapChanged(ValueChangedEvent beatmap)
{
beatmap.OldValue?.CancelAsyncLoad();
-
- updateModDefaults();
-
beatmap.NewValue?.BeginAsyncLoad();
}
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;
@@ -546,19 +541,6 @@ namespace osu.Game
}
}
- private void updateModDefaults()
- {
- BeatmapDifficulty baseDifficulty = Beatmap.Value.BeatmapInfo.BaseDifficulty;
-
- if (baseDifficulty != null && SelectedMods.Value.Any(m => m is IApplicableToDifficulty))
- {
- var adjustedDifficulty = baseDifficulty.Clone();
-
- foreach (var mod in SelectedMods.Value.OfType())
- mod.ReadFromDifficulty(adjustedDifficulty);
- }
- }
-
#endregion
private PerformFromMenuRunner performFromMainMenuTask;
diff --git a/osu.Game/Overlays/OverlayColourProvider.cs b/osu.Game/Overlays/OverlayColourProvider.cs
index abd1e43f25..008e7696e1 100644
--- a/osu.Game/Overlays/OverlayColourProvider.cs
+++ b/osu.Game/Overlays/OverlayColourProvider.cs
@@ -14,6 +14,7 @@ namespace osu.Game.Overlays
public static OverlayColourProvider Red { get; } = new OverlayColourProvider(OverlayColourScheme.Red);
public static OverlayColourProvider Pink { get; } = new OverlayColourProvider(OverlayColourScheme.Pink);
public static OverlayColourProvider Orange { get; } = new OverlayColourProvider(OverlayColourScheme.Orange);
+ public static OverlayColourProvider Lime { get; } = new OverlayColourProvider(OverlayColourScheme.Lime);
public static OverlayColourProvider Green { get; } = new OverlayColourProvider(OverlayColourScheme.Green);
public static OverlayColourProvider Purple { get; } = new OverlayColourProvider(OverlayColourScheme.Purple);
public static OverlayColourProvider Blue { get; } = new OverlayColourProvider(OverlayColourScheme.Blue);
@@ -51,7 +52,7 @@ namespace osu.Game.Overlays
private Color4 getColour(float saturation, float lightness) => Color4.FromHsl(new Vector4(getBaseHue(colourScheme), saturation, lightness, 1));
- // See https://github.com/ppy/osu-web/blob/4218c288292d7c810b619075471eaea8bbb8f9d8/app/helpers.php#L1463
+ // See https://github.com/ppy/osu-web/blob/5a536d217a21582aad999db50a981003d3ad5659/app/helpers.php#L1620-L1628
private static float getBaseHue(OverlayColourScheme colourScheme)
{
switch (colourScheme)
@@ -66,10 +67,13 @@ namespace osu.Game.Overlays
return 333 / 360f;
case OverlayColourScheme.Orange:
- return 46 / 360f;
+ return 45 / 360f;
+
+ case OverlayColourScheme.Lime:
+ return 90 / 360f;
case OverlayColourScheme.Green:
- return 115 / 360f;
+ return 125 / 360f;
case OverlayColourScheme.Purple:
return 255 / 360f;
@@ -85,6 +89,7 @@ namespace osu.Game.Overlays
Red,
Pink,
Orange,
+ Lime,
Green,
Purple,
Blue
diff --git a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs
index 706eec226c..81f4808789 100644
--- a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs
+++ b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs
@@ -27,7 +27,8 @@ namespace osu.Game.Rulesets.Edit
// Compose
new CheckUnsnappedObjects(),
- new CheckConcurrentObjects()
+ new CheckConcurrentObjects(),
+ new CheckZeroLengthObjects(),
};
public IEnumerable Run(BeatmapVerifierContext context)
diff --git a/osu.Game/Rulesets/Edit/Checks/CheckZeroLengthObjects.cs b/osu.Game/Rulesets/Edit/Checks/CheckZeroLengthObjects.cs
new file mode 100644
index 0000000000..b9be94736b
--- /dev/null
+++ b/osu.Game/Rulesets/Edit/Checks/CheckZeroLengthObjects.cs
@@ -0,0 +1,47 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using osu.Game.Rulesets.Edit.Checks.Components;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Types;
+
+namespace osu.Game.Rulesets.Edit.Checks
+{
+ public class CheckZeroLengthObjects : ICheck
+ {
+ ///
+ /// The duration can be this low before being treated as having no length, in case of precision errors. Unit is milliseconds.
+ ///
+ private const double leniency = 0.5d;
+
+ public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Compose, "Zero-length hitobjects");
+
+ public IEnumerable PossibleTemplates => new IssueTemplate[]
+ {
+ new IssueTemplateZeroLength(this)
+ };
+
+ public IEnumerable Run(BeatmapVerifierContext context)
+ {
+ foreach (var hitObject in context.Beatmap.HitObjects)
+ {
+ if (!(hitObject is IHasDuration hasDuration))
+ continue;
+
+ if (hasDuration.Duration < leniency)
+ yield return new IssueTemplateZeroLength(this).Create(hitObject, hasDuration.Duration);
+ }
+ }
+
+ public class IssueTemplateZeroLength : IssueTemplate
+ {
+ public IssueTemplateZeroLength(ICheck check)
+ : base(check, IssueType.Problem, "{0} has a duration of {1:0}.")
+ {
+ }
+
+ public Issue Create(HitObject hitobject, double duration) => new Issue(hitobject, this, hitobject.GetType(), duration);
+ }
+ }
+}
diff --git a/osu.Game/Rulesets/Mods/IApplicableToDifficulty.cs b/osu.Game/Rulesets/Mods/IApplicableToDifficulty.cs
index 34198da722..42b520ab26 100644
--- a/osu.Game/Rulesets/Mods/IApplicableToDifficulty.cs
+++ b/osu.Game/Rulesets/Mods/IApplicableToDifficulty.cs
@@ -10,13 +10,6 @@ namespace osu.Game.Rulesets.Mods
///
public interface IApplicableToDifficulty : IApplicableMod
{
- ///
- /// Called when a beatmap is changed. Can be used to read default values.
- /// Any changes made will not be preserved.
- ///
- /// The difficulty to read from.
- void ReadFromDifficulty(BeatmapDifficulty difficulty);
-
///
/// Called post beatmap conversion. Can be used to apply changes to difficulty attributes.
///
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
index f2dd9a6f25..baf9570209 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
@@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using System;
-using System.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Audio;
@@ -72,25 +71,20 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
{
var localUser = Client.LocalUser;
- if (localUser == null)
- return;
+ int newCountReady = Room?.Users.Count(u => u.State == MultiplayerUserState.Ready) ?? 0;
+ int newCountTotal = Room?.Users.Count(u => u.State != MultiplayerUserState.Spectating) ?? 0;
- Debug.Assert(Room != null);
-
- int newCountReady = Room.Users.Count(u => u.State == MultiplayerUserState.Ready);
- int newCountTotal = Room.Users.Count(u => u.State != MultiplayerUserState.Spectating);
-
- string countText = $"({newCountReady} / {newCountTotal} ready)";
-
- switch (localUser.State)
+ switch (localUser?.State)
{
- case MultiplayerUserState.Idle:
+ default:
button.Text = "Ready";
updateButtonColour(true);
break;
case MultiplayerUserState.Spectating:
case MultiplayerUserState.Ready:
+ string countText = $"({newCountReady} / {newCountTotal} ready)";
+
if (Room?.Host?.Equals(localUser) == true)
{
button.Text = $"Start match {countText}";
@@ -108,7 +102,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
bool enableButton = Client.Room?.State == MultiplayerRoomState.Open && !operationInProgress.Value;
// When the local user is the host and spectating the match, the "start match" state should be enabled if any users are ready.
- if (localUser.State == MultiplayerUserState.Spectating)
+ if (localUser?.State == MultiplayerUserState.Spectating)
enableButton &= Room?.Host?.Equals(localUser) == true && newCountReady > 0;
button.Enabled.Value = enableButton;
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs
index 04150902bc..db99c6a5d5 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs
@@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using System;
-using System.Diagnostics;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
@@ -57,14 +56,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
private void updateState()
{
- var localUser = Client.LocalUser;
-
- if (localUser == null)
- return;
-
- Debug.Assert(Room != null);
-
- switch (localUser.State)
+ switch (Client.LocalUser?.State)
{
default:
button.Text = "Spectate";
@@ -81,7 +73,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
break;
}
- button.Enabled.Value = Client.Room?.State != MultiplayerRoomState.Closed && !operationInProgress.Value;
+ button.Enabled.Value = Client.Room != null
+ && Client.Room.State != MultiplayerRoomState.Closed
+ && !operationInProgress.Value;
}
private class ButtonWithTrianglesExposed : TriangleButton
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs
index 4b8c4422ec..561fa220c8 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs
@@ -48,6 +48,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
[Resolved]
private OngoingOperationTracker ongoingOperationTracker { get; set; }
+ [Resolved]
+ private Bindable currentRoom { get; set; }
+
private MultiplayerMatchSettingsOverlay settingsOverlay;
private readonly IBindable isConnected = new Bindable();
@@ -273,6 +276,17 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
if (!connected.NewValue)
Schedule(this.Exit);
}, true);
+
+ currentRoom.BindValueChanged(room =>
+ {
+ if (room.NewValue == null)
+ {
+ // the room has gone away.
+ // this could mean something happened during the join process, or an external connection issue occurred.
+ // one specific scenario is where the underlying room is created, but the signalr server returns an error during the join process. this triggers a PartRoom operation (see https://github.com/ppy/osu/blob/7654df94f6f37b8382be7dfcb4f674e03bd35427/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs#L97)
+ Schedule(this.Exit);
+ }
+ }, true);
}
protected override void UpdateMods()
@@ -310,7 +324,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
public override bool OnExiting(IScreen next)
{
- if (client.Room == null)
+ // the room may not be left immediately after a disconnection due to async flow,
+ // so checking the IsConnected status is also required.
+ if (client.Room == null || !client.IsConnected.Value)
{
// room has not been created yet; exit immediately.
return base.OnExiting(next);
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index eb7a0141c7..b2f1d6507f 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -36,7 +36,7 @@
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
diff --git a/osu.iOS.props b/osu.iOS.props
index 2e5fab758d..dc15df6ea6 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -70,7 +70,7 @@
-
+
@@ -93,7 +93,7 @@
-
+