diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index cbd0231fdb..57694d7f57 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -21,7 +21,7 @@
]
},
"ppy.localisationanalyser.tools": {
- "version": "2022.607.0",
+ "version": "2022.809.0",
"commands": [
"localisation"
]
diff --git a/.globalconfig b/.globalconfig
index 462dbc74ed..a7b652c454 100644
--- a/.globalconfig
+++ b/.globalconfig
@@ -53,3 +53,7 @@ dotnet_diagnostic.CA2225.severity = none
# Banned APIs
dotnet_diagnostic.RS0030.severity = error
+
+# Temporarily disable analysing CanBeNull = true in NRT contexts due to mobile issues.
+# See: https://github.com/ppy/osu/pull/19677
+dotnet_diagnostic.OSUF001.severity = none
\ No newline at end of file
diff --git a/osu.Android.props b/osu.Android.props
index 247140ceef..17a6178641 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -51,11 +51,11 @@
-
-
+
+
-
+
diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs
index 9e4db1f1c9..4ae6cb9c7c 100644
--- a/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs
+++ b/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs
@@ -16,11 +16,11 @@ namespace osu.Game.Rulesets.Mania.Tests
{
protected override string ResourceAssembly => "osu.Game.Rulesets.Mania";
- [TestCase(2.3449735700206298d, 242, "diffcalc-test")]
+ [TestCase(2.3493769750220914d, 242, "diffcalc-test")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name);
- [TestCase(2.7879104989252959d, 242, "diffcalc-test")]
+ [TestCase(2.797245912537965d, 242, "diffcalc-test")]
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new ManiaModDoubleTime());
diff --git a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs
index c2e532430c..2c7c84de97 100644
--- a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs
+++ b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs
@@ -21,7 +21,8 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills
protected override double SkillMultiplier => 1;
protected override double StrainDecayBase => 1;
- private readonly double[] holdEndTimes;
+ private readonly double[] startTimes;
+ private readonly double[] endTimes;
private readonly double[] individualStrains;
private double individualStrain;
@@ -30,7 +31,8 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills
public Strain(Mod[] mods, int totalColumns)
: base(mods)
{
- holdEndTimes = new double[totalColumns];
+ startTimes = new double[totalColumns];
+ endTimes = new double[totalColumns];
individualStrains = new double[totalColumns];
overallStrain = 1;
}
@@ -38,32 +40,27 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills
protected override double StrainValueOf(DifficultyHitObject current)
{
var maniaCurrent = (ManiaDifficultyHitObject)current;
+ double startTime = maniaCurrent.StartTime;
double endTime = maniaCurrent.EndTime;
int column = maniaCurrent.BaseObject.Column;
- double closestEndTime = Math.Abs(endTime - maniaCurrent.LastObject.StartTime); // Lowest value we can assume with the current information
-
- double holdFactor = 1.0; // Factor to all additional strains in case something else is held
- double holdAddition = 0; // Addition to the current note in case it's a hold and has to be released awkwardly
bool isOverlapping = false;
- // Fill up the holdEndTimes array
- for (int i = 0; i < holdEndTimes.Length; ++i)
+ double closestEndTime = Math.Abs(endTime - startTime); // Lowest value we can assume with the current information
+ double holdFactor = 1.0; // Factor to all additional strains in case something else is held
+ double holdAddition = 0; // Addition to the current note in case it's a hold and has to be released awkwardly
+
+ for (int i = 0; i < endTimes.Length; ++i)
{
// The current note is overlapped if a previous note or end is overlapping the current note body
- isOverlapping |= Precision.DefinitelyBigger(holdEndTimes[i], maniaCurrent.StartTime, 1) && Precision.DefinitelyBigger(endTime, holdEndTimes[i], 1);
+ isOverlapping |= Precision.DefinitelyBigger(endTimes[i], startTime, 1) && Precision.DefinitelyBigger(endTime, endTimes[i], 1);
// We give a slight bonus to everything if something is held meanwhile
- if (Precision.DefinitelyBigger(holdEndTimes[i], endTime, 1))
+ if (Precision.DefinitelyBigger(endTimes[i], endTime, 1))
holdFactor = 1.25;
- closestEndTime = Math.Min(closestEndTime, Math.Abs(endTime - holdEndTimes[i]));
-
- // Decay individual strains
- individualStrains[i] = applyDecay(individualStrains[i], current.DeltaTime, individual_decay_base);
+ closestEndTime = Math.Min(closestEndTime, Math.Abs(endTime - endTimes[i]));
}
- holdEndTimes[column] = endTime;
-
// The hold addition is given if there was an overlap, however it is only valid if there are no other note with a similar ending.
// Releasing multiple notes is just as easy as releasing 1. Nerfs the hold addition by half if the closest release is release_threshold away.
// holdAddition
@@ -77,12 +74,22 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills
if (isOverlapping)
holdAddition = 1 / (1 + Math.Exp(0.5 * (release_threshold - closestEndTime)));
- // Increase individual strain in own column
+ // Decay and increase individualStrains in own column
+ individualStrains[column] = applyDecay(individualStrains[column], startTime - startTimes[column], individual_decay_base);
individualStrains[column] += 2.0 * holdFactor;
- individualStrain = individualStrains[column];
- overallStrain = applyDecay(overallStrain, current.DeltaTime, overall_decay_base) + (1 + holdAddition) * holdFactor;
+ // For notes at the same time (in a chord), the individualStrain should be the hardest individualStrain out of those columns
+ individualStrain = maniaCurrent.DeltaTime <= 1 ? Math.Max(individualStrain, individualStrains[column]) : individualStrains[column];
+ // Decay and increase overallStrain
+ overallStrain = applyDecay(overallStrain, current.DeltaTime, overall_decay_base);
+ overallStrain += (1 + holdAddition) * holdFactor;
+
+ // Update startTimes and endTimes arrays
+ startTimes[column] = startTime;
+ endTimes[column] = endTime;
+
+ // By subtracting CurrentStrain, this skill effectively only considers the maximum strain of any one hitobject within each strain section.
return individualStrain + overallStrain - CurrentStrain;
}
diff --git a/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs b/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs
index b602a67baa..81c41ab628 100644
--- a/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs
+++ b/osu.Game.Rulesets.Mania/Edit/Setup/ManiaSetupSection.cs
@@ -43,6 +43,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup
private void updateBeatmap()
{
Beatmap.BeatmapInfo.SpecialStyle = specialStyle.Current.Value;
+ Beatmap.SaveState();
}
}
}
diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs
index 614ef76a3b..73dfaaa878 100644
--- a/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs
+++ b/osu.Game.Rulesets.Mania/Mods/ManiaModConstantSpeed.cs
@@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.Mania.Mods
public override string Acronym => "CS";
- public override double ScoreMultiplier => 1;
+ public override double ScoreMultiplier => 0.9;
public override string Description => "No more tricky speed changes!";
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs
new file mode 100644
index 0000000000..b68231ce64
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectMerging.cs
@@ -0,0 +1,213 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Utils;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Types;
+using osu.Game.Rulesets.Osu.Objects;
+using osuTK;
+using osuTK.Input;
+
+namespace osu.Game.Rulesets.Osu.Tests.Editor
+{
+ public class TestSceneObjectMerging : TestSceneOsuEditor
+ {
+ [Test]
+ public void TestSimpleMerge()
+ {
+ HitCircle? circle1 = null;
+ HitCircle? circle2 = null;
+
+ AddStep("select first two circles", () =>
+ {
+ circle1 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle);
+ circle2 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle && h != circle1);
+ EditorClock.Seek(circle1.StartTime);
+ EditorBeatmap.SelectedHitObjects.Add(circle1);
+ EditorBeatmap.SelectedHitObjects.Add(circle2);
+ });
+
+ mergeSelection();
+
+ AddAssert("slider created", () => circle1 is not null && circle2 is not null && sliderCreatedFor(
+ (pos: circle1.Position, pathType: PathType.Linear),
+ (pos: circle2.Position, pathType: null)));
+
+ AddStep("undo", () => Editor.Undo());
+ AddAssert("merged objects restored", () => circle1 is not null && circle2 is not null && objectsRestored(circle1, circle2));
+ }
+
+ [Test]
+ public void TestMergeCircleSlider()
+ {
+ HitCircle? circle1 = null;
+ Slider? slider = null;
+ HitCircle? circle2 = null;
+
+ AddStep("select a circle, slider, circle", () =>
+ {
+ circle1 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle);
+ slider = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider && h.StartTime > circle1.StartTime);
+ circle2 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle && h.StartTime > slider.StartTime);
+ EditorClock.Seek(circle1.StartTime);
+ EditorBeatmap.SelectedHitObjects.Add(circle1);
+ EditorBeatmap.SelectedHitObjects.Add(slider);
+ EditorBeatmap.SelectedHitObjects.Add(circle2);
+ });
+
+ mergeSelection();
+
+ AddAssert("slider created", () =>
+ {
+ if (circle1 is null || circle2 is null || slider is null)
+ return false;
+
+ var controlPoints = slider.Path.ControlPoints;
+ (Vector2, PathType?)[] args = new (Vector2, PathType?)[controlPoints.Count + 2];
+ args[0] = (circle1.Position, PathType.Linear);
+
+ for (int i = 0; i < controlPoints.Count; i++)
+ {
+ args[i + 1] = (controlPoints[i].Position + slider.Position, i == controlPoints.Count - 1 ? PathType.Linear : controlPoints[i].Type);
+ }
+
+ args[^1] = (circle2.Position, null);
+ return sliderCreatedFor(args);
+ });
+
+ AddStep("undo", () => Editor.Undo());
+ AddAssert("merged objects restored", () => circle1 is not null && circle2 is not null && slider is not null && objectsRestored(circle1, slider, circle2));
+ }
+
+ [Test]
+ public void TestMergeSliderSlider()
+ {
+ Slider? slider1 = null;
+ SliderPath? slider1Path = null;
+ Slider? slider2 = null;
+
+ AddStep("select two sliders", () =>
+ {
+ slider1 = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider);
+ slider1Path = new SliderPath(slider1.Path.ControlPoints.Select(p => new PathControlPoint(p.Position, p.Type)).ToArray(), slider1.Path.ExpectedDistance.Value);
+ slider2 = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider && h.StartTime > slider1.StartTime);
+ EditorClock.Seek(slider1.StartTime);
+ EditorBeatmap.SelectedHitObjects.Add(slider1);
+ EditorBeatmap.SelectedHitObjects.Add(slider2);
+ });
+
+ mergeSelection();
+
+ AddAssert("slider created", () =>
+ {
+ if (slider1 is null || slider2 is null || slider1Path is null)
+ return false;
+
+ var controlPoints1 = slider1Path.ControlPoints;
+ var controlPoints2 = slider2.Path.ControlPoints;
+ (Vector2, PathType?)[] args = new (Vector2, PathType?)[controlPoints1.Count + controlPoints2.Count - 1];
+
+ for (int i = 0; i < controlPoints1.Count - 1; i++)
+ {
+ args[i] = (controlPoints1[i].Position + slider1.Position, controlPoints1[i].Type);
+ }
+
+ for (int i = 0; i < controlPoints2.Count; i++)
+ {
+ args[i + controlPoints1.Count - 1] = (controlPoints2[i].Position + controlPoints1[^1].Position + slider1.Position, controlPoints2[i].Type);
+ }
+
+ return sliderCreatedFor(args);
+ });
+
+ AddAssert("merged slider matches first slider", () =>
+ {
+ var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First();
+ return slider1 is not null && mergedSlider.HeadCircle.Samples.SequenceEqual(slider1.HeadCircle.Samples)
+ && mergedSlider.TailCircle.Samples.SequenceEqual(slider1.TailCircle.Samples)
+ && mergedSlider.Samples.SequenceEqual(slider1.Samples)
+ && mergedSlider.SampleControlPoint.IsRedundant(slider1.SampleControlPoint);
+ });
+
+ AddAssert("slider end is at same completion for last slider", () =>
+ {
+ if (slider1Path is null || slider2 is null)
+ return false;
+
+ var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First();
+ return Precision.AlmostEquals(mergedSlider.Path.Distance, slider1Path.CalculatedDistance + slider2.Path.Distance);
+ });
+ }
+
+ [Test]
+ public void TestNonMerge()
+ {
+ HitCircle? circle1 = null;
+ HitCircle? circle2 = null;
+ Spinner? spinner = null;
+
+ AddStep("select first two circles and spinner", () =>
+ {
+ circle1 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle);
+ circle2 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle && h != circle1);
+ spinner = (Spinner)EditorBeatmap.HitObjects.First(h => h is Spinner);
+ EditorClock.Seek(spinner.StartTime);
+ EditorBeatmap.SelectedHitObjects.Add(circle1);
+ EditorBeatmap.SelectedHitObjects.Add(circle2);
+ EditorBeatmap.SelectedHitObjects.Add(spinner);
+ });
+
+ mergeSelection();
+
+ AddAssert("slider created", () => circle1 is not null && circle2 is not null && sliderCreatedFor(
+ (pos: circle1.Position, pathType: PathType.Linear),
+ (pos: circle2.Position, pathType: null)));
+
+ AddAssert("spinner not merged", () => EditorBeatmap.HitObjects.Contains(spinner));
+ }
+
+ private void mergeSelection()
+ {
+ AddStep("merge selection", () =>
+ {
+ InputManager.PressKey(Key.LControl);
+ InputManager.PressKey(Key.LShift);
+ InputManager.Key(Key.M);
+ InputManager.ReleaseKey(Key.LShift);
+ InputManager.ReleaseKey(Key.LControl);
+ });
+ }
+
+ private bool sliderCreatedFor(params (Vector2 pos, PathType? pathType)[] expectedControlPoints)
+ {
+ if (EditorBeatmap.SelectedHitObjects.Count != 1)
+ return false;
+
+ var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First();
+ int i = 0;
+
+ foreach ((Vector2 pos, PathType? pathType) in expectedControlPoints)
+ {
+ var controlPoint = mergedSlider.Path.ControlPoints[i++];
+
+ if (!Precision.AlmostEquals(controlPoint.Position + mergedSlider.Position, pos) || controlPoint.Type != pathType)
+ return false;
+ }
+
+ return true;
+ }
+
+ private bool objectsRestored(params HitObject[] objects)
+ {
+ foreach (var hitObject in objects)
+ {
+ if (EditorBeatmap.HitObjects.Contains(hitObject))
+ return false;
+ }
+
+ return true;
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDoubleTime.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDoubleTime.cs
index 335ef31019..8df8afe147 100644
--- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDoubleTime.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModDoubleTime.cs
@@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
{
Mod = mod,
PassCondition = () => Player.ScoreProcessor.JudgedHits >= 2 &&
- Precision.AlmostEquals(Player.GameplayClockContainer.GameplayClock.Rate, mod.SpeedChange.Value)
+ Precision.AlmostEquals(Player.GameplayClockContainer.Rate, mod.SpeedChange.Value)
});
}
}
diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs
index d1796f2231..c01b2576e8 100644
--- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs
+++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs
@@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-#nullable disable
-
using System;
using System.Collections.Generic;
using System.Linq;
@@ -12,7 +10,6 @@ using osu.Framework.Audio;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Testing;
using osu.Framework.Timing;
-using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Replays;
using osu.Game.Rulesets.Objects;
@@ -36,16 +33,16 @@ namespace osu.Game.Rulesets.Osu.Tests
private const double spinner_duration = 6000;
[Resolved]
- private AudioManager audioManager { get; set; }
+ private AudioManager audioManager { get; set; } = null!;
protected override bool Autoplay => true;
protected override TestPlayer CreatePlayer(Ruleset ruleset) => new ScoreExposedPlayer();
- protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard storyboard = null)
+ protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null)
=> new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(new ManualClock { Rate = 1 }), audioManager);
- private DrawableSpinner drawableSpinner;
+ private DrawableSpinner drawableSpinner = null!;
private SpriteIcon spinnerSymbol => drawableSpinner.ChildrenOfType().Single();
[SetUpSteps]
@@ -67,12 +64,12 @@ namespace osu.Game.Rulesets.Osu.Tests
{
trackerRotationTolerance = Math.Abs(drawableSpinner.RotationTracker.Rotation * 0.1f);
});
- AddAssert("is disc rotation not almost 0", () => !Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, 0, 100));
- AddAssert("is disc rotation absolute not almost 0", () => !Precision.AlmostEquals(drawableSpinner.Result.RateAdjustedRotation, 0, 100));
+ AddAssert("is disc rotation not almost 0", () => drawableSpinner.RotationTracker.Rotation, () => Is.Not.EqualTo(0).Within(100));
+ AddAssert("is disc rotation absolute not almost 0", () => drawableSpinner.Result.RateAdjustedRotation, () => Is.Not.EqualTo(0).Within(100));
addSeekStep(0);
- AddAssert("is disc rotation almost 0", () => Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, 0, trackerRotationTolerance));
- AddAssert("is disc rotation absolute almost 0", () => Precision.AlmostEquals(drawableSpinner.Result.RateAdjustedRotation, 0, 100));
+ AddAssert("is disc rotation almost 0", () => drawableSpinner.RotationTracker.Rotation, () => Is.EqualTo(0).Within(trackerRotationTolerance));
+ AddAssert("is disc rotation absolute almost 0", () => drawableSpinner.Result.RateAdjustedRotation, () => Is.EqualTo(0).Within(100));
}
[Test]
@@ -100,20 +97,20 @@ namespace osu.Game.Rulesets.Osu.Tests
// we want to make sure that the rotation at time 2500 is in the same direction as at time 5000, but about half-way in.
// due to the exponential damping applied we're allowing a larger margin of error of about 10%
// (5% relative to the final rotation value, but we're half-way through the spin).
- () => Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, finalTrackerRotation / 2, trackerRotationTolerance));
+ () => drawableSpinner.RotationTracker.Rotation, () => Is.EqualTo(finalTrackerRotation / 2).Within(trackerRotationTolerance));
AddAssert("symbol rotation rewound",
- () => Precision.AlmostEquals(spinnerSymbol.Rotation, finalSpinnerSymbolRotation / 2, spinnerSymbolRotationTolerance));
+ () => spinnerSymbol.Rotation, () => Is.EqualTo(finalSpinnerSymbolRotation / 2).Within(spinnerSymbolRotationTolerance));
AddAssert("is cumulative rotation rewound",
// cumulative rotation is not damped, so we're treating it as the "ground truth" and allowing a comparatively smaller margin of error.
- () => Precision.AlmostEquals(drawableSpinner.Result.RateAdjustedRotation, finalCumulativeTrackerRotation / 2, 100));
+ () => drawableSpinner.Result.RateAdjustedRotation, () => Is.EqualTo(finalCumulativeTrackerRotation / 2).Within(100));
addSeekStep(spinner_start_time + 5000);
AddAssert("is disc rotation almost same",
- () => Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, finalTrackerRotation, trackerRotationTolerance));
+ () => drawableSpinner.RotationTracker.Rotation, () => Is.EqualTo(finalTrackerRotation).Within(trackerRotationTolerance));
AddAssert("is symbol rotation almost same",
- () => Precision.AlmostEquals(spinnerSymbol.Rotation, finalSpinnerSymbolRotation, spinnerSymbolRotationTolerance));
+ () => spinnerSymbol.Rotation, () => Is.EqualTo(finalSpinnerSymbolRotation).Within(spinnerSymbolRotationTolerance));
AddAssert("is cumulative rotation almost same",
- () => Precision.AlmostEquals(drawableSpinner.Result.RateAdjustedRotation, finalCumulativeTrackerRotation, 100));
+ () => drawableSpinner.Result.RateAdjustedRotation, () => Is.EqualTo(finalCumulativeTrackerRotation).Within(100));
}
[Test]
@@ -177,10 +174,10 @@ namespace osu.Game.Rulesets.Osu.Tests
AddStep("retrieve spm", () => estimatedSpm = drawableSpinner.SpinsPerMinute.Value);
addSeekStep(2000);
- AddAssert("spm still valid", () => Precision.AlmostEquals(drawableSpinner.SpinsPerMinute.Value, estimatedSpm, 1.0));
+ AddAssert("spm still valid", () => drawableSpinner.SpinsPerMinute.Value, () => Is.EqualTo(estimatedSpm).Within(1.0));
addSeekStep(1000);
- AddAssert("spm still valid", () => Precision.AlmostEquals(drawableSpinner.SpinsPerMinute.Value, estimatedSpm, 1.0));
+ AddAssert("spm still valid", () => drawableSpinner.SpinsPerMinute.Value, () => Is.EqualTo(estimatedSpm).Within(1.0));
}
[TestCase(0.5)]
@@ -202,14 +199,14 @@ namespace osu.Game.Rulesets.Osu.Tests
AddStep("adjust track rate", () => ((MasterGameplayClockContainer)Player.GameplayClockContainer).UserPlaybackRate.Value = rate);
addSeekStep(1000);
- AddAssert("progress almost same", () => Precision.AlmostEquals(expectedProgress, drawableSpinner.Progress, 0.05));
- AddAssert("spm almost same", () => Precision.AlmostEquals(expectedSpm, drawableSpinner.SpinsPerMinute.Value, 2.0));
+ AddAssert("progress almost same", () => drawableSpinner.Progress, () => Is.EqualTo(expectedProgress).Within(0.05));
+ AddAssert("spm almost same", () => drawableSpinner.SpinsPerMinute.Value, () => Is.EqualTo(expectedSpm).Within(2.0));
}
private void addSeekStep(double time)
{
AddStep($"seek to {time}", () => Player.GameplayClockContainer.Seek(time));
- AddUntilStep("wait for seek to finish", () => Precision.AlmostEquals(time, Player.DrawableRuleset.FrameStableClock.CurrentTime, 100));
+ AddUntilStep("wait for seek to finish", () => Player.DrawableRuleset.FrameStableClock.CurrentTime, () => Is.EqualTo(time).Within(100));
}
private void transformReplay(Func replayTransformation) => AddStep("set replay", () =>
diff --git a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj
index 4349d25cb3..36c40c0fe2 100644
--- a/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj
+++ b/osu.Game.Rulesets.Osu.Tests/osu.Game.Rulesets.Osu.Tests.csproj
@@ -2,7 +2,7 @@
-
+
diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs
index 3c82c2dc33..fb0eff5cb2 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs
@@ -270,8 +270,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
comboBasedMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo);
}
- // Clamp miss count since it's derived from combo and can be higher than total hits and that breaks some calculations
- comboBasedMissCount = Math.Min(comboBasedMissCount, totalHits);
+ // Clamp miss count to maximum amount of possible breaks
+ comboBasedMissCount = Math.Min(comboBasedMissCount, countOk + countMeh + countMiss);
return Math.Max(countMiss, comboBasedMissCount);
}
diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs
index bd11cc826f..794551dab7 100644
--- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs
+++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs
@@ -251,13 +251,13 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
private void convertToStream()
{
- if (editorBeatmap == null || changeHandler == null || beatDivisor == null)
+ if (editorBeatmap == null || beatDivisor == null)
return;
var timingPoint = editorBeatmap.ControlPointInfo.TimingPointAt(HitObject.StartTime);
double streamSpacing = timingPoint.BeatLength / beatDivisor.Value;
- changeHandler.BeginChange();
+ changeHandler?.BeginChange();
int i = 0;
double time = HitObject.StartTime;
@@ -292,7 +292,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
editorBeatmap.Remove(HitObject);
- changeHandler.EndChange();
+ changeHandler?.EndChange();
}
public override MenuItem[] ContextMenuItems => new MenuItem[]
diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs
index 4a6d94f303..f3c0a05bc2 100644
--- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs
+++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs
@@ -6,8 +6,11 @@ using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Primitives;
+using osu.Framework.Graphics.UserInterface;
+using osu.Framework.Input.Events;
using osu.Framework.Utils;
using osu.Game.Extensions;
+using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
@@ -15,6 +18,7 @@ using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Screens.Edit.Compose.Components;
using osuTK;
+using osuTK.Input;
namespace osu.Game.Rulesets.Osu.Edit
{
@@ -53,6 +57,17 @@ namespace osu.Game.Rulesets.Osu.Edit
referencePathTypes = null;
}
+ protected override bool OnKeyDown(KeyDownEvent e)
+ {
+ if (e.Key == Key.M && e.ControlPressed && e.ShiftPressed)
+ {
+ mergeSelection();
+ return true;
+ }
+
+ return false;
+ }
+
public override bool HandleMovement(MoveSelectionEvent moveEvent)
{
var hitObjects = selectedMovableObjects;
@@ -320,7 +335,109 @@ namespace osu.Game.Rulesets.Osu.Edit
/// All osu! hitobjects which can be moved/rotated/scaled.
///
private OsuHitObject[] selectedMovableObjects => SelectedItems.OfType()
- .Where(h => !(h is Spinner))
+ .Where(h => h is not Spinner)
.ToArray();
+
+ ///
+ /// All osu! hitobjects which can be merged.
+ ///
+ private OsuHitObject[] selectedMergeableObjects => SelectedItems.OfType()
+ .Where(h => h is HitCircle or Slider)
+ .OrderBy(h => h.StartTime)
+ .ToArray();
+
+ private void mergeSelection()
+ {
+ var mergeableObjects = selectedMergeableObjects;
+
+ if (mergeableObjects.Length < 2)
+ return;
+
+ ChangeHandler?.BeginChange();
+
+ // Have an initial slider object.
+ var firstHitObject = mergeableObjects[0];
+ var mergedHitObject = firstHitObject as Slider ?? new Slider
+ {
+ StartTime = firstHitObject.StartTime,
+ Position = firstHitObject.Position,
+ NewCombo = firstHitObject.NewCombo,
+ SampleControlPoint = firstHitObject.SampleControlPoint,
+ };
+
+ if (mergedHitObject.Path.ControlPoints.Count == 0)
+ {
+ mergedHitObject.Path.ControlPoints.Add(new PathControlPoint(Vector2.Zero, PathType.Linear));
+ }
+
+ // Merge all the selected hit objects into one slider path.
+ bool lastCircle = firstHitObject is HitCircle;
+
+ foreach (var selectedMergeableObject in mergeableObjects.Skip(1))
+ {
+ if (selectedMergeableObject is IHasPath hasPath)
+ {
+ var offset = lastCircle ? selectedMergeableObject.Position - mergedHitObject.Position : mergedHitObject.Path.ControlPoints[^1].Position;
+ float distanceToLastControlPoint = Vector2.Distance(mergedHitObject.Path.ControlPoints[^1].Position, offset);
+
+ // Calculate the distance required to travel to the expected distance of the merging slider.
+ mergedHitObject.Path.ExpectedDistance.Value = mergedHitObject.Path.CalculatedDistance + distanceToLastControlPoint + hasPath.Path.Distance;
+
+ // Remove the last control point if it sits exactly on the start of the next control point.
+ if (Precision.AlmostEquals(distanceToLastControlPoint, 0))
+ {
+ mergedHitObject.Path.ControlPoints.RemoveAt(mergedHitObject.Path.ControlPoints.Count - 1);
+ }
+
+ mergedHitObject.Path.ControlPoints.AddRange(hasPath.Path.ControlPoints.Select(o => new PathControlPoint(o.Position + offset, o.Type)));
+ lastCircle = false;
+ }
+ else
+ {
+ // Turn the last control point into a linear type if this is the first merging circle in a sequence, so the subsequent control points can be inherited path type.
+ if (!lastCircle)
+ {
+ mergedHitObject.Path.ControlPoints.Last().Type = PathType.Linear;
+ }
+
+ mergedHitObject.Path.ControlPoints.Add(new PathControlPoint(selectedMergeableObject.Position - mergedHitObject.Position));
+ mergedHitObject.Path.ExpectedDistance.Value = null;
+ lastCircle = true;
+ }
+ }
+
+ // Make sure only the merged hit object is in the beatmap.
+ if (firstHitObject is Slider)
+ {
+ foreach (var selectedMergeableObject in mergeableObjects.Skip(1))
+ {
+ EditorBeatmap.Remove(selectedMergeableObject);
+ }
+ }
+ else
+ {
+ foreach (var selectedMergeableObject in mergeableObjects)
+ {
+ EditorBeatmap.Remove(selectedMergeableObject);
+ }
+
+ EditorBeatmap.Add(mergedHitObject);
+ }
+
+ // Make sure the merged hitobject is selected.
+ SelectedItems.Clear();
+ SelectedItems.Add(mergedHitObject);
+
+ ChangeHandler?.EndChange();
+ }
+
+ protected override IEnumerable
WinExe
diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs
index 9196dde73c..32b7f0b29b 100644
--- a/osu.Game/Beatmaps/BeatmapInfo.cs
+++ b/osu.Game/Beatmaps/BeatmapInfo.cs
@@ -102,6 +102,14 @@ namespace osu.Game.Beatmaps
public string OnlineMD5Hash { get; set; } = string.Empty;
+ ///
+ /// The last time of a local modification (via the editor).
+ ///
+ public DateTimeOffset? LastLocalUpdate { get; set; }
+
+ ///
+ /// The last time online metadata was applied to this beatmap.
+ ///
public DateTimeOffset? LastOnlineUpdate { get; set; }
///
diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs
index e148016487..d736765dd9 100644
--- a/osu.Game/Beatmaps/BeatmapManager.cs
+++ b/osu.Game/Beatmaps/BeatmapManager.cs
@@ -94,6 +94,7 @@ namespace osu.Game.Beatmaps
var beatmapSet = new BeatmapSetInfo
{
+ DateAdded = DateTimeOffset.UtcNow,
Beatmaps =
{
new BeatmapInfo(ruleset, new BeatmapDifficulty(), metadata)
@@ -313,6 +314,7 @@ namespace osu.Game.Beatmaps
beatmapInfo.MD5Hash = stream.ComputeMD5Hash();
beatmapInfo.Hash = stream.ComputeSHA2Hash();
+ beatmapInfo.LastLocalUpdate = DateTimeOffset.Now;
beatmapInfo.Status = BeatmapOnlineStatus.LocallyModified;
AddFile(setInfo, stream, createBeatmapFilenameFromMetadata(beatmapInfo));
diff --git a/osu.Game/Beatmaps/IWorkingBeatmap.cs b/osu.Game/Beatmaps/IWorkingBeatmap.cs
index 548341cc77..a39766abe1 100644
--- a/osu.Game/Beatmaps/IWorkingBeatmap.cs
+++ b/osu.Game/Beatmaps/IWorkingBeatmap.cs
@@ -134,6 +134,6 @@ namespace osu.Game.Beatmaps
///
/// Reads the correct track restart point from beatmap metadata and sets looping to enabled.
///
- void PrepareTrackForPreviewLooping();
+ void PrepareTrackForPreview(bool looping);
}
}
diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs
index 16464932e0..301610ee58 100644
--- a/osu.Game/Beatmaps/WorkingBeatmap.cs
+++ b/osu.Game/Beatmaps/WorkingBeatmap.cs
@@ -110,9 +110,9 @@ namespace osu.Game.Beatmaps
public Track LoadTrack() => track = GetBeatmapTrack() ?? GetVirtualTrack(1000);
- public void PrepareTrackForPreviewLooping()
+ public void PrepareTrackForPreview(bool looping)
{
- Track.Looping = true;
+ Track.Looping = looping;
Track.RestartPoint = Metadata.PreviewTime;
if (Track.RestartPoint == -1)
diff --git a/osu.Game/Database/LegacyCollectionImporter.cs b/osu.Game/Database/LegacyCollectionImporter.cs
index 4bb28bf731..6d3e3fb76a 100644
--- a/osu.Game/Database/LegacyCollectionImporter.cs
+++ b/osu.Game/Database/LegacyCollectionImporter.cs
@@ -89,7 +89,7 @@ namespace osu.Game.Database
if (existing != null)
{
- foreach (string newBeatmap in existing.BeatmapMD5Hashes)
+ foreach (string newBeatmap in collection.BeatmapMD5Hashes)
{
if (!existing.BeatmapMD5Hashes.Contains(newBeatmap))
existing.BeatmapMD5Hashes.Add(newBeatmap);
diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs
index 8099db44b1..0f2e724567 100644
--- a/osu.Game/Database/RealmAccess.cs
+++ b/osu.Game/Database/RealmAccess.cs
@@ -68,8 +68,9 @@ namespace osu.Game.Database
/// 20 2022-07-21 Added LastAppliedDifficultyVersion to RulesetInfo, changed default value of BeatmapInfo.StarRating to -1.
/// 21 2022-07-27 Migrate collections to realm (BeatmapCollection).
/// 22 2022-07-31 Added ModPreset.
+ /// 23 2022-08-01 Added LastLocalUpdate to BeatmapInfo.
///
- private const int schema_version = 22;
+ private const int schema_version = 23;
///
/// Lock object which is held during sections, blocking realm retrieval during blocking periods.
@@ -900,8 +901,15 @@ namespace osu.Game.Database
try
{
using (var source = storage.GetStream(Filename, mode: FileMode.Open))
- using (var destination = storage.GetStream(backupFilename, FileAccess.Write, FileMode.CreateNew))
- source.CopyTo(destination);
+ {
+ // source may not exist.
+ if (source == null)
+ return;
+
+ using (var destination = storage.GetStream(backupFilename, FileAccess.Write, FileMode.CreateNew))
+ source.CopyTo(destination);
+ }
+
return;
}
catch (IOException)
diff --git a/osu.Game/Online/PollingComponent.cs b/osu.Game/Online/PollingComponent.cs
index d54b8ca75d..fcea650e2d 100644
--- a/osu.Game/Online/PollingComponent.cs
+++ b/osu.Game/Online/PollingComponent.cs
@@ -16,7 +16,7 @@ namespace osu.Game.Online
///
/// A component which requires a constant polling process.
///
- public abstract class PollingComponent : CompositeDrawable // switch away from Component because InternalChildren are used in usages.
+ public abstract class PollingComponent : CompositeComponent
{
private double? lastTimePolled;
diff --git a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs
index 2fd8445980..bb8ec4f6ff 100644
--- a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs
+++ b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs
@@ -27,13 +27,10 @@ namespace osu.Game.Online.Rooms
/// 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 OnlinePlayBeatmapAvailabilityTracker : CompositeDrawable
+ public class OnlinePlayBeatmapAvailabilityTracker : CompositeComponent
{
public readonly IBindable SelectedItem = new Bindable();
- // Required to allow child components to update. Can potentially be replaced with a `CompositeComponent` class if or when we make one.
- protected override bool RequiresChildrenUpdate => true;
-
[Resolved]
private RealmAccess realm { get; set; } = null!;
diff --git a/osu.Game/Overlays/Comments/CommentMarkdownContainer.cs b/osu.Game/Overlays/Comments/CommentMarkdownContainer.cs
index e94a1b0147..8fc011b2bf 100644
--- a/osu.Game/Overlays/Comments/CommentMarkdownContainer.cs
+++ b/osu.Game/Overlays/Comments/CommentMarkdownContainer.cs
@@ -4,7 +4,6 @@
#nullable disable
using Markdig.Syntax;
-using Markdig.Syntax.Inlines;
using osu.Framework.Graphics.Containers.Markdown;
using osu.Game.Graphics.Containers.Markdown;
@@ -12,16 +11,8 @@ namespace osu.Game.Overlays.Comments
{
public class CommentMarkdownContainer : OsuMarkdownContainer
{
- public override MarkdownTextFlowContainer CreateTextFlow() => new CommentMarkdownTextFlowContainer();
-
protected override MarkdownHeading CreateHeading(HeadingBlock headingBlock) => new CommentMarkdownHeading(headingBlock);
- private class CommentMarkdownTextFlowContainer : OsuMarkdownTextFlowContainer
- {
- // Don't render image in comment for now
- protected override void AddImage(LinkInline linkInline) { }
- }
-
private class CommentMarkdownHeading : OsuMarkdownHeading
{
public CommentMarkdownHeading(HeadingBlock headingBlock)
diff --git a/osu.Game/Overlays/Music/Playlist.cs b/osu.Game/Overlays/Music/Playlist.cs
index 2bb0ff1085..15fc54a337 100644
--- a/osu.Game/Overlays/Music/Playlist.cs
+++ b/osu.Game/Overlays/Music/Playlist.cs
@@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-#nullable disable
-
using System;
using System.Linq;
using osu.Framework.Bindables;
@@ -17,10 +15,12 @@ namespace osu.Game.Overlays.Music
{
public class Playlist : OsuRearrangeableListContainer>
{
- public Action> RequestSelection;
+ public Action>? RequestSelection;
public readonly Bindable> SelectedSet = new Bindable>();
+ private FilterCriteria currentCriteria = new FilterCriteria();
+
public new MarginPadding Padding
{
get => base.Padding;
@@ -31,26 +31,22 @@ namespace osu.Game.Overlays.Music
{
var items = (SearchContainer>>)ListContainer;
- string[] currentCollectionHashes = criteria.Collection?.PerformRead(c => c.BeatmapMD5Hashes.ToArray());
+ string[]? currentCollectionHashes = criteria.Collection?.PerformRead(c => c.BeatmapMD5Hashes.ToArray());
foreach (var item in items.OfType())
{
- if (currentCollectionHashes == null)
- item.InSelectedCollection = true;
- else
- {
- item.InSelectedCollection = item.Model.Value.Beatmaps.Select(b => b.MD5Hash)
- .Any(currentCollectionHashes.Contains);
- }
+ item.InSelectedCollection = currentCollectionHashes == null || item.Model.Value.Beatmaps.Select(b => b.MD5Hash).Any(currentCollectionHashes.Contains);
}
items.SearchTerm = criteria.SearchText;
+ currentCriteria = criteria;
}
- public Live FirstVisibleSet => Items.FirstOrDefault(i => ((PlaylistItem)ItemMap[i]).MatchingFilter);
+ public Live? FirstVisibleSet => Items.FirstOrDefault(i => ((PlaylistItem)ItemMap[i]).MatchingFilter);
protected override OsuRearrangeableListItem> CreateOsuDrawable(Live item) => new PlaylistItem(item)
{
+ InSelectedCollection = currentCriteria.Collection?.PerformRead(c => item.Value.Beatmaps.Select(b => b.MD5Hash).Any(c.BeatmapMD5Hashes.Contains)) != false,
SelectedSet = { BindTarget = SelectedSet },
RequestSelection = set => RequestSelection?.Invoke(set)
};
diff --git a/osu.Game/Overlays/Music/PlaylistOverlay.cs b/osu.Game/Overlays/Music/PlaylistOverlay.cs
index e33fc8064f..9fe2fd5279 100644
--- a/osu.Game/Overlays/Music/PlaylistOverlay.cs
+++ b/osu.Game/Overlays/Music/PlaylistOverlay.cs
@@ -26,8 +26,6 @@ namespace osu.Game.Overlays.Music
private const float transition_duration = 600;
private const float playlist_height = 510;
- public IBindableList> BeatmapSets => beatmapSets;
-
private readonly BindableList> beatmapSets = new BindableList>();
private readonly Bindable beatmap = new Bindable();
@@ -104,9 +102,7 @@ namespace osu.Game.Overlays.Music
{
base.LoadComplete();
- // tests might bind externally, in which case we don't want to involve realm.
- if (beatmapSets.Count == 0)
- beatmapSubscription = realm.RegisterForNotifications(r => r.All().Where(s => !s.DeletePending), beatmapsChanged);
+ beatmapSubscription = realm.RegisterForNotifications(r => r.All().Where(s => !s.DeletePending), beatmapsChanged);
list.Items.BindTo(beatmapSets);
beatmap.BindValueChanged(working => list.SelectedSet.Value = working.NewValue.BeatmapSetInfo.ToLive(realm), true);
diff --git a/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs b/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs
index 38d26ed05a..7b84db844b 100644
--- a/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs
+++ b/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs
@@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Mods
public override ModType Type => ModType.Fun;
- public override double ScoreMultiplier => 1;
+ public override double ScoreMultiplier => 0.5;
public override bool ValidForMultiplayer => false;
public override bool ValidForMultiplayerAsFreeMod => false;
diff --git a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs
index eefa1531c4..b7435ec3ec 100644
--- a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs
+++ b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs
@@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Mods
public override IconUsage? Icon => FontAwesome.Solid.Hammer;
- public override double ScoreMultiplier => 1.0;
+ public override double ScoreMultiplier => 0.5;
public override bool RequiresConfiguration => true;
diff --git a/osu.Game/Rulesets/Mods/ModRelax.cs b/osu.Game/Rulesets/Mods/ModRelax.cs
index e5995ff180..49c10339ee 100644
--- a/osu.Game/Rulesets/Mods/ModRelax.cs
+++ b/osu.Game/Rulesets/Mods/ModRelax.cs
@@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Mods
public override string Acronym => "RX";
public override IconUsage? Icon => OsuIcon.ModRelax;
public override ModType Type => ModType.Automation;
- public override double ScoreMultiplier => 1;
+ public override double ScoreMultiplier => 0.1;
public override Type[] IncompatibleMods => new[] { typeof(ModAutoplay), typeof(ModNoFail), typeof(ModFailCondition) };
}
}
diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs
index f7f62d2af0..59c1146995 100644
--- a/osu.Game/Rulesets/UI/DrawableRuleset.cs
+++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs
@@ -67,7 +67,7 @@ namespace osu.Game.Rulesets.UI
public override Container FrameStableComponents { get; } = new Container { RelativeSizeAxes = Axes.Both };
- public override IFrameStableClock FrameStableClock => frameStabilityContainer.FrameStableClock;
+ public override IFrameStableClock FrameStableClock => frameStabilityContainer;
private bool frameStablePlayback = true;
diff --git a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs
index dcd7141419..e41802808f 100644
--- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs
+++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs
@@ -1,16 +1,16 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-#nullable disable
-
using System;
using System.Collections.Generic;
+using System.Diagnostics;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Timing;
+using osu.Framework.Utils;
using osu.Game.Input.Handlers;
using osu.Game.Screens.Play;
@@ -20,9 +20,11 @@ namespace osu.Game.Rulesets.UI
/// A container which consumes a parent gameplay clock and standardises frame counts for children.
/// Will ensure a minimum of 50 frames per clock second is maintained, regardless of any system lag or seeks.
///
- public class FrameStabilityContainer : Container, IHasReplayHandler
+ [Cached(typeof(IGameplayClock))]
+ [Cached(typeof(IFrameStableClock))]
+ public sealed class FrameStabilityContainer : Container, IHasReplayHandler, IFrameStableClock, IGameplayClock
{
- private readonly double gameplayStartTime;
+ public ReplayInputHandler? ReplayInputHandler { get; set; }
///
/// The number of frames (per parent frame) which can be run in an attempt to catch-up to real-time.
@@ -32,28 +34,35 @@ namespace osu.Game.Rulesets.UI
///
/// Whether to enable frame-stable playback.
///
- internal bool FrameStablePlayback = true;
+ internal bool FrameStablePlayback { get; set; } = true;
- public IFrameStableClock FrameStableClock => frameStableClock;
+ protected override bool RequiresChildrenUpdate => base.RequiresChildrenUpdate && state != PlaybackState.NotValid;
- [Cached(typeof(GameplayClock))]
- private readonly FrameStabilityClock frameStableClock;
+ private readonly Bindable isCatchingUp = new Bindable();
- public FrameStabilityContainer(double gameplayStartTime = double.MinValue)
- {
- RelativeSizeAxes = Axes.Both;
+ private readonly Bindable waitingOnFrames = new Bindable();
- frameStableClock = new FrameStabilityClock(framedClock = new FramedClock(manualClock = new ManualClock()));
+ private readonly double gameplayStartTime;
- this.gameplayStartTime = gameplayStartTime;
- }
+ private IGameplayClock? parentGameplayClock;
+ ///
+ /// A clock which is used as reference for time, rate and running state.
+ ///
+ private IClock referenceClock = null!;
+
+ ///
+ /// A local manual clock which tracks the reference clock.
+ /// Values are transferred from each update call.
+ ///
private readonly ManualClock manualClock;
+ ///
+ /// The main framed clock which has stability applied to it.
+ /// This gets exposed to children as an .
+ ///
private readonly FramedClock framedClock;
- private IFrameBasedClock parentGameplayClock;
-
///
/// The current direction of playback to be exposed to frame stable children.
///
@@ -62,32 +71,34 @@ namespace osu.Game.Rulesets.UI
///
private int direction = 1;
- [BackgroundDependencyLoader(true)]
- private void load(GameplayClock clock)
- {
- if (clock != null)
- {
- parentGameplayClock = frameStableClock.ParentGameplayClock = clock;
- frameStableClock.IsPaused.BindTo(clock.IsPaused);
- }
- }
-
- protected override void LoadComplete()
- {
- base.LoadComplete();
- setClock();
- }
-
private PlaybackState state;
- protected override bool RequiresChildrenUpdate => base.RequiresChildrenUpdate && state != PlaybackState.NotValid;
-
private bool hasReplayAttached => ReplayInputHandler != null;
- private const double sixty_frame_time = 1000.0 / 60;
-
private bool firstConsumption = true;
+ public FrameStabilityContainer(double gameplayStartTime = double.MinValue)
+ {
+ RelativeSizeAxes = Axes.Both;
+
+ framedClock = new FramedClock(manualClock = new ManualClock());
+
+ this.gameplayStartTime = gameplayStartTime;
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(IGameplayClock? gameplayClock)
+ {
+ if (gameplayClock != null)
+ {
+ parentGameplayClock = gameplayClock;
+ IsPaused.BindTo(parentGameplayClock.IsPaused);
+ }
+
+ referenceClock = gameplayClock ?? Clock;
+ Clock = this;
+ }
+
public override bool UpdateSubTree()
{
int loops = MaxCatchUpFrames;
@@ -110,12 +121,12 @@ namespace osu.Game.Rulesets.UI
private void updateClock()
{
- if (frameStableClock.WaitingOnFrames.Value)
+ if (waitingOnFrames.Value)
{
// if waiting on frames, run one update loop to determine if frames have arrived.
state = PlaybackState.Valid;
}
- else if (frameStableClock.IsPaused.Value)
+ else if (IsPaused.Value)
{
// time should not advance while paused, nor should anything run.
state = PlaybackState.NotValid;
@@ -126,10 +137,7 @@ namespace osu.Game.Rulesets.UI
state = PlaybackState.Valid;
}
- if (parentGameplayClock == null)
- setClock(); // LoadComplete may not be run yet, but we still want the clock.
-
- double proposedTime = parentGameplayClock.CurrentTime;
+ double proposedTime = referenceClock.CurrentTime;
if (FrameStablePlayback)
// if we require frame stability, the proposed time will be adjusted to move at most one known
@@ -149,14 +157,14 @@ namespace osu.Game.Rulesets.UI
if (state == PlaybackState.Valid && proposedTime != manualClock.CurrentTime)
direction = proposedTime >= manualClock.CurrentTime ? 1 : -1;
- double timeBehind = Math.Abs(proposedTime - parentGameplayClock.CurrentTime);
+ double timeBehind = Math.Abs(proposedTime - referenceClock.CurrentTime);
- frameStableClock.IsCatchingUp.Value = timeBehind > 200;
- frameStableClock.WaitingOnFrames.Value = state == PlaybackState.NotValid;
+ isCatchingUp.Value = timeBehind > 200;
+ waitingOnFrames.Value = state == PlaybackState.NotValid;
manualClock.CurrentTime = proposedTime;
- manualClock.Rate = Math.Abs(parentGameplayClock.Rate) * direction;
- manualClock.IsRunning = parentGameplayClock.IsRunning;
+ manualClock.Rate = Math.Abs(referenceClock.Rate) * direction;
+ manualClock.IsRunning = referenceClock.IsRunning;
// determine whether catch-up is required.
if (state == PlaybackState.Valid && timeBehind > 0)
@@ -174,6 +182,8 @@ namespace osu.Game.Rulesets.UI
/// Whether playback is still valid.
private bool updateReplay(ref double proposedTime)
{
+ Debug.Assert(ReplayInputHandler != null);
+
double? newTime;
if (FrameStablePlayback)
@@ -210,6 +220,8 @@ namespace osu.Game.Rulesets.UI
/// The time which is to be displayed.
private void applyFrameStability(ref double proposedTime)
{
+ const double sixty_frame_time = 1000.0 / 60;
+
if (firstConsumption)
{
// On the first update, frame-stability seeking would result in unexpected/unwanted behaviour.
@@ -233,20 +245,54 @@ namespace osu.Game.Rulesets.UI
}
}
- private void setClock()
+ #region Delegation of IGameplayClock
+
+ public IBindable IsPaused { get; } = new BindableBool();
+
+ public double CurrentTime => framedClock.CurrentTime;
+
+ public double Rate => framedClock.Rate;
+
+ public bool IsRunning => framedClock.IsRunning;
+
+ public void ProcessFrame() { }
+
+ public double ElapsedFrameTime => framedClock.ElapsedFrameTime;
+
+ public double FramesPerSecond => framedClock.FramesPerSecond;
+
+ public FrameTimeInfo TimeInfo => framedClock.TimeInfo;
+
+ public double TrueGameplayRate
{
- if (parentGameplayClock == null)
+ get
{
- // in case a parent gameplay clock isn't available, just use the parent clock.
- parentGameplayClock ??= Clock;
- }
- else
- {
- Clock = frameStableClock;
+ double baseRate = Rate;
+
+ foreach (double adjustment in NonGameplayAdjustments)
+ {
+ if (Precision.AlmostEquals(adjustment, 0))
+ return 0;
+
+ baseRate /= adjustment;
+ }
+
+ return baseRate;
}
}
- public ReplayInputHandler ReplayInputHandler { get; set; }
+ public double? StartTime => parentGameplayClock?.StartTime;
+
+ public IEnumerable NonGameplayAdjustments => parentGameplayClock?.NonGameplayAdjustments ?? Enumerable.Empty();
+
+ #endregion
+
+ #region Delegation of IFrameStableClock
+
+ IBindable IFrameStableClock.IsCatchingUp => isCatchingUp;
+ IBindable IFrameStableClock.WaitingOnFrames => waitingOnFrames;
+
+ #endregion
private enum PlaybackState
{
@@ -266,25 +312,5 @@ namespace osu.Game.Rulesets.UI
///
Valid
}
-
- private class FrameStabilityClock : GameplayClock, IFrameStableClock
- {
- public GameplayClock ParentGameplayClock;
-
- public readonly Bindable IsCatchingUp = new Bindable();
-
- public readonly Bindable WaitingOnFrames = new Bindable();
-
- public override IEnumerable> NonGameplayAdjustments => ParentGameplayClock?.NonGameplayAdjustments ?? Enumerable.Empty>();
-
- public FrameStabilityClock(FramedClock underlyingClock)
- : base(underlyingClock)
- {
- }
-
- IBindable IFrameStableClock.IsCatchingUp => IsCatchingUp;
-
- IBindable IFrameStableClock.WaitingOnFrames => WaitingOnFrames;
- }
}
}
diff --git a/osu.Game/Rulesets/UI/IFrameStableClock.cs b/osu.Game/Rulesets/UI/IFrameStableClock.cs
index 132605adaf..569ef5e06c 100644
--- a/osu.Game/Rulesets/UI/IFrameStableClock.cs
+++ b/osu.Game/Rulesets/UI/IFrameStableClock.cs
@@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-#nullable disable
-
using osu.Framework.Bindables;
using osu.Framework.Timing;
diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs
index 89f9aec5ee..a7cbe1f1ad 100644
--- a/osu.Game/Screens/Edit/Editor.cs
+++ b/osu.Game/Screens/Edit/Editor.cs
@@ -24,6 +24,7 @@ using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Screens;
using osu.Framework.Testing;
+using osu.Framework.Threading;
using osu.Framework.Timing;
using osu.Game.Audio;
using osu.Game.Beatmaps;
@@ -233,6 +234,8 @@ namespace osu.Game.Screens.Edit
AddInternal(editorBeatmap = new EditorBeatmap(playableBeatmap, loadableBeatmap.GetSkin(), loadableBeatmap.BeatmapInfo));
dependencies.CacheAs(editorBeatmap);
+ editorBeatmap.UpdateInProgress.BindValueChanged(updateInProgress);
+
canSave = editorBeatmap.BeatmapInfo.Ruleset.CreateInstance() is ILegacyRuleset;
if (canSave)
@@ -714,6 +717,27 @@ namespace osu.Game.Screens.Edit
this.Exit();
}
+ #region Mute from update application
+
+ private ScheduledDelegate temporaryMuteRestorationDelegate;
+ private bool temporaryMuteFromUpdateInProgress;
+
+ private void updateInProgress(ValueChangedEvent obj)
+ {
+ temporaryMuteFromUpdateInProgress = true;
+ updateSampleDisabledState();
+
+ // Debounce is arbitrarily high enough to avoid flip-flopping the value each other frame.
+ temporaryMuteRestorationDelegate?.Cancel();
+ temporaryMuteRestorationDelegate = Scheduler.AddDelayed(() =>
+ {
+ temporaryMuteFromUpdateInProgress = false;
+ updateSampleDisabledState();
+ }, 50);
+ }
+
+ #endregion
+
#region Clipboard support
private EditorMenuItem cutMenuItem;
@@ -829,7 +853,9 @@ namespace osu.Game.Screens.Edit
private void updateSampleDisabledState()
{
- samplePlaybackDisabled.Value = clock.SeekingOrStopped.Value || !(currentScreen is ComposeScreen);
+ samplePlaybackDisabled.Value = clock.SeekingOrStopped.Value
+ || currentScreen is not ComposeScreen
+ || temporaryMuteFromUpdateInProgress;
}
private void seek(UIEvent e, int direction)
diff --git a/osu.Game/Screens/Edit/EditorBeatmap.cs b/osu.Game/Screens/Edit/EditorBeatmap.cs
index 96425e8bc8..8aa754b305 100644
--- a/osu.Game/Screens/Edit/EditorBeatmap.cs
+++ b/osu.Game/Screens/Edit/EditorBeatmap.cs
@@ -22,6 +22,17 @@ namespace osu.Game.Screens.Edit
{
public class EditorBeatmap : TransactionalCommitComponent, IBeatmap, IBeatSnapProvider
{
+ ///
+ /// Will become true when a new update is queued, and false when all updates have been applied.
+ ///
+ ///
+ /// This is intended to be used to avoid performing operations (like playback of samples)
+ /// while mutating hitobjects.
+ ///
+ public IBindable UpdateInProgress => updateInProgress;
+
+ private readonly BindableBool updateInProgress = new BindableBool();
+
///
/// Invoked when a is added to this .
///
@@ -78,7 +89,10 @@ namespace osu.Game.Screens.Edit
this.beatmapInfo = beatmapInfo ?? playableBeatmap.BeatmapInfo;
if (beatmapSkin is Skin skin)
+ {
BeatmapSkin = new EditorBeatmapSkin(skin);
+ BeatmapSkin.BeatmapSkinChanged += SaveState;
+ }
beatmapProcessor = playableBeatmap.BeatmapInfo.Ruleset.CreateInstance().CreateBeatmapProcessor(PlayableBeatmap);
@@ -225,6 +239,8 @@ namespace osu.Game.Screens.Edit
{
// updates are debounced regardless of whether a batch is active.
batchPendingUpdates.Add(hitObject);
+
+ updateInProgress.Value = true;
}
///
@@ -234,6 +250,8 @@ namespace osu.Game.Screens.Edit
{
foreach (var h in HitObjects)
batchPendingUpdates.Add(h);
+
+ updateInProgress.Value = true;
}
///
@@ -326,6 +344,8 @@ namespace osu.Game.Screens.Edit
foreach (var h in deletes) HitObjectRemoved?.Invoke(h);
foreach (var h in inserts) HitObjectAdded?.Invoke(h);
foreach (var h in updates) HitObjectUpdated?.Invoke(h);
+
+ updateInProgress.Value = false;
}
///
diff --git a/osu.Game/Screens/Edit/Setup/DesignSection.cs b/osu.Game/Screens/Edit/Setup/DesignSection.cs
index 40bbfeaf7d..5cec730440 100644
--- a/osu.Game/Screens/Edit/Setup/DesignSection.cs
+++ b/osu.Game/Screens/Edit/Setup/DesignSection.cs
@@ -126,6 +126,8 @@ namespace osu.Game.Screens.Edit.Setup
Beatmap.BeatmapInfo.EpilepsyWarning = epilepsyWarning.Current.Value;
Beatmap.BeatmapInfo.LetterboxInBreaks = letterboxDuringBreaks.Current.Value;
Beatmap.BeatmapInfo.SamplesMatchPlaybackRate = samplesMatchPlaybackRate.Current.Value;
+
+ Beatmap.SaveState();
}
}
}
diff --git a/osu.Game/Screens/Edit/Setup/DifficultySection.cs b/osu.Game/Screens/Edit/Setup/DifficultySection.cs
index 5ce5d05d64..ce44445683 100644
--- a/osu.Game/Screens/Edit/Setup/DifficultySection.cs
+++ b/osu.Game/Screens/Edit/Setup/DifficultySection.cs
@@ -96,6 +96,7 @@ namespace osu.Game.Screens.Edit.Setup
Beatmap.Difficulty.OverallDifficulty = overallDifficultySlider.Current.Value;
Beatmap.UpdateAllHitObjects();
+ Beatmap.SaveState();
}
}
}
diff --git a/osu.Game/Screens/Edit/Setup/MetadataSection.cs b/osu.Game/Screens/Edit/Setup/MetadataSection.cs
index 854dec2001..928e5bc3b6 100644
--- a/osu.Game/Screens/Edit/Setup/MetadataSection.cs
+++ b/osu.Game/Screens/Edit/Setup/MetadataSection.cs
@@ -87,7 +87,7 @@ namespace osu.Game.Screens.Edit.Setup
target.Current.Value = value;
updateReadOnlyState();
- updateMetadata();
+ Scheduler.AddOnce(updateMetadata);
}
private void updateReadOnlyState()
@@ -102,7 +102,7 @@ namespace osu.Game.Screens.Edit.Setup
// for now, update on commit rather than making BeatmapMetadata bindables.
// after switching database engines we can reconsider if switching to bindables is a good direction.
- updateMetadata();
+ Scheduler.AddOnce(updateMetadata);
}
private void updateMetadata()
@@ -117,6 +117,8 @@ namespace osu.Game.Screens.Edit.Setup
Beatmap.BeatmapInfo.DifficultyName = difficultyTextBox.Current.Value;
Beatmap.Metadata.Source = sourceTextBox.Current.Value;
Beatmap.Metadata.Tags = tagsTextBox.Current.Value;
+
+ Beatmap.SaveState();
}
}
}
diff --git a/osu.Game/Screens/Menu/IntroCircles.cs b/osu.Game/Screens/Menu/IntroCircles.cs
index faa9a267ce..7a4bdb231f 100644
--- a/osu.Game/Screens/Menu/IntroCircles.cs
+++ b/osu.Game/Screens/Menu/IntroCircles.cs
@@ -19,8 +19,9 @@ namespace osu.Game.Screens.Menu
protected override string BeatmapFile => "circles.osz";
- private const double delay_step_one = 2300;
- private const double delay_step_two = 600;
+ public const double TRACK_START_DELAY = 600;
+
+ private const double delay_for_menu = 2900;
private Sample welcome;
@@ -50,8 +51,8 @@ namespace osu.Game.Screens.Menu
PrepareMenuLoad();
- Scheduler.AddDelayed(LoadMenu, delay_step_one);
- }, delay_step_two);
+ Scheduler.AddDelayed(LoadMenu, delay_for_menu - TRACK_START_DELAY);
+ }, TRACK_START_DELAY);
logo.ScaleTo(1);
logo.FadeIn();
diff --git a/osu.Game/Screens/Menu/IntroScreen.cs b/osu.Game/Screens/Menu/IntroScreen.cs
index a2ecd7eacb..409c7d6c8d 100644
--- a/osu.Game/Screens/Menu/IntroScreen.cs
+++ b/osu.Game/Screens/Menu/IntroScreen.cs
@@ -272,11 +272,22 @@ namespace osu.Game.Screens.Menu
FadeInBackground(200);
}
- protected virtual void StartTrack()
+ protected void StartTrack()
{
- // Only start the current track if it is the menu music. A beatmap's track is started when entering the Main Menu.
- if (UsingThemedIntro)
- Track.Start();
+ var drawableTrack = musicController.CurrentTrack;
+
+ if (!UsingThemedIntro)
+ {
+ initialBeatmap?.PrepareTrackForPreview(false);
+
+ drawableTrack.VolumeTo(0);
+ drawableTrack.Restart();
+ drawableTrack.VolumeTo(1, 2200, Easing.InCubic);
+ }
+ else
+ {
+ drawableTrack.Restart();
+ }
}
protected override void LogoArriving(OsuLogo logo, bool resuming)
diff --git a/osu.Game/Screens/Menu/IntroTriangles.cs b/osu.Game/Screens/Menu/IntroTriangles.cs
index 6ad0350e43..d777f78df2 100644
--- a/osu.Game/Screens/Menu/IntroTriangles.cs
+++ b/osu.Game/Screens/Menu/IntroTriangles.cs
@@ -84,9 +84,17 @@ namespace osu.Game.Screens.Menu
return;
if (!UsingThemedIntro)
+ {
+ // If the user has requested no theme, fallback to the same intro voice and delay as IntroCircles.
+ // The triangles intro voice and theme are combined which makes it impossible to use.
welcome?.Play();
+ Scheduler.AddDelayed(StartTrack, IntroCircles.TRACK_START_DELAY);
+ }
+ else
+ StartTrack();
- StartTrack();
+ // no-op for the case of themed intro, no harm in calling for both scenarios as a safety measure.
+ decoupledClock.Start();
});
}
}
@@ -99,11 +107,6 @@ namespace osu.Game.Screens.Menu
intro.Expire();
}
- protected override void StartTrack()
- {
- decoupledClock.Start();
- }
-
private class TrianglesIntroSequence : CompositeDrawable
{
private readonly OsuLogo logo;
diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs
index 066a37055c..0071ada05a 100644
--- a/osu.Game/Screens/Menu/MainMenu.cs
+++ b/osu.Game/Screens/Menu/MainMenu.cs
@@ -192,7 +192,7 @@ namespace osu.Game.Screens.Menu
// presume the track is the current beatmap's track. not sure how correct this assumption is but it has worked until now.
if (!track.IsRunning)
{
- Beatmap.Value.PrepareTrackForPreviewLooping();
+ Beatmap.Value.PrepareTrackForPreview(false);
track.Restart();
}
}
diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs
index 25f2a94a3c..03216180fb 100644
--- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs
+++ b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs
@@ -485,7 +485,7 @@ namespace osu.Game.Screens.OnlinePlay.Match
if (track != null)
{
- Beatmap.Value.PrepareTrackForPreviewLooping();
+ Beatmap.Value.PrepareTrackForPreview(true);
music?.EnsurePlayingSomething();
}
}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs
index a0558f97a9..68eae76030 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs
@@ -1,12 +1,8 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-#nullable disable
-
-using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
-using osu.Framework.Timing;
using osu.Game.Beatmaps;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
@@ -26,7 +22,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
///
/// The score containing the player's replay.
/// The clock controlling the gameplay running state.
- public MultiSpectatorPlayer([NotNull] Score score, [NotNull] ISpectatorPlayerClock spectatorPlayerClock)
+ public MultiSpectatorPlayer(Score score, ISpectatorPlayerClock spectatorPlayerClock)
: base(score, new PlayerConfiguration { AllowUserInteraction = false })
{
this.spectatorPlayerClock = spectatorPlayerClock;
@@ -41,6 +37,19 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
HUDOverlay.HoldToQuit.Expire();
}
+ protected override void Update()
+ {
+ // The player clock's running state is controlled externally, but the local pausing state needs to be updated to start/stop gameplay.
+ CatchUpSpectatorPlayerClock catchUpClock = (CatchUpSpectatorPlayerClock)GameplayClockContainer.SourceClock;
+
+ if (catchUpClock.IsRunning)
+ GameplayClockContainer.Start();
+ else
+ GameplayClockContainer.Stop();
+
+ base.Update();
+ }
+
protected override void UpdateAfterChildren()
{
base.UpdateAfterChildren();
@@ -50,28 +59,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
}
protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart)
- => new SpectatorGameplayClockContainer(spectatorPlayerClock);
-
- private class SpectatorGameplayClockContainer : GameplayClockContainer
- {
- public SpectatorGameplayClockContainer([NotNull] IClock sourceClock)
- : base(sourceClock)
- {
- }
-
- protected override void Update()
- {
- // The SourceClock here is always a CatchUpSpectatorPlayerClock.
- // The player clock's running state is controlled externally, but the local pausing state needs to be updated to stop gameplay.
- if (SourceClock.IsRunning)
- Start();
- else
- Stop();
-
- base.Update();
- }
-
- protected override GameplayClock CreateGameplayClock(IFrameBasedClock source) => new GameplayClock(source);
- }
+ => new GameplayClockContainer(spectatorPlayerClock);
}
}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs
index 5cd9e0ddf9..7ed0be50e5 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs
@@ -126,7 +126,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate
for (int i = 0; i < Users.Count; i++)
{
- grid.Add(instances[i] = new PlayerArea(Users[i], masterClockContainer.GameplayClock));
+ grid.Add(instances[i] = new PlayerArea(Users[i], masterClockContainer));
syncManager.AddPlayerClock(instances[i].GameplayClock);
}
diff --git a/osu.Game/Screens/Play/ComboEffects.cs b/osu.Game/Screens/Play/ComboEffects.cs
index 77681401bb..442b061af7 100644
--- a/osu.Game/Screens/Play/ComboEffects.cs
+++ b/osu.Game/Screens/Play/ComboEffects.cs
@@ -45,7 +45,7 @@ namespace osu.Game.Screens.Play
private ISamplePlaybackDisabler samplePlaybackDisabler { get; set; }
[Resolved]
- private GameplayClock gameplayClock { get; set; }
+ private IGameplayClock gameplayClock { get; set; }
private void onComboChange(ValueChangedEvent combo)
{
diff --git a/osu.Game/Screens/Play/GameplayClock.cs b/osu.Game/Screens/Play/GameplayClock.cs
deleted file mode 100644
index 6af795cfd8..0000000000
--- a/osu.Game/Screens/Play/GameplayClock.cs
+++ /dev/null
@@ -1,88 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-#nullable disable
-
-using System.Collections.Generic;
-using System.Linq;
-using osu.Framework.Bindables;
-using osu.Framework.Timing;
-using osu.Framework.Utils;
-
-namespace osu.Game.Screens.Play
-{
- ///
- /// A clock which is used for gameplay elements that need to follow audio time 1:1.
- /// Exposed via DI by .
- ///
- /// The main purpose of this clock is to stop components using it from accidentally processing the main
- /// , as this should only be done once to ensure accuracy.
- ///
- ///
- public class GameplayClock : IFrameBasedClock
- {
- internal readonly IFrameBasedClock UnderlyingClock;
-
- public readonly BindableBool IsPaused = new BindableBool();
-
- ///
- /// All adjustments applied to this clock which don't come from gameplay or mods.
- ///
- public virtual IEnumerable> NonGameplayAdjustments => Enumerable.Empty>();
-
- public GameplayClock(IFrameBasedClock underlyingClock)
- {
- UnderlyingClock = underlyingClock;
- }
-
- ///
- /// The time from which the clock should start. Will be seeked to on calling .
- ///
- ///
- /// If not set, a value of zero will be used.
- /// Importantly, the value will be inferred from the current ruleset in unless specified.
- ///
- public double? StartTime { get; internal set; }
-
- public double CurrentTime => UnderlyingClock.CurrentTime;
-
- public double Rate => UnderlyingClock.Rate;
-
- ///
- /// The rate of gameplay when playback is at 100%.
- /// This excludes any seeking / user adjustments.
- ///
- public double TrueGameplayRate
- {
- get
- {
- double baseRate = Rate;
-
- foreach (var adjustment in NonGameplayAdjustments)
- {
- if (Precision.AlmostEquals(adjustment.Value, 0))
- return 0;
-
- baseRate /= adjustment.Value;
- }
-
- return baseRate;
- }
- }
-
- public bool IsRunning => UnderlyingClock.IsRunning;
-
- public void ProcessFrame()
- {
- // intentionally not updating the underlying clock (handled externally).
- }
-
- public double ElapsedFrameTime => UnderlyingClock.ElapsedFrameTime;
-
- public double FramesPerSecond => UnderlyingClock.FramesPerSecond;
-
- public FrameTimeInfo TimeInfo => UnderlyingClock.TimeInfo;
-
- public IClock Source => UnderlyingClock;
- }
-}
diff --git a/osu.Game/Screens/Play/GameplayClockContainer.cs b/osu.Game/Screens/Play/GameplayClockContainer.cs
index b37d15e06c..ac846b45c4 100644
--- a/osu.Game/Screens/Play/GameplayClockContainer.cs
+++ b/osu.Game/Screens/Play/GameplayClockContainer.cs
@@ -1,49 +1,38 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-#nullable disable
-
using System;
+using System.Collections.Generic;
+using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Logging;
using osu.Framework.Timing;
+using osu.Framework.Utils;
namespace osu.Game.Screens.Play
{
///
- /// Encapsulates gameplay timing logic and provides a via DI for gameplay components to use.
+ /// Encapsulates gameplay timing logic and provides a via DI for gameplay components to use.
///
- public abstract class GameplayClockContainer : Container, IAdjustableClock
+ public class GameplayClockContainer : Container, IAdjustableClock, IGameplayClock
{
- ///
- /// The final clock which is exposed to gameplay components.
- ///
- public GameplayClock GameplayClock { get; private set; }
-
///
/// Whether gameplay is paused.
///
- public readonly BindableBool IsPaused = new BindableBool(true);
+ public IBindable IsPaused => isPaused;
///
- /// The adjustable source clock used for gameplay. Should be used for seeks and clock control.
+ /// The source clock. Should generally not be used for any timekeeping purposes.
///
- protected readonly DecoupleableInterpolatingFramedClock AdjustableSource;
-
- ///
- /// The source clock.
- ///
- protected IClock SourceClock { get; private set; }
+ public IClock SourceClock { get; private set; }
///
/// Invoked when a seek has been performed via
///
- public event Action OnSeek;
-
- private double? startTime;
+ public event Action? OnSeek;
///
/// The time from which the clock should start. Will be seeked to on calling .
@@ -52,40 +41,46 @@ namespace osu.Game.Screens.Play
/// If not set, a value of zero will be used.
/// Importantly, the value will be inferred from the current ruleset in unless specified.
///
- public double? StartTime
- {
- get => startTime;
- set
- {
- startTime = value;
+ public double? StartTime { get; set; }
- if (GameplayClock != null)
- GameplayClock.StartTime = value;
- }
- }
+ public virtual IEnumerable NonGameplayAdjustments => Enumerable.Empty();
+
+ ///
+ /// The final clock which is exposed to gameplay components.
+ ///
+ protected IFrameBasedClock FramedClock { get; private set; }
+
+ private readonly BindableBool isPaused = new BindableBool(true);
+
+ ///
+ /// The adjustable source clock used for gameplay. Should be used for seeks and clock control.
+ ///
+ private readonly DecoupleableInterpolatingFramedClock decoupledClock;
///
/// Creates a new .
///
/// The source used for timing.
- protected GameplayClockContainer(IClock sourceClock)
+ public GameplayClockContainer(IClock sourceClock)
{
SourceClock = sourceClock;
RelativeSizeAxes = Axes.Both;
- AdjustableSource = new DecoupleableInterpolatingFramedClock { IsCoupled = false };
+ decoupledClock = new DecoupleableInterpolatingFramedClock { IsCoupled = false };
IsPaused.BindValueChanged(OnIsPausedChanged);
+
+ // this will be replaced during load, but non-null for tests which don't add this component to the hierarchy.
+ FramedClock = new FramedClock();
}
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
{
var dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
- dependencies.CacheAs(GameplayClock = CreateGameplayClock(AdjustableSource));
+ FramedClock = CreateGameplayClock(decoupledClock);
- GameplayClock.StartTime = StartTime;
- GameplayClock.IsPaused.BindTo(IsPaused);
+ dependencies.CacheAs(this);
return dependencies;
}
@@ -97,16 +92,16 @@ namespace osu.Game.Screens.Play
{
ensureSourceClockSet();
- if (!AdjustableSource.IsRunning)
+ if (!decoupledClock.IsRunning)
{
// Seeking the decoupled clock to its current time ensures that its source clock will be seeked to the same time
// This accounts for the clock source potentially taking time to enter a completely stopped state
- Seek(GameplayClock.CurrentTime);
+ Seek(FramedClock.CurrentTime);
- AdjustableSource.Start();
+ decoupledClock.Start();
}
- IsPaused.Value = false;
+ isPaused.Value = false;
}
///
@@ -117,10 +112,10 @@ namespace osu.Game.Screens.Play
{
Logger.Log($"{nameof(GameplayClockContainer)} seeking to {time}");
- AdjustableSource.Seek(time);
+ decoupledClock.Seek(time);
// Manually process to make sure the gameplay clock is correctly updated after a seek.
- GameplayClock.UnderlyingClock.ProcessFrame();
+ FramedClock.ProcessFrame();
OnSeek?.Invoke();
}
@@ -128,7 +123,7 @@ namespace osu.Game.Screens.Play
///
/// Stops gameplay.
///
- public void Stop() => IsPaused.Value = true;
+ public void Stop() => isPaused.Value = true;
///
/// Resets this and the source to an initial state ready for gameplay.
@@ -137,7 +132,7 @@ namespace osu.Game.Screens.Play
public void Reset(bool startClock = false)
{
// Manually stop the source in order to not affect the IsPaused state.
- AdjustableSource.Stop();
+ decoupledClock.Stop();
if (!IsPaused.Value || startClock)
Start();
@@ -150,10 +145,10 @@ namespace osu.Game.Screens.Play
/// Changes the source clock.
///
/// The new source.
- protected void ChangeSource(IClock sourceClock) => AdjustableSource.ChangeSource(SourceClock = sourceClock);
+ protected void ChangeSource(IClock sourceClock) => decoupledClock.ChangeSource(SourceClock = sourceClock);
///
- /// Ensures that the is set to , if it hasn't been given a source yet.
+ /// Ensures that the is set to , if it hasn't been given a source yet.
/// This is usually done before a seek to avoid accidentally seeking only the adjustable source in decoupled mode,
/// but not the actual source clock.
/// That will pretty much only happen on the very first call of this method, as the source clock is passed in the constructor,
@@ -161,39 +156,39 @@ namespace osu.Game.Screens.Play
///
private void ensureSourceClockSet()
{
- if (AdjustableSource.Source == null)
+ if (decoupledClock.Source == null)
ChangeSource(SourceClock);
}
protected override void Update()
{
if (!IsPaused.Value)
- GameplayClock.UnderlyingClock.ProcessFrame();
+ FramedClock.ProcessFrame();
base.Update();
}
///
- /// Invoked when the value of is changed to start or stop the clock.
+ /// Invoked when the value of is changed to start or stop the clock.
///
/// Whether the clock should now be paused.
protected virtual void OnIsPausedChanged(ValueChangedEvent isPaused)
{
if (isPaused.NewValue)
- AdjustableSource.Stop();
+ decoupledClock.Stop();
else
- AdjustableSource.Start();
+ decoupledClock.Start();
}
///
- /// Creates the final which is exposed via DI to be used by gameplay components.
+ /// Creates the final which is exposed via DI to be used by gameplay components.
///
///
/// Any intermediate clocks such as platform offsets should be applied here.
///
/// The providing the source time.
- /// The final .
- protected abstract GameplayClock CreateGameplayClock(IFrameBasedClock source);
+ /// The final .
+ protected virtual IFrameBasedClock CreateGameplayClock(IFrameBasedClock source) => source;
#region IAdjustableClock
@@ -205,22 +200,49 @@ namespace osu.Game.Screens.Play
void IAdjustableClock.Reset() => Reset();
- public void ResetSpeedAdjustments()
- {
- }
+ public void ResetSpeedAdjustments() => throw new NotImplementedException();
double IAdjustableClock.Rate
{
- get => GameplayClock.Rate;
+ get => FramedClock.Rate;
set => throw new NotSupportedException();
}
- double IClock.Rate => GameplayClock.Rate;
+ public double Rate => FramedClock.Rate;
- public double CurrentTime => GameplayClock.CurrentTime;
+ public double CurrentTime => FramedClock.CurrentTime;
- public bool IsRunning => GameplayClock.IsRunning;
+ public bool IsRunning => FramedClock.IsRunning;
#endregion
+
+ public void ProcessFrame()
+ {
+ // Handled via update. Don't process here to safeguard from external usages potentially processing frames additional times.
+ }
+
+ public double ElapsedFrameTime => FramedClock.ElapsedFrameTime;
+
+ public double FramesPerSecond => FramedClock.FramesPerSecond;
+
+ public FrameTimeInfo TimeInfo => FramedClock.TimeInfo;
+
+ public double TrueGameplayRate
+ {
+ get
+ {
+ double baseRate = Rate;
+
+ foreach (double adjustment in NonGameplayAdjustments)
+ {
+ if (Precision.AlmostEquals(adjustment, 0))
+ return 0;
+
+ baseRate /= adjustment;
+ }
+
+ return baseRate;
+ }
+ }
}
}
diff --git a/osu.Game/Screens/Play/HUD/SongProgress.cs b/osu.Game/Screens/Play/HUD/SongProgress.cs
index 09afd7a9d3..f368edbfb9 100644
--- a/osu.Game/Screens/Play/HUD/SongProgress.cs
+++ b/osu.Game/Screens/Play/HUD/SongProgress.cs
@@ -23,7 +23,7 @@ namespace osu.Game.Screens.Play.HUD
public bool UsesFixedAnchor { get; set; }
[Resolved]
- protected GameplayClock GameplayClock { get; private set; } = null!;
+ protected IGameplayClock GameplayClock { get; private set; } = null!;
[Resolved(canBeNull: true)]
private DrawableRuleset? drawableRuleset { get; set; }
@@ -94,7 +94,10 @@ namespace osu.Game.Screens.Play.HUD
double objectOffsetCurrent = currentTime - FirstHitTime;
double objectDuration = LastHitTime - FirstHitTime;
- UpdateProgress(objectOffsetCurrent / objectDuration, false);
+ if (objectDuration == 0)
+ UpdateProgress(0, false);
+ else
+ UpdateProgress(objectOffsetCurrent / objectDuration, false);
}
}
}
diff --git a/osu.Game/Screens/Play/HUD/SongProgressInfo.cs b/osu.Game/Screens/Play/HUD/SongProgressInfo.cs
index 8f10e84509..96a4c5f2bc 100644
--- a/osu.Game/Screens/Play/HUD/SongProgressInfo.cs
+++ b/osu.Game/Screens/Play/HUD/SongProgressInfo.cs
@@ -38,10 +38,10 @@ namespace osu.Game.Screens.Play.HUD
set => endTime = value;
}
- private GameplayClock gameplayClock;
+ private IGameplayClock gameplayClock;
[BackgroundDependencyLoader(true)]
- private void load(OsuColour colours, GameplayClock clock)
+ private void load(OsuColour colours, IGameplayClock clock)
{
if (clock != null)
gameplayClock = clock;
diff --git a/osu.Game/Screens/Play/IGameplayClock.cs b/osu.Game/Screens/Play/IGameplayClock.cs
new file mode 100644
index 0000000000..5f54ce691a
--- /dev/null
+++ b/osu.Game/Screens/Play/IGameplayClock.cs
@@ -0,0 +1,34 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using osu.Framework.Bindables;
+using osu.Framework.Timing;
+
+namespace osu.Game.Screens.Play
+{
+ public interface IGameplayClock : IFrameBasedClock
+ {
+ ///
+ /// The rate of gameplay when playback is at 100%.
+ /// This excludes any seeking / user adjustments.
+ ///
+ double TrueGameplayRate { get; }
+
+ ///
+ /// The time from which the clock should start. Will be seeked to on calling .
+ ///
+ ///
+ /// If not set, a value of zero will be used.
+ /// Importantly, the value will be inferred from the current ruleset in unless specified.
+ ///
+ double? StartTime { get; }
+
+ ///
+ /// All adjustments applied to this clock which don't come from gameplay or mods.
+ ///
+ IEnumerable NonGameplayAdjustments { get; }
+
+ IBindable IsPaused { get; }
+ }
+}
diff --git a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs
index d7f6992fee..d26f0c6311 100644
--- a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs
+++ b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs
@@ -35,8 +35,6 @@ namespace osu.Game.Screens.Play
///
public const double MINIMUM_SKIP_TIME = 1000;
- protected Track Track => (Track)SourceClock;
-
public readonly BindableNumber UserPlaybackRate = new BindableDouble(1)
{
Default = 1,
@@ -51,10 +49,10 @@ namespace osu.Game.Screens.Play
private readonly WorkingBeatmap beatmap;
- private HardwareCorrectionOffsetClock userGlobalOffsetClock = null!;
- private HardwareCorrectionOffsetClock userBeatmapOffsetClock = null!;
- private HardwareCorrectionOffsetClock platformOffsetClock = null!;
- private MasterGameplayClock masterGameplayClock = null!;
+ private OffsetCorrectionClock userGlobalOffsetClock = null!;
+ private OffsetCorrectionClock userBeatmapOffsetClock = null!;
+ private OffsetCorrectionClock platformOffsetClock = null!;
+
private Bindable userAudioOffset = null!;
private IDisposable? beatmapOffsetSubscription;
@@ -67,6 +65,10 @@ namespace osu.Game.Screens.Play
[Resolved]
private OsuConfigManager config { get; set; } = null!;
+ private readonly List> nonGameplayAdjustments = new List>();
+
+ public override IEnumerable NonGameplayAdjustments => nonGameplayAdjustments.Select(b => b.Value);
+
///
/// Create a new master gameplay clock container.
///
@@ -134,7 +136,7 @@ namespace osu.Game.Screens.Play
this.TransformBindableTo(pauseFreqAdjust, 0, 200, Easing.Out).OnComplete(_ =>
{
if (IsPaused.Value == isPaused.NewValue)
- AdjustableSource.Stop();
+ base.OnIsPausedChanged(isPaused);
});
}
else
@@ -143,14 +145,14 @@ namespace osu.Game.Screens.Play
else
{
if (isPaused.NewValue)
- AdjustableSource.Stop();
+ base.OnIsPausedChanged(isPaused);
// If not yet loaded, we still want to ensure relevant state is correct, as it is used for offset calculations.
pauseFreqAdjust.Value = isPaused.NewValue ? 0 : 1;
// We must also process underlying gameplay clocks to update rate-adjusted offsets with the new frequency adjustment.
// Without doing this, an initial seek may be performed with the wrong offset.
- GameplayClock.UnderlyingClock.ProcessFrame();
+ FramedClock.ProcessFrame();
}
}
@@ -179,29 +181,27 @@ namespace osu.Game.Screens.Play
///
public void Skip()
{
- if (GameplayClock.CurrentTime > skipTargetTime - MINIMUM_SKIP_TIME)
+ if (FramedClock.CurrentTime > skipTargetTime - MINIMUM_SKIP_TIME)
return;
double skipTarget = skipTargetTime - MINIMUM_SKIP_TIME;
- if (GameplayClock.CurrentTime < 0 && skipTarget > 6000)
+ if (FramedClock.CurrentTime < 0 && skipTarget > 6000)
// double skip exception for storyboards with very long intros
skipTarget = 0;
Seek(skipTarget);
}
- protected override GameplayClock CreateGameplayClock(IFrameBasedClock source)
+ protected override IFrameBasedClock CreateGameplayClock(IFrameBasedClock source)
{
// Lazer's audio timings in general doesn't match stable. This is the result of user testing, albeit limited.
// This only seems to be required on windows. We need to eventually figure out why, with a bit of luck.
- platformOffsetClock = new HardwareCorrectionOffsetClock(source, pauseFreqAdjust) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 };
+ platformOffsetClock = new OffsetCorrectionClock(source, pauseFreqAdjust) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 };
// the final usable gameplay clock with user-set offsets applied.
- userGlobalOffsetClock = new HardwareCorrectionOffsetClock(platformOffsetClock, pauseFreqAdjust);
- userBeatmapOffsetClock = new HardwareCorrectionOffsetClock(userGlobalOffsetClock, pauseFreqAdjust);
-
- return masterGameplayClock = new MasterGameplayClock(userBeatmapOffsetClock);
+ userGlobalOffsetClock = new OffsetCorrectionClock(platformOffsetClock, pauseFreqAdjust);
+ return userBeatmapOffsetClock = new OffsetCorrectionClock(userGlobalOffsetClock, pauseFreqAdjust);
}
///
@@ -221,11 +221,14 @@ namespace osu.Game.Screens.Play
if (speedAdjustmentsApplied)
return;
- Track.AddAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust);
- Track.AddAdjustment(AdjustableProperty.Tempo, UserPlaybackRate);
+ if (SourceClock is not Track track)
+ return;
- masterGameplayClock.MutableNonGameplayAdjustments.Add(pauseFreqAdjust);
- masterGameplayClock.MutableNonGameplayAdjustments.Add(UserPlaybackRate);
+ track.AddAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust);
+ track.AddAdjustment(AdjustableProperty.Tempo, UserPlaybackRate);
+
+ nonGameplayAdjustments.Add(pauseFreqAdjust);
+ nonGameplayAdjustments.Add(UserPlaybackRate);
speedAdjustmentsApplied = true;
}
@@ -235,11 +238,14 @@ namespace osu.Game.Screens.Play
if (!speedAdjustmentsApplied)
return;
- Track.RemoveAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust);
- Track.RemoveAdjustment(AdjustableProperty.Tempo, UserPlaybackRate);
+ if (SourceClock is not Track track)
+ return;
- masterGameplayClock.MutableNonGameplayAdjustments.Remove(pauseFreqAdjust);
- masterGameplayClock.MutableNonGameplayAdjustments.Remove(UserPlaybackRate);
+ track.RemoveAdjustment(AdjustableProperty.Frequency, pauseFreqAdjust);
+ track.RemoveAdjustment(AdjustableProperty.Tempo, UserPlaybackRate);
+
+ nonGameplayAdjustments.Remove(pauseFreqAdjust);
+ nonGameplayAdjustments.Remove(UserPlaybackRate);
speedAdjustmentsApplied = false;
}
@@ -252,63 +258,8 @@ namespace osu.Game.Screens.Play
}
ControlPointInfo IBeatSyncProvider.ControlPoints => beatmap.Beatmap.ControlPointInfo;
- IClock IBeatSyncProvider.Clock => GameplayClock;
+ IClock IBeatSyncProvider.Clock => this;
+
ChannelAmplitudes IHasAmplitudes.CurrentAmplitudes => beatmap.TrackLoaded ? beatmap.Track.CurrentAmplitudes : ChannelAmplitudes.Empty;
-
- private class HardwareCorrectionOffsetClock : FramedOffsetClock
- {
- private readonly BindableDouble pauseRateAdjust;
-
- private double offset;
-
- public new double Offset
- {
- get => offset;
- set
- {
- if (value == offset)
- return;
-
- offset = value;
-
- updateOffset();
- }
- }
-
- public double RateAdjustedOffset => base.Offset;
-
- public HardwareCorrectionOffsetClock(IClock source, BindableDouble pauseRateAdjust)
- : base(source)
- {
- this.pauseRateAdjust = pauseRateAdjust;
- }
-
- public override void ProcessFrame()
- {
- base.ProcessFrame();
- updateOffset();
- }
-
- private void updateOffset()
- {
- // changing this during the pause transform effect will cause a potentially large offset to be suddenly applied as we approach zero rate.
- if (pauseRateAdjust.Value == 1)
- {
- // we always want to apply the same real-time offset, so it should be adjusted by the difference in playback rate (from realtime) to achieve this.
- base.Offset = Offset * Rate;
- }
- }
- }
-
- private class MasterGameplayClock : GameplayClock
- {
- public readonly List> MutableNonGameplayAdjustments = new List>();
- public override IEnumerable> NonGameplayAdjustments => MutableNonGameplayAdjustments;
-
- public MasterGameplayClock(FramedOffsetClock underlyingClock)
- : base(underlyingClock)
- {
- }
- }
}
}
diff --git a/osu.Game/Screens/Play/OffsetCorrectionClock.cs b/osu.Game/Screens/Play/OffsetCorrectionClock.cs
new file mode 100644
index 0000000000..207980f45c
--- /dev/null
+++ b/osu.Game/Screens/Play/OffsetCorrectionClock.cs
@@ -0,0 +1,53 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Bindables;
+using osu.Framework.Timing;
+
+namespace osu.Game.Screens.Play
+{
+ public class OffsetCorrectionClock : FramedOffsetClock
+ {
+ private readonly BindableDouble pauseRateAdjust;
+
+ private double offset;
+
+ public new double Offset
+ {
+ get => offset;
+ set
+ {
+ if (value == offset)
+ return;
+
+ offset = value;
+
+ updateOffset();
+ }
+ }
+
+ public double RateAdjustedOffset => base.Offset;
+
+ public OffsetCorrectionClock(IClock source, BindableDouble pauseRateAdjust)
+ : base(source)
+ {
+ this.pauseRateAdjust = pauseRateAdjust;
+ }
+
+ public override void ProcessFrame()
+ {
+ base.ProcessFrame();
+ updateOffset();
+ }
+
+ private void updateOffset()
+ {
+ // changing this during the pause transform effect will cause a potentially large offset to be suddenly applied as we approach zero rate.
+ if (pauseRateAdjust.Value == 1)
+ {
+ // we always want to apply the same real-time offset, so it should be adjusted by the difference in playback rate (from realtime) to achieve this.
+ base.Offset = Offset * Rate;
+ }
+ }
+ }
+}
diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs
index 08b6da1921..6827ff04d3 100644
--- a/osu.Game/Screens/Play/Player.cs
+++ b/osu.Game/Screens/Play/Player.cs
@@ -77,7 +77,7 @@ namespace osu.Game.Screens.Play
///
protected virtual bool PauseOnFocusLost => true;
- public Action RestartRequested;
+ public Action RestartRequested;
private bool isRestarting;
@@ -267,7 +267,7 @@ namespace osu.Game.Screens.Play
FailOverlay = new FailOverlay
{
SaveReplay = prepareAndImportScore,
- OnRetry = Restart,
+ OnRetry = () => Restart(),
OnQuit = () => PerformExit(true),
},
new HotkeyExitOverlay
@@ -294,7 +294,7 @@ namespace osu.Game.Screens.Play
if (!this.IsCurrentScreen()) return;
fadeOut(true);
- Restart();
+ Restart(true);
},
});
}
@@ -330,7 +330,7 @@ namespace osu.Game.Screens.Play
DrawableRuleset.HasReplayLoaded.BindValueChanged(_ => updateGameplayState());
// bind clock into components that require it
- DrawableRuleset.IsPaused.BindTo(GameplayClockContainer.IsPaused);
+ ((IBindable)DrawableRuleset.IsPaused).BindTo(GameplayClockContainer.IsPaused);
DrawableRuleset.NewResult += r =>
{
@@ -371,6 +371,9 @@ namespace osu.Game.Screens.Play
IsBreakTime.BindTo(breakTracker.IsBreakTime);
IsBreakTime.BindValueChanged(onBreakTimeChanged, true);
+
+ if (Configuration.AutomaticallySkipIntro)
+ skipIntroOverlay.SkipWhenReady();
}
protected virtual GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new MasterGameplayClockContainer(beatmap, gameplayStart);
@@ -441,7 +444,7 @@ namespace osu.Game.Screens.Play
{
OnResume = Resume,
Retries = RestartCount,
- OnRetry = Restart,
+ OnRetry = () => Restart(),
OnQuit = () => PerformExit(true),
},
},
@@ -475,7 +478,7 @@ namespace osu.Game.Screens.Play
private void updateSampleDisabledState()
{
- samplePlaybackDisabled.Value = DrawableRuleset.FrameStableClock.IsCatchingUp.Value || GameplayClockContainer.GameplayClock.IsPaused.Value;
+ samplePlaybackDisabled.Value = DrawableRuleset.FrameStableClock.IsCatchingUp.Value || GameplayClockContainer.IsPaused.Value;
}
private void updatePauseOnFocusLostState()
@@ -648,7 +651,8 @@ namespace osu.Game.Screens.Play
/// Restart gameplay via a parent .
/// This can be called from a child screen in order to trigger the restart process.
///
- public void Restart()
+ /// Whether a quick restart was requested (skipping intro etc.).
+ public void Restart(bool quickRestart = false)
{
if (!Configuration.AllowRestart)
return;
@@ -660,7 +664,7 @@ namespace osu.Game.Screens.Play
musicController.Stop();
sampleRestart?.Play();
- RestartRequested?.Invoke();
+ RestartRequested?.Invoke(quickRestart);
PerformExit(false);
}
@@ -840,7 +844,7 @@ namespace osu.Game.Screens.Play
failAnimationLayer.Start();
if (GameplayState.Mods.OfType().Any(m => m.RestartOnFail))
- Restart();
+ Restart(true);
return true;
}
@@ -877,7 +881,7 @@ namespace osu.Game.Screens.Play
private double? lastPauseActionTime;
protected bool PauseCooldownActive =>
- lastPauseActionTime.HasValue && GameplayClockContainer.GameplayClock.CurrentTime < lastPauseActionTime + pause_cooldown;
+ lastPauseActionTime.HasValue && GameplayClockContainer.CurrentTime < lastPauseActionTime + pause_cooldown;
///
/// A set of conditionals which defines whether the current game state and configuration allows for
@@ -915,7 +919,7 @@ namespace osu.Game.Screens.Play
GameplayClockContainer.Stop();
PauseOverlay.Show();
- lastPauseActionTime = GameplayClockContainer.GameplayClock.CurrentTime;
+ lastPauseActionTime = GameplayClockContainer.CurrentTime;
return true;
}
@@ -1005,7 +1009,7 @@ namespace osu.Game.Screens.Play
///
protected virtual void StartGameplay()
{
- if (GameplayClockContainer.GameplayClock.IsRunning)
+ if (GameplayClockContainer.IsRunning)
throw new InvalidOperationException($"{nameof(StartGameplay)} should not be called when the gameplay clock is already running");
GameplayClockContainer.Reset(true);
diff --git a/osu.Game/Screens/Play/PlayerConfiguration.cs b/osu.Game/Screens/Play/PlayerConfiguration.cs
index d11825baee..b1b0e01d80 100644
--- a/osu.Game/Screens/Play/PlayerConfiguration.cs
+++ b/osu.Game/Screens/Play/PlayerConfiguration.cs
@@ -31,5 +31,10 @@ namespace osu.Game.Screens.Play
/// Whether the player should be allowed to skip intros/outros, advancing to the start of gameplay or the end of a storyboard.
///
public bool AllowSkipping { get; set; } = true;
+
+ ///
+ /// Whether the intro should be skipped by default.
+ ///
+ public bool AutomaticallySkipIntro { get; set; }
}
}
diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs
index 674490d595..e6bd1367ef 100644
--- a/osu.Game/Screens/Play/PlayerLoader.cs
+++ b/osu.Game/Screens/Play/PlayerLoader.cs
@@ -123,6 +123,8 @@ namespace osu.Game.Screens.Play
private EpilepsyWarning? epilepsyWarning;
+ private bool quickRestart;
+
[Resolved(CanBeNull = true)]
private INotificationOverlay? notificationOverlay { get; set; }
@@ -361,6 +363,7 @@ namespace osu.Game.Screens.Play
return;
CurrentPlayer = createPlayer();
+ CurrentPlayer.Configuration.AutomaticallySkipIntro = quickRestart;
CurrentPlayer.RestartCount = restartCount++;
CurrentPlayer.RestartRequested = restartRequested;
@@ -375,8 +378,9 @@ namespace osu.Game.Screens.Play
{
}
- private void restartRequested()
+ private void restartRequested(bool quickRestartRequested)
{
+ quickRestart = quickRestartRequested;
hideOverlays = true;
ValidForResume = true;
}
diff --git a/osu.Game/Screens/Play/ScreenSuspensionHandler.cs b/osu.Game/Screens/Play/ScreenSuspensionHandler.cs
index 59b92a1b97..cc1254975c 100644
--- a/osu.Game/Screens/Play/ScreenSuspensionHandler.cs
+++ b/osu.Game/Screens/Play/ScreenSuspensionHandler.cs
@@ -18,7 +18,7 @@ namespace osu.Game.Screens.Play
public class ScreenSuspensionHandler : Component
{
private readonly GameplayClockContainer gameplayClockContainer;
- private Bindable isPaused;
+ private IBindable isPaused;
private readonly Bindable disableSuspensionBindable = new Bindable();
diff --git a/osu.Game/Screens/Play/SkipOverlay.cs b/osu.Game/Screens/Play/SkipOverlay.cs
index 3e2cf9a756..5c9a706549 100644
--- a/osu.Game/Screens/Play/SkipOverlay.cs
+++ b/osu.Game/Screens/Play/SkipOverlay.cs
@@ -39,9 +39,12 @@ namespace osu.Game.Screens.Play
private double displayTime;
private bool isClickable;
+ private bool skipQueued;
[Resolved]
- private GameplayClock gameplayClock { get; set; }
+ private IGameplayClock gameplayClock { get; set; }
+
+ internal bool IsButtonVisible => fadeContainer.State == Visibility.Visible && buttonContainer.State.Value == Visibility.Visible;
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true;
@@ -123,6 +126,20 @@ namespace osu.Game.Screens.Play
displayTime = gameplayClock.CurrentTime;
fadeContainer.TriggerShow();
+
+ if (skipQueued)
+ {
+ Scheduler.AddDelayed(() => button.TriggerClick(), 200);
+ skipQueued = false;
+ }
+ }
+
+ public void SkipWhenReady()
+ {
+ if (IsLoaded)
+ button.TriggerClick();
+ else
+ skipQueued = true;
}
protected override void Update()
diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs
index c530febcae..226216b0f0 100644
--- a/osu.Game/Screens/Ranking/ResultsScreen.cs
+++ b/osu.Game/Screens/Ranking/ResultsScreen.cs
@@ -177,7 +177,7 @@ namespace osu.Game.Screens.Ranking
{
if (!this.IsCurrentScreen()) return;
- player?.Restart();
+ player?.Restart(true);
},
});
}
diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs
index 33ff31857f..0c2ca6d4af 100644
--- a/osu.Game/Screens/Select/SongSelect.cs
+++ b/osu.Game/Screens/Select/SongSelect.cs
@@ -683,7 +683,7 @@ namespace osu.Game.Screens.Select
}
private void ensureTrackLooping(IWorkingBeatmap beatmap, TrackChangeDirection changeDirection)
- => beatmap.PrepareTrackForPreviewLooping();
+ => beatmap.PrepareTrackForPreview(true);
public override bool OnBackButton()
{
diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs
index 8343f14050..6295604438 100644
--- a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs
+++ b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs
@@ -85,7 +85,7 @@ namespace osu.Game.Storyboards.Drawables
}
[BackgroundDependencyLoader(true)]
- private void load(GameplayClock clock, CancellationToken? cancellationToken, GameHost host, RealmAccess realm)
+ private void load(IGameplayClock clock, CancellationToken? cancellationToken, GameHost host, RealmAccess realm)
{
if (clock != null)
Clock = clock;
diff --git a/osu.Game/Tests/VisualTestRunner.cs b/osu.Game/Tests/VisualTestRunner.cs
index bd98482768..c8279b9e3c 100644
--- a/osu.Game/Tests/VisualTestRunner.cs
+++ b/osu.Game/Tests/VisualTestRunner.cs
@@ -14,7 +14,7 @@ namespace osu.Game.Tests
[STAThread]
public static int Main(string[] args)
{
- using (DesktopGameHost host = Host.GetSuitableDesktopHost(@"osu", new HostOptions { BindIPC = true, }))
+ using (DesktopGameHost host = Host.GetSuitableDesktopHost(@"osu-development", new HostOptions { BindIPC = true, }))
{
host.Run(new OsuTestBrowser());
return 0;
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index c17d45e84a..0613db891b 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -23,25 +23,25 @@
-
-
-
+
+
+
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
-
-
-
-
+
+
+
+
+
-
+
diff --git a/osu.iOS.props b/osu.iOS.props
index 5bfb53bc9d..bf1e4e350c 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -61,8 +61,8 @@
-
-
+
+
@@ -84,11 +84,11 @@
-
+
-
+