diff --git a/osu.Android.props b/osu.Android.props
index 3682a44b9f..73ee1d9d10 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -52,6 +52,6 @@
-
+
diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs
index b2487568ce..0c21c75290 100644
--- a/osu.Desktop/OsuGameDesktop.cs
+++ b/osu.Desktop/OsuGameDesktop.cs
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
+using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
@@ -18,6 +19,7 @@ using osu.Framework.Screens;
using osu.Game.Screens.Menu;
using osu.Game.Updater;
using osu.Desktop.Windows;
+using osu.Framework.Threading;
using osu.Game.IO;
namespace osu.Desktop
@@ -144,13 +146,39 @@ namespace osu.Desktop
desktopWindow.DragDrop += f => fileDrop(new[] { f });
}
+ private readonly List importableFiles = new List();
+ private ScheduledDelegate importSchedule;
+
private void fileDrop(string[] filePaths)
{
- var firstExtension = Path.GetExtension(filePaths.First());
+ lock (importableFiles)
+ {
+ var firstExtension = Path.GetExtension(filePaths.First());
- if (filePaths.Any(f => Path.GetExtension(f) != firstExtension)) return;
+ if (filePaths.Any(f => Path.GetExtension(f) != firstExtension)) return;
- Task.Factory.StartNew(() => Import(filePaths), TaskCreationOptions.LongRunning);
+ importableFiles.AddRange(filePaths);
+
+ Logger.Log($"Adding {filePaths.Length} files for import");
+
+ // File drag drop operations can potentially trigger hundreds or thousands of these calls on some platforms.
+ // In order to avoid spawning multiple import tasks for a single drop operation, debounce a touch.
+ importSchedule?.Cancel();
+ importSchedule = Scheduler.AddDelayed(handlePendingImports, 100);
+ }
+ }
+
+ private void handlePendingImports()
+ {
+ lock (importableFiles)
+ {
+ Logger.Log($"Handling batch import of {importableFiles.Count} files");
+
+ var paths = importableFiles.ToArray();
+ importableFiles.Clear();
+
+ Task.Factory.StartNew(() => Import(paths), TaskCreationOptions.LongRunning);
+ }
}
}
}
diff --git a/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs
index f4ee3f5a42..5580358f89 100644
--- a/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs
+++ b/osu.Game.Rulesets.Catch.Tests/CatchDifficultyCalculatorTest.cs
@@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Catch.Tests
public void Test(double expected, string name)
=> base.Test(expected, name);
- [TestCase(5.0565038923984691d, "diffcalc-test")]
+ [TestCase(5.169743871843191d, "diffcalc-test")]
public void TestClockRateAdjusted(double expected, string name)
=> Test(expected, name, new CatchModDoubleTime());
diff --git a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj
index 728af5124e..c2d9a923d9 100644
--- a/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj
+++ b/osu.Game.Rulesets.Catch.Tests/osu.Game.Rulesets.Catch.Tests.csproj
@@ -2,7 +2,7 @@
-
+
diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs b/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs
index 4b008d2734..bba42dea97 100644
--- a/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs
+++ b/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs
@@ -3,13 +3,16 @@
using System.Linq;
using osu.Framework.Graphics;
+using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.Objects.Drawables;
+using osu.Game.Rulesets.Catch.UI;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects.Drawables;
+using osu.Game.Rulesets.UI;
namespace osu.Game.Rulesets.Catch.Mods
{
- public class CatchModHidden : ModHidden
+ public class CatchModHidden : ModHidden, IApplicableToDrawableRuleset
{
public override string Description => @"Play with fading fruits.";
public override double ScoreMultiplier => 1.06;
@@ -17,6 +20,14 @@ namespace osu.Game.Rulesets.Catch.Mods
private const double fade_out_offset_multiplier = 0.6;
private const double fade_out_duration_multiplier = 0.44;
+ public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset)
+ {
+ var drawableCatchRuleset = (DrawableCatchRuleset)drawableRuleset;
+ var catchPlayfield = (CatchPlayfield)drawableCatchRuleset.Playfield;
+
+ catchPlayfield.CatcherArea.MovableCatcher.CatchFruitOnPlate = false;
+ }
+
protected override void ApplyNormalVisibilityState(DrawableHitObject hitObject, ArmedState state)
{
base.ApplyNormalVisibilityState(hitObject, state);
diff --git a/osu.Game.Rulesets.Catch/UI/Catcher.cs b/osu.Game.Rulesets.Catch/UI/Catcher.cs
index ed875e7002..5d57e84b75 100644
--- a/osu.Game.Rulesets.Catch/UI/Catcher.cs
+++ b/osu.Game.Rulesets.Catch/UI/Catcher.cs
@@ -43,6 +43,11 @@ namespace osu.Game.Rulesets.Catch.UI
///
public bool HyperDashing => hyperDashModifier != 1;
+ ///
+ /// Whether fruit should appear on the plate.
+ ///
+ public bool CatchFruitOnPlate { get; set; } = true;
+
///
/// The relative space to cover in 1 millisecond. based on 1 game pixel per millisecond as in osu-stable.
///
@@ -237,7 +242,8 @@ namespace osu.Game.Rulesets.Catch.UI
{
var positionInStack = computePositionInStack(new Vector2(palpableObject.X - X, 0), palpableObject.DisplaySize.X / 2);
- placeCaughtObject(palpableObject, positionInStack);
+ if (CatchFruitOnPlate)
+ placeCaughtObject(palpableObject, positionInStack);
if (hitLighting.Value)
addLighting(hitObject, positionInStack.X, drawableObject.AccentColour.Value);
diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs
index 09ca04be8a..6e6500a339 100644
--- a/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs
+++ b/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs
@@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Mania.Tests
public void Test(double expected, string name)
=> base.Test(expected, name);
- [TestCase(2.7646128945056723d, "diffcalc-test")]
+ [TestCase(2.7879104989252959d, "diffcalc-test")]
public void TestClockRateAdjusted(double expected, string name)
=> Test(expected, name, new ManiaModDoubleTime());
diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs
index 7ae69bf7d7..42ea12214f 100644
--- a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs
+++ b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs
@@ -288,17 +288,56 @@ namespace osu.Game.Rulesets.Mania.Tests
.All(j => j.Type.IsHit()));
}
+ [Test]
+ public void TestHitTailBeforeLastTick()
+ {
+ const int tick_rate = 8;
+ const double tick_spacing = TimingControlPoint.DEFAULT_BEAT_LENGTH / tick_rate;
+ const double time_last_tick = time_head + tick_spacing * (int)((time_tail - time_head) / tick_spacing - 1);
+
+ var beatmap = new Beatmap
+ {
+ HitObjects =
+ {
+ new HoldNote
+ {
+ StartTime = time_head,
+ Duration = time_tail - time_head,
+ Column = 0,
+ }
+ },
+ BeatmapInfo =
+ {
+ BaseDifficulty = new BeatmapDifficulty { SliderTickRate = tick_rate },
+ Ruleset = new ManiaRuleset().RulesetInfo
+ },
+ };
+
+ performTest(new List
+ {
+ new ManiaReplayFrame(time_head, ManiaAction.Key1),
+ new ManiaReplayFrame(time_last_tick - 5)
+ }, beatmap);
+
+ assertHeadJudgement(HitResult.Perfect);
+ assertLastTickJudgement(HitResult.LargeTickMiss);
+ assertTailJudgement(HitResult.Ok);
+ }
+
private void assertHeadJudgement(HitResult result)
- => AddAssert($"head judged as {result}", () => judgementResults[0].Type == result);
+ => AddAssert($"head judged as {result}", () => judgementResults.First(j => j.HitObject is Note).Type == result);
private void assertTailJudgement(HitResult result)
- => AddAssert($"tail judged as {result}", () => judgementResults[^2].Type == result);
+ => AddAssert($"tail judged as {result}", () => judgementResults.Single(j => j.HitObject is TailNote).Type == result);
private void assertNoteJudgement(HitResult result)
- => AddAssert($"hold note judged as {result}", () => judgementResults[^1].Type == result);
+ => AddAssert($"hold note judged as {result}", () => judgementResults.Single(j => j.HitObject is HoldNote).Type == result);
private void assertTickJudgement(HitResult result)
- => AddAssert($"tick judged as {result}", () => judgementResults[6].Type == result); // arbitrary tick
+ => AddAssert($"any tick judged as {result}", () => judgementResults.Where(j => j.HitObject is HoldNoteTick).Any(j => j.Type == result));
+
+ private void assertLastTickJudgement(HitResult result)
+ => AddAssert($"last tick judged as {result}", () => judgementResults.Last(j => j.HitObject is HoldNoteTick).Type == result);
private ScoreAccessibleReplayPlayer currentPlayer;
diff --git a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj
index af16f39563..64e934efd2 100644
--- a/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj
+++ b/osu.Game.Rulesets.Mania.Tests/osu.Game.Rulesets.Mania.Tests.csproj
@@ -2,7 +2,7 @@
-
+
diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs
index 7a0e3b2b76..26393c8edb 100644
--- a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs
+++ b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs
@@ -47,7 +47,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
if (IsForCurrentRuleset)
{
- TargetColumns = (int)Math.Max(1, roundedCircleSize);
+ TargetColumns = GetColumnCountForNonConvert(beatmap.BeatmapInfo);
if (TargetColumns > ManiaRuleset.MAX_STAGE_KEYS)
{
@@ -71,6 +71,12 @@ namespace osu.Game.Rulesets.Mania.Beatmaps
originalTargetColumns = TargetColumns;
}
+ public static int GetColumnCountForNonConvert(BeatmapInfo beatmap)
+ {
+ var roundedCircleSize = Math.Round(beatmap.BaseDifficulty.CircleSize);
+ return (int)Math.Max(1, roundedCircleSize);
+ }
+
public override bool CanConvert() => Beatmap.HitObjects.All(h => h is IHasXPosition);
protected override Beatmap ConvertBeatmap(IBeatmap original, CancellationToken cancellationToken)
diff --git a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs
index d6ea58ee78..830b6004a6 100644
--- a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs
+++ b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs
@@ -73,8 +73,8 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills
}
protected override double GetPeakStrain(double offset)
- => applyDecay(individualStrain, offset - Previous[0].BaseObject.StartTime, individual_decay_base)
- + applyDecay(overallStrain, offset - Previous[0].BaseObject.StartTime, overall_decay_base);
+ => applyDecay(individualStrain, offset - Previous[0].StartTime, individual_decay_base)
+ + applyDecay(overallStrain, offset - Previous[0].StartTime, overall_decay_base);
private double applyDecay(double value, double deltaTime, double decayBase)
=> value * Math.Pow(decayBase, deltaTime / 1000);
diff --git a/osu.Game.Rulesets.Mania/ManiaFilterCriteria.cs b/osu.Game.Rulesets.Mania/ManiaFilterCriteria.cs
new file mode 100644
index 0000000000..d9a278ef29
--- /dev/null
+++ b/osu.Game.Rulesets.Mania/ManiaFilterCriteria.cs
@@ -0,0 +1,33 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets.Filter;
+using osu.Game.Rulesets.Mania.Beatmaps;
+using osu.Game.Screens.Select;
+using osu.Game.Screens.Select.Filter;
+
+namespace osu.Game.Rulesets.Mania
+{
+ public class ManiaFilterCriteria : IRulesetFilterCriteria
+ {
+ private FilterCriteria.OptionalRange keys;
+
+ public bool Matches(BeatmapInfo beatmap)
+ {
+ return !keys.HasFilter || (beatmap.RulesetID == new ManiaRuleset().LegacyID && keys.IsInRange(ManiaBeatmapConverter.GetColumnCountForNonConvert(beatmap)));
+ }
+
+ public bool TryParseCustomKeywordCriteria(string key, Operator op, string value)
+ {
+ switch (key)
+ {
+ case "key":
+ case "keys":
+ return FilterQueryParser.TryUpdateCriteriaRange(ref keys, op, value);
+ }
+
+ return false;
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs
index d624e094ad..88b63606b9 100644
--- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs
+++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs
@@ -22,6 +22,7 @@ using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Configuration;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Filter;
using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Configuration;
using osu.Game.Rulesets.Mania.Difficulty;
@@ -382,6 +383,11 @@ namespace osu.Game.Rulesets.Mania
}
}
};
+
+ public override IRulesetFilterCriteria CreateRulesetFilterCriteria()
+ {
+ return new ManiaFilterCriteria();
+ }
}
public enum PlayfieldType
diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs
index 4f062753a6..828ee7b03e 100644
--- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs
+++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs
@@ -233,6 +233,12 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
{
if (Tail.AllJudged)
{
+ foreach (var tick in tickContainer)
+ {
+ if (!tick.Judged)
+ tick.MissForcefully();
+ }
+
ApplyResult(r => r.Type = r.Judgement.MaxResult);
endHold();
}
diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs
index c2119585ab..afd94f4570 100644
--- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs
+++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs
@@ -20,8 +20,8 @@ namespace osu.Game.Rulesets.Osu.Tests
public void Test(double expected, string name)
=> base.Test(expected, name);
- [TestCase(8.6228371119271454d, "diffcalc-test")]
- [TestCase(1.2864585280364178d, "zero-length-sliders")]
+ [TestCase(8.7212283220412345d, "diffcalc-test")]
+ [TestCase(1.3212137158641493d, "zero-length-sliders")]
public void TestClockRateAdjusted(double expected, string name)
=> Test(expected, name, new OsuModDoubleTime());
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 3d2d1f3fec..f743d65db3 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/Mods/OsuModClassic.cs b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs
index 5470d0fcb4..882f848190 100644
--- a/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs
+++ b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs
@@ -44,6 +44,9 @@ namespace osu.Game.Rulesets.Osu.Mods
[SettingSource("Use fixed slider follow circle hit area", "Makes the slider follow circle track its final size at all times.")]
public Bindable FixedFollowCircleHitArea { get; } = new BindableBool(true);
+ [SettingSource("Always play a slider's tail sample", "Always plays a slider's tail sample regardless of whether it was hit or not.")]
+ public Bindable AlwaysPlayTailSample { get; } = new BindableBool(true);
+
public void ApplyToHitObject(HitObject hitObject)
{
switch (hitObject)
@@ -79,6 +82,10 @@ namespace osu.Game.Rulesets.Osu.Mods
case DrawableSliderHead head:
head.TrackFollowCircle = !NoSliderHeadMovement.Value;
break;
+
+ case DrawableSliderTail tail:
+ tail.SamplePlaysOnlyOnHit = !AlwaysPlayTailSample.Value;
+ break;
}
}
}
diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs
index 9122f347d0..04708a5ece 100644
--- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs
+++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs
@@ -280,7 +280,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
{
// rather than doing it this way, we should probably attach the sample to the tail circle.
// this can only be done after we stop using LegacyLastTick.
- if (TailCircle.IsHit)
+ if (!TailCircle.SamplePlaysOnlyOnHit || TailCircle.IsHit)
base.PlaySamples();
}
diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs
index 6a8e02e886..87f098dd29 100644
--- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs
+++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs
@@ -26,6 +26,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
///
public override bool DisplayResult => false;
+ ///
+ /// Whether the hit samples only play on successful hits.
+ /// If false, the hit samples will also play on misses.
+ ///
+ public bool SamplePlaysOnlyOnHit { get; set; } = true;
+
public bool Tracking { get; set; }
private SkinnableDrawable circlePiece;
diff --git a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs
index eb21c02d5f..dd3c6b317a 100644
--- a/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs
+++ b/osu.Game.Rulesets.Taiko.Tests/TaikoDifficultyCalculatorTest.cs
@@ -19,8 +19,8 @@ namespace osu.Game.Rulesets.Taiko.Tests
public void Test(double expected, string name)
=> base.Test(expected, name);
- [TestCase(3.1473940254109078d, "diffcalc-test")]
- [TestCase(3.1473940254109078d, "diffcalc-test-strong")]
+ [TestCase(3.1704781712282624d, "diffcalc-test")]
+ [TestCase(3.1704781712282624d, "diffcalc-test-strong")]
public void TestClockRateAdjusted(double expected, string name)
=> Test(expected, name, new TaikoModDoubleTime());
diff --git a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj
index fa00922706..eab144592f 100644
--- a/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj
+++ b/osu.Game.Rulesets.Taiko.Tests/osu.Game.Rulesets.Taiko.Tests.csproj
@@ -2,7 +2,7 @@
-
+
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs
index 4a0e1282c4..9d85a9995d 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs
@@ -3,6 +3,8 @@
using System.Collections.Generic;
using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
@@ -11,6 +13,7 @@ using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
+using osu.Game.Database;
using osu.Game.Online;
using osu.Game.Online.Spectator;
using osu.Game.Replays.Legacy;
@@ -29,10 +32,13 @@ namespace osu.Game.Tests.Visual.Gameplay
[Cached(typeof(SpectatorStreamingClient))]
private TestSpectatorStreamingClient testSpectatorStreamingClient = new TestSpectatorStreamingClient();
+ [Cached(typeof(UserLookupCache))]
+ private UserLookupCache lookupCache = new TestUserLookupCache();
+
// used just to show beatmap card for the time being.
protected override bool UseOnlineAPI => true;
- private Spectator spectatorScreen;
+ private SoloSpectator spectatorScreen;
[Resolved]
private OsuGameBase game { get; set; }
@@ -69,7 +75,7 @@ namespace osu.Game.Tests.Visual.Gameplay
{
loadSpectatingScreen();
- AddAssert("screen hasn't changed", () => Stack.CurrentScreen is Spectator);
+ AddAssert("screen hasn't changed", () => Stack.CurrentScreen is SoloSpectator);
start();
sendFrames();
@@ -195,7 +201,7 @@ namespace osu.Game.Tests.Visual.Gameplay
start(-1234);
sendFrames();
- AddAssert("screen didn't change", () => Stack.CurrentScreen is Spectator);
+ AddAssert("screen didn't change", () => Stack.CurrentScreen is SoloSpectator);
}
private OsuFramedReplayInputHandler replayHandler =>
@@ -226,7 +232,7 @@ namespace osu.Game.Tests.Visual.Gameplay
private void loadSpectatingScreen()
{
- AddStep("load screen", () => LoadScreen(spectatorScreen = new Spectator(testSpectatorStreamingClient.StreamingUser)));
+ AddStep("load screen", () => LoadScreen(spectatorScreen = new SoloSpectator(testSpectatorStreamingClient.StreamingUser)));
AddUntilStep("wait for screen load", () => spectatorScreen.LoadState == LoadState.Loaded);
}
@@ -301,5 +307,14 @@ namespace osu.Game.Tests.Visual.Gameplay
});
}
}
+
+ internal class TestUserLookupCache : UserLookupCache
+ {
+ protected override Task ComputeValueAsync(int lookup, CancellationToken token = default) => Task.FromResult(new User
+ {
+ Id = lookup,
+ Username = $"User {lookup}"
+ });
+ }
}
}
diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs
index f2bb518b2e..3e25e22b5f 100644
--- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs
+++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs
@@ -43,6 +43,29 @@ namespace osu.Game.Tests.Visual.Navigation
exitViaEscapeAndConfirm();
}
+ [Test]
+ public void TestRetryCountIncrements()
+ {
+ Player player = null;
+
+ PushAndConfirm(() => new TestSongSelect());
+
+ AddStep("import beatmap", () => ImportBeatmapTest.LoadQuickOszIntoOsu(Game).Wait());
+
+ AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault);
+
+ AddStep("press enter", () => InputManager.Key(Key.Enter));
+
+ AddUntilStep("wait for player", () => (player = Game.ScreenStack.CurrentScreen as Player) != null);
+ AddAssert("retry count is 0", () => player.RestartCount == 0);
+
+ AddStep("attempt to retry", () => player.ChildrenOfType().First().Action());
+ AddUntilStep("wait for old player gone", () => Game.ScreenStack.CurrentScreen != player);
+
+ AddUntilStep("get new player", () => (player = Game.ScreenStack.CurrentScreen as Player) != null);
+ AddAssert("retry count is 1", () => player.RestartCount == 1);
+ }
+
[Test]
public void TestRetryFromResults()
{
diff --git a/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs b/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs
index a7f6c8c0d3..a62980addf 100644
--- a/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs
+++ b/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs
@@ -45,6 +45,8 @@ namespace osu.Game.Tests.Visual.Settings
public Bindable AreaOffset { get; } = new Bindable();
public Bindable AreaSize { get; } = new Bindable();
+ public Bindable Rotation { get; } = new Bindable();
+
public IBindable Tablet => tablet;
private readonly Bindable tablet = new Bindable();
diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj
index e36b3cdc74..0e1f6f6b0c 100644
--- a/osu.Game.Tests/osu.Game.Tests.csproj
+++ b/osu.Game.Tests/osu.Game.Tests.csproj
@@ -3,7 +3,7 @@
-
+
diff --git a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj
index b20583dd7e..a4e52f8cd4 100644
--- a/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj
+++ b/osu.Game.Tournament.Tests/osu.Game.Tournament.Tests.csproj
@@ -5,7 +5,7 @@
-
+
diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs
index a898e10e4f..bf7906bd5c 100644
--- a/osu.Game/Beatmaps/BeatmapInfo.cs
+++ b/osu.Game/Beatmaps/BeatmapInfo.cs
@@ -147,7 +147,7 @@ namespace osu.Game.Beatmaps
{
string version = string.IsNullOrEmpty(Version) ? string.Empty : $"[{Version}]";
- return $"{Metadata} {version}".Trim();
+ return $"{Metadata ?? BeatmapSet?.Metadata} {version}".Trim();
}
public bool Equals(BeatmapInfo other)
diff --git a/osu.Game/Beatmaps/BeatmapMetadata.cs b/osu.Game/Beatmaps/BeatmapMetadata.cs
index 367f612dc8..858da8e602 100644
--- a/osu.Game/Beatmaps/BeatmapMetadata.cs
+++ b/osu.Game/Beatmaps/BeatmapMetadata.cs
@@ -19,8 +19,13 @@ namespace osu.Game.Beatmaps
public int ID { get; set; }
public string Title { get; set; }
+
+ [JsonProperty("title_unicode")]
public string TitleUnicode { get; set; }
+
public string Artist { get; set; }
+
+ [JsonProperty("artist_unicode")]
public string ArtistUnicode { get; set; }
[JsonIgnore]
diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs
index 5608002513..da1bbd18c7 100644
--- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs
+++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs
@@ -61,6 +61,9 @@ namespace osu.Game.Online.Leaderboards
[Resolved(CanBeNull = true)]
private SongSelect songSelect { get; set; }
+ [Resolved]
+ private ScoreManager scoreManager { get; set; }
+
public LeaderboardScore(ScoreInfo score, int? rank, bool allowHighlight = true)
{
this.score = score;
@@ -388,6 +391,9 @@ namespace osu.Game.Online.Leaderboards
if (score.Mods.Length > 0 && modsContainer.Any(s => s.IsHovered) && songSelect != null)
items.Add(new OsuMenuItem("Use these mods", MenuItemType.Highlighted, () => songSelect.Mods.Value = score.Mods));
+ if (score.Files.Count > 0)
+ items.Add(new OsuMenuItem("Export", MenuItemType.Standard, () => scoreManager.Export(score)));
+
if (score.ID != 0)
items.Add(new OsuMenuItem("Delete", MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(score))));
diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs
index bff3e15bfb..b2bbd0b48b 100644
--- a/osu.Game/OsuGameBase.cs
+++ b/osu.Game/OsuGameBase.cs
@@ -429,6 +429,9 @@ namespace osu.Game
public async Task Import(params string[] paths)
{
+ if (paths.Length == 0)
+ return;
+
var extension = Path.GetExtension(paths.First())?.ToLowerInvariant();
foreach (var importer in fileImporters)
diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs
index 153aa41582..a61640a02e 100644
--- a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs
+++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs
@@ -8,6 +8,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
+using osu.Framework.Localisation;
using osu.Game.Beatmaps.Drawables;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
@@ -228,8 +229,8 @@ namespace osu.Game.Overlays.BeatmapSet
loading.Hide();
- title.Text = setInfo.NewValue.Metadata.Title ?? string.Empty;
- artist.Text = setInfo.NewValue.Metadata.Artist ?? string.Empty;
+ title.Text = new RomanisableString(setInfo.NewValue.Metadata.TitleUnicode, setInfo.NewValue.Metadata.Title);
+ artist.Text = new RomanisableString(setInfo.NewValue.Metadata.ArtistUnicode, setInfo.NewValue.Metadata.Artist);
explicitContentPill.Alpha = setInfo.NewValue.OnlineInfo.HasExplicitContent ? 1 : 0;
diff --git a/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs b/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs
index c89699f2ee..336430fd9b 100644
--- a/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs
+++ b/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs
@@ -137,7 +137,7 @@ namespace osu.Game.Overlays.Dashboard
Text = "Watch",
Anchor = Anchor.TopCentre,
Origin = Anchor.TopCentre,
- Action = () => game?.PerformFromScreen(s => s.Push(new Spectator(User))),
+ Action = () => game?.PerformFromScreen(s => s.Push(new SoloSpectator(User))),
Enabled = { Value = User.Id != api.LocalUser.Value.Id }
}
}
diff --git a/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs b/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs
new file mode 100644
index 0000000000..3e8da9f7d0
--- /dev/null
+++ b/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs
@@ -0,0 +1,109 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Input.Handlers.Tablet;
+using osu.Game.Graphics;
+using osu.Game.Graphics.UserInterface;
+
+namespace osu.Game.Overlays.Settings.Sections.Input
+{
+ internal class RotationPresetButtons : FillFlowContainer
+ {
+ private readonly ITabletHandler tabletHandler;
+
+ private Bindable rotation;
+
+ private const int height = 50;
+
+ public RotationPresetButtons(ITabletHandler tabletHandler)
+ {
+ this.tabletHandler = tabletHandler;
+
+ RelativeSizeAxes = Axes.X;
+ Height = height;
+
+ for (int i = 0; i < 360; i += 90)
+ {
+ var presetRotation = i;
+
+ Add(new RotationButton(i)
+ {
+ RelativeSizeAxes = Axes.X,
+ Height = height,
+ Width = 0.25f,
+ Text = $"{presetRotation}ยบ",
+ Action = () => tabletHandler.Rotation.Value = presetRotation,
+ });
+ }
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ rotation = tabletHandler.Rotation.GetBoundCopy();
+ rotation.BindValueChanged(val =>
+ {
+ foreach (var b in Children.OfType())
+ b.IsSelected = b.Preset == val.NewValue;
+ }, true);
+ }
+
+ public class RotationButton : TriangleButton
+ {
+ [Resolved]
+ private OsuColour colours { get; set; }
+
+ public readonly int Preset;
+
+ public RotationButton(int preset)
+ {
+ Preset = preset;
+ }
+
+ private bool isSelected;
+
+ public bool IsSelected
+ {
+ get => isSelected;
+ set
+ {
+ if (value == isSelected)
+ return;
+
+ isSelected = value;
+
+ if (IsLoaded)
+ updateColour();
+ }
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+ updateColour();
+ }
+
+ private void updateColour()
+ {
+ if (isSelected)
+ {
+ BackgroundColour = colours.BlueDark;
+ Triangles.ColourDark = colours.BlueDarker;
+ Triangles.ColourLight = colours.Blue;
+ }
+ else
+ {
+ BackgroundColour = colours.Gray4;
+ Triangles.ColourDark = colours.Gray5;
+ Triangles.ColourLight = colours.Gray6;
+ }
+ }
+ }
+ }
+}
diff --git a/osu.Game/Overlays/Settings/Sections/Input/TabletAreaSelection.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletAreaSelection.cs
index ecb8acce54..f61742093c 100644
--- a/osu.Game/Overlays/Settings/Sections/Input/TabletAreaSelection.cs
+++ b/osu.Game/Overlays/Settings/Sections/Input/TabletAreaSelection.cs
@@ -25,6 +25,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input
private readonly Bindable areaOffset = new Bindable();
private readonly Bindable areaSize = new Bindable();
+ private readonly BindableNumber rotation = new BindableNumber();
+
private readonly IBindable tablet = new Bindable();
private OsuSpriteText tabletName;
@@ -124,6 +126,13 @@ namespace osu.Game.Overlays.Settings.Sections.Input
usableAreaText.Text = $"{(float)x / commonDivider}:{(float)y / commonDivider}";
}, true);
+ rotation.BindTo(handler.Rotation);
+ rotation.BindValueChanged(val =>
+ {
+ usableAreaContainer.RotateTo(val.NewValue, 100, Easing.OutQuint)
+ .OnComplete(_ => checkBounds()); // required as we are using SSDQ.
+ });
+
tablet.BindTo(handler.Tablet);
tablet.BindValueChanged(_ => Scheduler.AddOnce(updateTabletDetails));
diff --git a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs
index bd0f7ddc4c..d770c18878 100644
--- a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs
+++ b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs
@@ -27,6 +27,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input
private readonly BindableNumber sizeX = new BindableNumber { MinValue = 10 };
private readonly BindableNumber sizeY = new BindableNumber { MinValue = 10 };
+ private readonly BindableNumber rotation = new BindableNumber { MinValue = 0, MaxValue = 360 };
+
[Resolved]
private GameHost host { get; set; }
@@ -110,12 +112,6 @@ namespace osu.Game.Overlays.Settings.Sections.Input
}
},
new SettingsSlider
- {
- TransferValueOnCommit = true,
- LabelText = "Aspect Ratio",
- Current = aspectRatio
- },
- new SettingsSlider
{
TransferValueOnCommit = true,
LabelText = "X Offset",
@@ -127,6 +123,19 @@ namespace osu.Game.Overlays.Settings.Sections.Input
LabelText = "Y Offset",
Current = offsetY
},
+ new SettingsSlider
+ {
+ TransferValueOnCommit = true,
+ LabelText = "Rotation",
+ Current = rotation
+ },
+ new RotationPresetButtons(tabletHandler),
+ new SettingsSlider
+ {
+ TransferValueOnCommit = true,
+ LabelText = "Aspect Ratio",
+ Current = aspectRatio
+ },
new SettingsCheckbox
{
LabelText = "Lock aspect ratio",
@@ -153,6 +162,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input
{
base.LoadComplete();
+ rotation.BindTo(tabletHandler.Rotation);
+
areaOffset.BindTo(tabletHandler.AreaOffset);
areaOffset.BindValueChanged(val =>
{
diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs
index a25dc3e6db..9d06f960b7 100644
--- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs
+++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs
@@ -83,7 +83,7 @@ namespace osu.Game.Rulesets.Difficulty
foreach (Skill s in skills)
{
s.SaveCurrentPeak();
- s.StartNewSectionFrom(currentSectionEnd);
+ s.StartNewSectionFrom(currentSectionEnd / clockRate);
}
currentSectionEnd += sectionLength;
diff --git a/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs b/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs
index ebbffb5143..576fbb2af0 100644
--- a/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs
+++ b/osu.Game/Rulesets/Difficulty/Preprocessing/DifficultyHitObject.cs
@@ -25,6 +25,16 @@ namespace osu.Game.Rulesets.Difficulty.Preprocessing
///
public readonly double DeltaTime;
+ ///
+ /// Clockrate adjusted start time of .
+ ///
+ public readonly double StartTime;
+
+ ///
+ /// Clockrate adjusted end time of .
+ ///
+ public readonly double EndTime;
+
///
/// Creates a new .
///
@@ -36,6 +46,8 @@ namespace osu.Game.Rulesets.Difficulty.Preprocessing
BaseObject = hitObject;
LastObject = lastObject;
DeltaTime = (hitObject.StartTime - lastObject.StartTime) / clockRate;
+ StartTime = hitObject.StartTime / clockRate;
+ EndTime = hitObject.GetEndTime() / clockRate;
}
}
}
diff --git a/osu.Game/Rulesets/Difficulty/Skills/Skill.cs b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs
index 95117be073..126e30ed73 100644
--- a/osu.Game/Rulesets/Difficulty/Skills/Skill.cs
+++ b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs
@@ -88,7 +88,7 @@ namespace osu.Game.Rulesets.Difficulty.Skills
///
/// Sets the initial strain level for a new section.
///
- /// The beginning of the new section in milliseconds.
+ /// The beginning of the new section in milliseconds, adjusted by clockrate.
public void StartNewSectionFrom(double time)
{
// The maximum strain of the new section is not zero by default, strain decays as usual regardless of section boundaries.
@@ -100,9 +100,9 @@ namespace osu.Game.Rulesets.Difficulty.Skills
///
/// Retrieves the peak strain at a point in time.
///
- /// The time to retrieve the peak strain at.
+ /// The time to retrieve the peak strain at, adjusted by clockrate.
/// The peak strain.
- protected virtual double GetPeakStrain(double time) => CurrentStrain * strainDecay(time - Previous[0].BaseObject.StartTime);
+ protected virtual double GetPeakStrain(double time) => CurrentStrain * strainDecay(time - Previous[0].StartTime);
///
/// Returns the calculated difficulty value representing all processed s.
diff --git a/osu.Game/Rulesets/RulesetStore.cs b/osu.Game/Rulesets/RulesetStore.cs
index deabea57ef..4261ee3d47 100644
--- a/osu.Game/Rulesets/RulesetStore.cs
+++ b/osu.Game/Rulesets/RulesetStore.cs
@@ -173,7 +173,7 @@ namespace osu.Game.Rulesets
{
var filename = Path.GetFileNameWithoutExtension(file);
- if (loadedAssemblies.Values.Any(t => t.Namespace == filename))
+ if (loadedAssemblies.Values.Any(t => Path.GetFileNameWithoutExtension(t.Assembly.Location) == filename))
return;
try
diff --git a/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs b/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs
index 6e2737256a..70876bf26c 100644
--- a/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs
+++ b/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs
@@ -54,7 +54,7 @@ namespace osu.Game.Screens.Edit.Setup
{
FileSelector fileSelector;
- Target.Child = fileSelector = new FileSelector(validFileExtensions: ResourcesSection.AudioExtensions)
+ Target.Child = fileSelector = new FileSelector(currentFile.Value?.DirectoryName, ResourcesSection.AudioExtensions)
{
RelativeSizeAxes = Axes.X,
Height = 400,
diff --git a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs
index 1b841775e2..e00eaf9aa2 100644
--- a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs
+++ b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs
@@ -73,7 +73,8 @@ namespace osu.Game.Screens.Edit.Setup
audioTrackTextBox = new FileChooserLabelledTextBox
{
Label = "Audio Track",
- Current = { Value = working.Value.Metadata.AudioFile ?? "Click to select a track" },
+ PlaceholderText = "Click to select a track",
+ Current = { Value = working.Value.Metadata.AudioFile },
Target = audioTrackFileChooserContainer,
TabbableContentContainer = this
},
diff --git a/osu.Game/Screens/Edit/Timing/DifficultySection.cs b/osu.Game/Screens/Edit/Timing/DifficultySection.cs
index 9d80ca0b14..97d110c502 100644
--- a/osu.Game/Screens/Edit/Timing/DifficultySection.cs
+++ b/osu.Game/Screens/Edit/Timing/DifficultySection.cs
@@ -28,7 +28,16 @@ namespace osu.Game.Screens.Edit.Timing
{
if (point.NewValue != null)
{
- multiplierSlider.Current = point.NewValue.SpeedMultiplierBindable;
+ var selectedPointBindable = point.NewValue.SpeedMultiplierBindable;
+
+ // there may be legacy control points, which contain infinite precision for compatibility reasons (see LegacyDifficultyControlPoint).
+ // generally that level of precision could only be set by externally editing the .osu file, so at the point
+ // a user is looking to update this within the editor it should be safe to obliterate this additional precision.
+ double expectedPrecision = new DifficultyControlPoint().SpeedMultiplierBindable.Precision;
+ if (selectedPointBindable.Precision < expectedPrecision)
+ selectedPointBindable.Precision = expectedPrecision;
+
+ multiplierSlider.Current = selectedPointBindable;
multiplierSlider.Current.BindValueChanged(_ => ChangeHandler?.SaveState());
}
}
diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs
index 7d906cdc5b..679b3c7313 100644
--- a/osu.Game/Screens/Play/PlayerLoader.cs
+++ b/osu.Game/Screens/Play/PlayerLoader.cs
@@ -309,10 +309,8 @@ namespace osu.Game.Screens.Play
if (!this.IsCurrentScreen())
return;
- var restartCount = player?.RestartCount + 1 ?? 0;
-
player = createPlayer();
- player.RestartCount = restartCount;
+ player.RestartCount = restartCount++;
player.RestartRequested = restartRequested;
LoadTask = LoadComponentAsync(player, _ => MetadataInfo.Loading = false);
@@ -428,6 +426,8 @@ namespace osu.Game.Screens.Play
private Bindable muteWarningShownOnce;
+ private int restartCount;
+
private void showMuteWarningIfNeeded()
{
if (!muteWarningShownOnce.Value)
diff --git a/osu.Game/Screens/Play/Spectator.cs b/osu.Game/Screens/Play/SoloSpectator.cs
similarity index 52%
rename from osu.Game/Screens/Play/Spectator.cs
rename to osu.Game/Screens/Play/SoloSpectator.cs
index 28311f5113..820d776e63 100644
--- a/osu.Game/Screens/Play/Spectator.cs
+++ b/osu.Game/Screens/Play/SoloSpectator.cs
@@ -1,18 +1,15 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-using System;
-using System.Collections.Generic;
using System.Diagnostics;
-using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
-using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Screens;
+using osu.Framework.Threading;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
@@ -24,73 +21,49 @@ using osu.Game.Online.API.Requests;
using osu.Game.Online.Spectator;
using osu.Game.Overlays.BeatmapListing.Panels;
using osu.Game.Overlays.Settings;
-using osu.Game.Replays;
using osu.Game.Rulesets;
-using osu.Game.Rulesets.Mods;
-using osu.Game.Rulesets.Replays;
-using osu.Game.Rulesets.Replays.Types;
-using osu.Game.Scoring;
using osu.Game.Screens.OnlinePlay.Match.Components;
+using osu.Game.Screens.Spectate;
using osu.Game.Users;
using osuTK;
namespace osu.Game.Screens.Play
{
[Cached(typeof(IPreviewTrackOwner))]
- public class Spectator : OsuScreen, IPreviewTrackOwner
+ public class SoloSpectator : SpectatorScreen, IPreviewTrackOwner
{
+ [NotNull]
private readonly User targetUser;
- [Resolved]
- private Bindable beatmap { get; set; }
-
- [Resolved]
- private Bindable ruleset { get; set; }
-
- private Ruleset rulesetInstance;
-
- [Resolved]
- private Bindable> mods { get; set; }
-
[Resolved]
private IAPIProvider api { get; set; }
[Resolved]
- private SpectatorStreamingClient spectatorStreaming { get; set; }
-
- [Resolved]
- private BeatmapManager beatmaps { get; set; }
+ private PreviewTrackManager previewTrackManager { get; set; }
[Resolved]
private RulesetStore rulesets { get; set; }
[Resolved]
- private PreviewTrackManager previewTrackManager { get; set; }
-
- private Score score;
-
- private readonly object scoreLock = new object();
+ private BeatmapManager beatmaps { get; set; }
private Container beatmapPanelContainer;
-
- private SpectatorState state;
-
- private IBindable> managerUpdated;
-
private TriangleButton watchButton;
-
private SettingsCheckbox automaticDownload;
-
private BeatmapSetInfo onlineBeatmap;
///
- /// Becomes true if a new state is waiting to be loaded (while this screen was not active).
+ /// The player's immediate online gameplay state.
+ /// This doesn't always reflect the gameplay state being watched.
///
- private bool newStatePending;
+ private GameplayState immediateGameplayState;
- public Spectator([NotNull] User targetUser)
+ private GetBeatmapSetRequest onlineBeatmapRequest;
+
+ public SoloSpectator([NotNull] User targetUser)
+ : base(targetUser.Id)
{
- this.targetUser = targetUser ?? throw new ArgumentNullException(nameof(targetUser));
+ this.targetUser = targetUser;
}
[BackgroundDependencyLoader]
@@ -173,7 +146,7 @@ namespace osu.Game.Screens.Play
Width = 250,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
- Action = attemptStart,
+ Action = () => scheduleStart(immediateGameplayState),
Enabled = { Value = false }
}
}
@@ -185,169 +158,76 @@ namespace osu.Game.Screens.Play
protected override void LoadComplete()
{
base.LoadComplete();
-
- spectatorStreaming.OnUserBeganPlaying += userBeganPlaying;
- spectatorStreaming.OnUserFinishedPlaying += userFinishedPlaying;
- spectatorStreaming.OnNewFrames += userSentFrames;
-
- spectatorStreaming.WatchUser(targetUser.Id);
-
- managerUpdated = beatmaps.ItemUpdated.GetBoundCopy();
- managerUpdated.BindValueChanged(beatmapUpdated);
-
automaticDownload.Current.BindValueChanged(_ => checkForAutomaticDownload());
}
- private void beatmapUpdated(ValueChangedEvent> beatmap)
+ protected override void OnUserStateChanged(int userId, SpectatorState spectatorState)
{
- if (beatmap.NewValue.TryGetTarget(out var beatmapSet) && beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID == state.BeatmapID))
- Schedule(attemptStart);
+ clearDisplay();
+ showBeatmapPanel(spectatorState);
}
- private void userSentFrames(int userId, FrameDataBundle data)
+ protected override void StartGameplay(int userId, GameplayState gameplayState)
{
- // this is not scheduled as it handles propagation of frames even when in a child screen (at which point we are not alive).
- // probably not the safest way to handle this.
+ immediateGameplayState = gameplayState;
+ watchButton.Enabled.Value = true;
- if (userId != targetUser.Id)
- return;
-
- lock (scoreLock)
- {
- // this should never happen as the server sends the user's state on watching,
- // but is here as a safety measure.
- if (score == null)
- return;
-
- // rulesetInstance should be guaranteed to be in sync with the score via scoreLock.
- Debug.Assert(rulesetInstance != null && rulesetInstance.RulesetInfo.Equals(score.ScoreInfo.Ruleset));
-
- foreach (var frame in data.Frames)
- {
- IConvertibleReplayFrame convertibleFrame = rulesetInstance.CreateConvertibleReplayFrame();
- convertibleFrame.FromLegacy(frame, beatmap.Value.Beatmap);
-
- var convertedFrame = (ReplayFrame)convertibleFrame;
- convertedFrame.Time = frame.Time;
-
- score.Replay.Frames.Add(convertedFrame);
- }
- }
+ scheduleStart(gameplayState);
}
- private void userBeganPlaying(int userId, SpectatorState state)
+ protected override void EndGameplay(int userId)
{
- if (userId != targetUser.Id)
- return;
+ scheduledStart?.Cancel();
+ immediateGameplayState = null;
+ watchButton.Enabled.Value = false;
- this.state = state;
-
- if (this.IsCurrentScreen())
- Schedule(attemptStart);
- else
- newStatePending = true;
- }
-
- public override void OnResuming(IScreen last)
- {
- base.OnResuming(last);
-
- if (newStatePending)
- {
- attemptStart();
- newStatePending = false;
- }
- }
-
- private void userFinishedPlaying(int userId, SpectatorState state)
- {
- if (userId != targetUser.Id)
- return;
-
- lock (scoreLock)
- {
- if (score != null)
- {
- score.Replay.HasReceivedAllFrames = true;
- score = null;
- }
- }
-
- Schedule(clearDisplay);
+ clearDisplay();
}
private void clearDisplay()
{
watchButton.Enabled.Value = false;
+ onlineBeatmapRequest?.Cancel();
beatmapPanelContainer.Clear();
previewTrackManager.StopAnyPlaying(this);
}
- private void attemptStart()
+ private ScheduledDelegate scheduledStart;
+
+ private void scheduleStart(GameplayState gameplayState)
{
- clearDisplay();
- showBeatmapPanel(state);
-
- var resolvedRuleset = rulesets.AvailableRulesets.FirstOrDefault(r => r.ID == state.RulesetID)?.CreateInstance();
-
- // ruleset not available
- if (resolvedRuleset == null)
- return;
-
- if (state.BeatmapID == null)
- return;
-
- var resolvedBeatmap = beatmaps.QueryBeatmap(b => b.OnlineBeatmapID == state.BeatmapID);
-
- if (resolvedBeatmap == null)
+ // This function may be called multiple times in quick succession once the screen becomes current again.
+ scheduledStart?.Cancel();
+ scheduledStart = Schedule(() =>
{
- return;
- }
+ if (this.IsCurrentScreen())
+ start();
+ else
+ scheduleStart(gameplayState);
+ });
- lock (scoreLock)
+ void start()
{
- score = new Score
- {
- ScoreInfo = new ScoreInfo
- {
- Beatmap = resolvedBeatmap,
- User = targetUser,
- Mods = state.Mods.Select(m => m.ToMod(resolvedRuleset)).ToArray(),
- Ruleset = resolvedRuleset.RulesetInfo,
- },
- Replay = new Replay { HasReceivedAllFrames = false },
- };
+ Beatmap.Value = gameplayState.Beatmap;
+ Ruleset.Value = gameplayState.Ruleset.RulesetInfo;
- ruleset.Value = resolvedRuleset.RulesetInfo;
- rulesetInstance = resolvedRuleset;
-
- beatmap.Value = beatmaps.GetWorkingBeatmap(resolvedBeatmap);
- watchButton.Enabled.Value = true;
-
- this.Push(new SpectatorPlayerLoader(score));
+ this.Push(new SpectatorPlayerLoader(gameplayState.Score));
}
}
private void showBeatmapPanel(SpectatorState state)
{
- if (state?.BeatmapID == null)
- {
- onlineBeatmap = null;
- return;
- }
+ Debug.Assert(state.BeatmapID != null);
- var req = new GetBeatmapSetRequest(state.BeatmapID.Value, BeatmapSetLookupType.BeatmapId);
- req.Success += res => Schedule(() =>
+ onlineBeatmapRequest = new GetBeatmapSetRequest(state.BeatmapID.Value, BeatmapSetLookupType.BeatmapId);
+ onlineBeatmapRequest.Success += res => Schedule(() =>
{
- if (state != this.state)
- return;
-
onlineBeatmap = res.ToBeatmapSet(rulesets);
beatmapPanelContainer.Child = new GridBeatmapPanel(onlineBeatmap);
checkForAutomaticDownload();
});
- api.Queue(req);
+ api.Queue(onlineBeatmapRequest);
}
private void checkForAutomaticDownload()
@@ -369,21 +249,5 @@ namespace osu.Game.Screens.Play
previewTrackManager.StopAnyPlaying(this);
return base.OnExiting(next);
}
-
- protected override void Dispose(bool isDisposing)
- {
- base.Dispose(isDisposing);
-
- if (spectatorStreaming != null)
- {
- spectatorStreaming.OnUserBeganPlaying -= userBeganPlaying;
- spectatorStreaming.OnUserFinishedPlaying -= userFinishedPlaying;
- spectatorStreaming.OnNewFrames -= userSentFrames;
-
- spectatorStreaming.StopWatchingUser(targetUser.Id);
- }
-
- managerUpdated?.UnbindAll();
- }
}
}
diff --git a/osu.Game/Screens/Spectate/GameplayState.cs b/osu.Game/Screens/Spectate/GameplayState.cs
new file mode 100644
index 0000000000..4579b9c07c
--- /dev/null
+++ b/osu.Game/Screens/Spectate/GameplayState.cs
@@ -0,0 +1,37 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets;
+using osu.Game.Scoring;
+
+namespace osu.Game.Screens.Spectate
+{
+ ///
+ /// The gameplay state of a spectated user. This class is immutable.
+ ///
+ public class GameplayState
+ {
+ ///
+ /// The score which the user is playing.
+ ///
+ public readonly Score Score;
+
+ ///
+ /// The ruleset which the user is playing.
+ ///
+ public readonly Ruleset Ruleset;
+
+ ///
+ /// The beatmap which the user is playing.
+ ///
+ public readonly WorkingBeatmap Beatmap;
+
+ public GameplayState(Score score, Ruleset ruleset, WorkingBeatmap beatmap)
+ {
+ Score = score;
+ Ruleset = ruleset;
+ Beatmap = beatmap;
+ }
+ }
+}
diff --git a/osu.Game/Screens/Spectate/SpectatorScreen.cs b/osu.Game/Screens/Spectate/SpectatorScreen.cs
new file mode 100644
index 0000000000..6dd3144fc8
--- /dev/null
+++ b/osu.Game/Screens/Spectate/SpectatorScreen.cs
@@ -0,0 +1,238 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.Linq;
+using System.Threading.Tasks;
+using JetBrains.Annotations;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Game.Beatmaps;
+using osu.Game.Database;
+using osu.Game.Online.Spectator;
+using osu.Game.Replays;
+using osu.Game.Rulesets;
+using osu.Game.Rulesets.Replays;
+using osu.Game.Rulesets.Replays.Types;
+using osu.Game.Scoring;
+using osu.Game.Users;
+
+namespace osu.Game.Screens.Spectate
+{
+ ///
+ /// A which spectates one or more users.
+ ///
+ public abstract class SpectatorScreen : OsuScreen
+ {
+ private readonly int[] userIds;
+
+ [Resolved]
+ private BeatmapManager beatmaps { get; set; }
+
+ [Resolved]
+ private RulesetStore rulesets { get; set; }
+
+ [Resolved]
+ private SpectatorStreamingClient spectatorClient { get; set; }
+
+ [Resolved]
+ private UserLookupCache userLookupCache { get; set; }
+
+ // A lock is used to synchronise access to spectator/gameplay states, since this class is a screen which may become non-current and stop receiving updates at any point.
+ private readonly object stateLock = new object();
+
+ private readonly Dictionary userMap = new Dictionary();
+ private readonly Dictionary spectatorStates = new Dictionary();
+ private readonly Dictionary gameplayStates = new Dictionary();
+
+ private IBindable> managerUpdated;
+
+ ///
+ /// Creates a new .
+ ///
+ /// The users to spectate.
+ protected SpectatorScreen(params int[] userIds)
+ {
+ this.userIds = userIds;
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ spectatorClient.OnUserBeganPlaying += userBeganPlaying;
+ spectatorClient.OnUserFinishedPlaying += userFinishedPlaying;
+ spectatorClient.OnNewFrames += userSentFrames;
+
+ foreach (var id in userIds)
+ {
+ userLookupCache.GetUserAsync(id).ContinueWith(u => Schedule(() =>
+ {
+ if (u.Result == null)
+ return;
+
+ lock (stateLock)
+ userMap[id] = u.Result;
+
+ spectatorClient.WatchUser(id);
+ }), TaskContinuationOptions.OnlyOnRanToCompletion);
+ }
+
+ managerUpdated = beatmaps.ItemUpdated.GetBoundCopy();
+ managerUpdated.BindValueChanged(beatmapUpdated);
+ }
+
+ private void beatmapUpdated(ValueChangedEvent> e)
+ {
+ if (!e.NewValue.TryGetTarget(out var beatmapSet))
+ return;
+
+ lock (stateLock)
+ {
+ foreach (var (userId, state) in spectatorStates)
+ {
+ if (beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID == state.BeatmapID))
+ updateGameplayState(userId);
+ }
+ }
+ }
+
+ private void userBeganPlaying(int userId, SpectatorState state)
+ {
+ if (state.RulesetID == null || state.BeatmapID == null)
+ return;
+
+ lock (stateLock)
+ {
+ if (!userMap.ContainsKey(userId))
+ return;
+
+ spectatorStates[userId] = state;
+ Schedule(() => OnUserStateChanged(userId, state));
+
+ updateGameplayState(userId);
+ }
+ }
+
+ private void updateGameplayState(int userId)
+ {
+ lock (stateLock)
+ {
+ Debug.Assert(userMap.ContainsKey(userId));
+
+ var spectatorState = spectatorStates[userId];
+ var user = userMap[userId];
+
+ var resolvedRuleset = rulesets.AvailableRulesets.FirstOrDefault(r => r.ID == spectatorState.RulesetID)?.CreateInstance();
+ if (resolvedRuleset == null)
+ return;
+
+ var resolvedBeatmap = beatmaps.QueryBeatmap(b => b.OnlineBeatmapID == spectatorState.BeatmapID);
+ if (resolvedBeatmap == null)
+ return;
+
+ var score = new Score
+ {
+ ScoreInfo = new ScoreInfo
+ {
+ Beatmap = resolvedBeatmap,
+ User = user,
+ Mods = spectatorState.Mods.Select(m => m.ToMod(resolvedRuleset)).ToArray(),
+ Ruleset = resolvedRuleset.RulesetInfo,
+ },
+ Replay = new Replay { HasReceivedAllFrames = false },
+ };
+
+ var gameplayState = new GameplayState(score, resolvedRuleset, beatmaps.GetWorkingBeatmap(resolvedBeatmap));
+
+ gameplayStates[userId] = gameplayState;
+ Schedule(() => StartGameplay(userId, gameplayState));
+ }
+ }
+
+ private void userSentFrames(int userId, FrameDataBundle bundle)
+ {
+ lock (stateLock)
+ {
+ if (!userMap.ContainsKey(userId))
+ return;
+
+ if (!gameplayStates.TryGetValue(userId, out var gameplayState))
+ return;
+
+ // The ruleset instance should be guaranteed to be in sync with the score via ScoreLock.
+ Debug.Assert(gameplayState.Ruleset != null && gameplayState.Ruleset.RulesetInfo.Equals(gameplayState.Score.ScoreInfo.Ruleset));
+
+ foreach (var frame in bundle.Frames)
+ {
+ IConvertibleReplayFrame convertibleFrame = gameplayState.Ruleset.CreateConvertibleReplayFrame();
+ convertibleFrame.FromLegacy(frame, gameplayState.Beatmap.Beatmap);
+
+ var convertedFrame = (ReplayFrame)convertibleFrame;
+ convertedFrame.Time = frame.Time;
+
+ gameplayState.Score.Replay.Frames.Add(convertedFrame);
+ }
+ }
+ }
+
+ private void userFinishedPlaying(int userId, SpectatorState state)
+ {
+ lock (stateLock)
+ {
+ if (!userMap.ContainsKey(userId))
+ return;
+
+ if (!gameplayStates.TryGetValue(userId, out var gameplayState))
+ return;
+
+ gameplayState.Score.Replay.HasReceivedAllFrames = true;
+
+ gameplayStates.Remove(userId);
+ Schedule(() => EndGameplay(userId));
+ }
+ }
+
+ ///
+ /// Invoked when a spectated user's state has changed.
+ ///
+ /// The user whose state has changed.
+ /// The new state.
+ protected abstract void OnUserStateChanged(int userId, [NotNull] SpectatorState spectatorState);
+
+ ///
+ /// Starts gameplay for a user.
+ ///
+ /// The user to start gameplay for.
+ /// The gameplay state.
+ protected abstract void StartGameplay(int userId, [NotNull] GameplayState gameplayState);
+
+ ///
+ /// Ends gameplay for a user.
+ ///
+ /// The user to end gameplay for.
+ protected abstract void EndGameplay(int userId);
+
+ protected override void Dispose(bool isDisposing)
+ {
+ base.Dispose(isDisposing);
+
+ if (spectatorClient != null)
+ {
+ spectatorClient.OnUserBeganPlaying -= userBeganPlaying;
+ spectatorClient.OnUserFinishedPlaying -= userFinishedPlaying;
+ spectatorClient.OnNewFrames -= userSentFrames;
+
+ lock (stateLock)
+ {
+ foreach (var (userId, _) in userMap)
+ spectatorClient.StopWatchingUser(userId);
+ }
+ }
+
+ managerUpdated?.UnbindAll();
+ }
+ }
+}
diff --git a/osu.Game/Users/UserStatistics.cs b/osu.Game/Users/UserStatistics.cs
index 04a358436e..5ddcd86d28 100644
--- a/osu.Game/Users/UserStatistics.cs
+++ b/osu.Game/Users/UserStatistics.cs
@@ -45,7 +45,7 @@ namespace osu.Game.Users
public double Accuracy;
[JsonIgnore]
- public string DisplayAccuracy => Accuracy.FormatAccuracy();
+ public string DisplayAccuracy => (Accuracy / 100).FormatAccuracy();
[JsonProperty(@"play_count")]
public int PlayCount;
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index 6d571218fc..931b55222a 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -29,7 +29,7 @@
-
+
diff --git a/osu.iOS.props b/osu.iOS.props
index ceb46eae87..64e9a01a92 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -70,7 +70,7 @@
-
+
@@ -93,7 +93,7 @@
-
+