diff --git a/osu.Android.props b/osu.Android.props
index 62397ca028..a2686c380e 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -52,6 +52,6 @@
-
+
diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs
index 75f5b18607..fa9011d826 100644
--- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs
+++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs
@@ -8,6 +8,5 @@ namespace osu.Game.Rulesets.Catch.Difficulty
public class CatchDifficultyAttributes : DifficultyAttributes
{
public double ApproachRate;
- public int MaxCombo;
}
}
diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs
index 37cba1fd3c..b08c520c54 100644
--- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs
+++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs
@@ -11,6 +11,7 @@ using osu.Game.Rulesets.Mania.Beatmaps;
using osu.Game.Rulesets.Mania.Difficulty.Preprocessing;
using osu.Game.Rulesets.Mania.Difficulty.Skills;
using osu.Game.Rulesets.Mania.Mods;
+using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Scoring;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
@@ -43,6 +44,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty
Mods = mods,
// Todo: This int cast is temporary to achieve 1:1 results with osu!stable, and should be removed in the future
GreatHitWindow = (int)(hitWindows.WindowFor(HitResult.Great)) / clockRate,
+ MaxCombo = beatmap.HitObjects.Sum(h => h is HoldNote ? 2 : 1),
Skills = skills
};
}
diff --git a/osu.Game.Rulesets.Mania/Edit/HoldNoteCompositionTool.cs b/osu.Game.Rulesets.Mania/Edit/HoldNoteCompositionTool.cs
index 295bf417c4..a5f10ed436 100644
--- a/osu.Game.Rulesets.Mania/Edit/HoldNoteCompositionTool.cs
+++ b/osu.Game.Rulesets.Mania/Edit/HoldNoteCompositionTool.cs
@@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using osu.Framework.Graphics;
+using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Mania.Edit.Blueprints;
@@ -14,6 +16,8 @@ namespace osu.Game.Rulesets.Mania.Edit
{
}
+ public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders);
+
public override PlacementBlueprint CreatePlacementBlueprint() => new HoldNotePlacementBlueprint();
}
}
diff --git a/osu.Game.Rulesets.Mania/Edit/NoteCompositionTool.cs b/osu.Game.Rulesets.Mania/Edit/NoteCompositionTool.cs
index 50b5f9a8fe..9f54152596 100644
--- a/osu.Game.Rulesets.Mania/Edit/NoteCompositionTool.cs
+++ b/osu.Game.Rulesets.Mania/Edit/NoteCompositionTool.cs
@@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using osu.Framework.Graphics;
+using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Mania.Edit.Blueprints;
@@ -15,6 +17,8 @@ namespace osu.Game.Rulesets.Mania.Edit
{
}
+ public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles);
+
public override PlacementBlueprint CreatePlacementBlueprint() => new NotePlacementBlueprint();
}
}
diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs
index 6e991a1d08..a9879013f8 100644
--- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs
+++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs
@@ -11,6 +11,5 @@ namespace osu.Game.Rulesets.Osu.Difficulty
public double SpeedStrain;
public double ApproachRate;
public double OverallDifficulty;
- public int MaxCombo;
}
}
diff --git a/osu.Game.Rulesets.Osu/Edit/HitCircleCompositionTool.cs b/osu.Game.Rulesets.Osu/Edit/HitCircleCompositionTool.cs
index 9c94fe0e3d..5f7c8b77b0 100644
--- a/osu.Game.Rulesets.Osu/Edit/HitCircleCompositionTool.cs
+++ b/osu.Game.Rulesets.Osu/Edit/HitCircleCompositionTool.cs
@@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using osu.Framework.Graphics;
+using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles;
@@ -15,6 +17,8 @@ namespace osu.Game.Rulesets.Osu.Edit
{
}
+ public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles);
+
public override PlacementBlueprint CreatePlacementBlueprint() => new HitCirclePlacementBlueprint();
}
}
diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs
index 37019a7a05..f87bd53ec3 100644
--- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs
+++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs
@@ -5,6 +5,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
+using osu.Framework.Bindables;
using osu.Framework.Caching;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@@ -38,6 +39,13 @@ namespace osu.Game.Rulesets.Osu.Edit
new SpinnerCompositionTool()
};
+ private readonly BindableBool distanceSnapToggle = new BindableBool(true) { Description = "Distance Snap" };
+
+ protected override IEnumerable Toggles => new[]
+ {
+ distanceSnapToggle
+ };
+
[BackgroundDependencyLoader]
private void load()
{
@@ -45,6 +53,7 @@ namespace osu.Game.Rulesets.Osu.Edit
EditorBeatmap.SelectedHitObjects.CollectionChanged += (_, __) => updateDistanceSnapGrid();
EditorBeatmap.PlacementObject.ValueChanged += _ => updateDistanceSnapGrid();
+ distanceSnapToggle.ValueChanged += _ => updateDistanceSnapGrid();
}
protected override ComposeBlueprintContainer CreateBlueprintContainer(IEnumerable hitObjects)
@@ -87,6 +96,10 @@ namespace osu.Game.Rulesets.Osu.Edit
{
distanceSnapGridContainer.Clear();
distanceSnapGridCache.Invalidate();
+ distanceSnapGrid = null;
+
+ if (!distanceSnapToggle.Value)
+ return;
switch (BlueprintContainer.CurrentTool)
{
diff --git a/osu.Game.Rulesets.Osu/Edit/SliderCompositionTool.cs b/osu.Game.Rulesets.Osu/Edit/SliderCompositionTool.cs
index a377deb35f..596224e5c6 100644
--- a/osu.Game.Rulesets.Osu/Edit/SliderCompositionTool.cs
+++ b/osu.Game.Rulesets.Osu/Edit/SliderCompositionTool.cs
@@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using osu.Framework.Graphics;
+using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders;
@@ -15,6 +17,8 @@ namespace osu.Game.Rulesets.Osu.Edit
{
}
+ public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders);
+
public override PlacementBlueprint CreatePlacementBlueprint() => new SliderPlacementBlueprint();
}
}
diff --git a/osu.Game.Rulesets.Osu/Edit/SpinnerCompositionTool.cs b/osu.Game.Rulesets.Osu/Edit/SpinnerCompositionTool.cs
index 0de0af8f8c..c5e90da3bd 100644
--- a/osu.Game.Rulesets.Osu/Edit/SpinnerCompositionTool.cs
+++ b/osu.Game.Rulesets.Osu/Edit/SpinnerCompositionTool.cs
@@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using osu.Framework.Graphics;
+using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners;
@@ -15,6 +17,8 @@ namespace osu.Game.Rulesets.Osu.Edit
{
}
+ public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners);
+
public override PlacementBlueprint CreatePlacementBlueprint() => new SpinnerPlacementBlueprint();
}
}
diff --git a/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs b/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs
index 20adbc1c02..88c855d768 100644
--- a/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs
+++ b/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs
@@ -192,7 +192,7 @@ namespace osu.Game.Rulesets.Osu.Statistics
protected void AddPoint(Vector2 start, Vector2 end, Vector2 hitPoint, float radius)
{
- if (pointGrid.Content.Length == 0)
+ if (pointGrid.Content.Count == 0)
return;
double angle1 = Math.Atan2(end.Y - hitPoint.Y, hitPoint.X - end.X); // Angle between the end point and the hit point.
diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs
index 75d3807bba..00ad956c8f 100644
--- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs
+++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs
@@ -8,6 +8,5 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
public class TaikoDifficultyAttributes : DifficultyAttributes
{
public double GreatHitWindow;
- public int MaxCombo;
}
}
diff --git a/osu.Game.Rulesets.Taiko/Edit/DrumRollCompositionTool.cs b/osu.Game.Rulesets.Taiko/Edit/DrumRollCompositionTool.cs
index bf77c76670..587a4efecb 100644
--- a/osu.Game.Rulesets.Taiko/Edit/DrumRollCompositionTool.cs
+++ b/osu.Game.Rulesets.Taiko/Edit/DrumRollCompositionTool.cs
@@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using osu.Framework.Graphics;
+using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Taiko.Edit.Blueprints;
@@ -15,6 +17,8 @@ namespace osu.Game.Rulesets.Taiko.Edit
{
}
+ public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders);
+
public override PlacementBlueprint CreatePlacementBlueprint() => new DrumRollPlacementBlueprint();
}
}
diff --git a/osu.Game.Rulesets.Taiko/Edit/HitCompositionTool.cs b/osu.Game.Rulesets.Taiko/Edit/HitCompositionTool.cs
index e877cf6240..3e97b4e322 100644
--- a/osu.Game.Rulesets.Taiko/Edit/HitCompositionTool.cs
+++ b/osu.Game.Rulesets.Taiko/Edit/HitCompositionTool.cs
@@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using osu.Framework.Graphics;
+using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Taiko.Edit.Blueprints;
@@ -15,6 +17,8 @@ namespace osu.Game.Rulesets.Taiko.Edit
{
}
+ public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles);
+
public override PlacementBlueprint CreatePlacementBlueprint() => new HitPlacementBlueprint();
}
}
diff --git a/osu.Game.Rulesets.Taiko/Edit/SwellCompositionTool.cs b/osu.Game.Rulesets.Taiko/Edit/SwellCompositionTool.cs
index a6191fcedc..918afde1dd 100644
--- a/osu.Game.Rulesets.Taiko/Edit/SwellCompositionTool.cs
+++ b/osu.Game.Rulesets.Taiko/Edit/SwellCompositionTool.cs
@@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using osu.Framework.Graphics;
+using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Taiko.Edit.Blueprints;
@@ -15,6 +17,8 @@ namespace osu.Game.Rulesets.Taiko.Edit
{
}
+ public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners);
+
public override PlacementBlueprint CreatePlacementBlueprint() => new SwellPlacementBlueprint();
}
}
diff --git a/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs b/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs
index 0151678db3..dd3dba1274 100644
--- a/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs
+++ b/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs
@@ -15,8 +15,10 @@ using osu.Framework.Extensions;
using osu.Framework.Logging;
using osu.Game.Beatmaps;
using osu.Game.IO;
+using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Tests.Resources;
+using osu.Game.Users;
using SharpCompress.Archives;
using SharpCompress.Archives.Zip;
using SharpCompress.Common;
@@ -32,7 +34,7 @@ namespace osu.Game.Tests.Beatmaps.IO
public async Task TestImportWhenClosed()
{
// unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here.
- using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportWhenClosed)))
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
{
try
{
@@ -49,7 +51,7 @@ namespace osu.Game.Tests.Beatmaps.IO
public async Task TestImportThenDelete()
{
// unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here.
- using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportThenDelete)))
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
{
try
{
@@ -70,7 +72,7 @@ namespace osu.Game.Tests.Beatmaps.IO
public async Task TestImportThenImport()
{
// unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here.
- using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportThenImport)))
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
{
try
{
@@ -96,7 +98,7 @@ namespace osu.Game.Tests.Beatmaps.IO
[Test]
public async Task TestImportThenImportWithReZip()
{
- using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportThenImportWithReZip)))
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
{
try
{
@@ -154,7 +156,7 @@ namespace osu.Game.Tests.Beatmaps.IO
[Test]
public async Task TestImportThenImportWithChangedFile()
{
- using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportThenImportWithChangedFile)))
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
{
try
{
@@ -205,7 +207,7 @@ namespace osu.Game.Tests.Beatmaps.IO
[Test]
public async Task TestImportThenImportWithDifferentFilename()
{
- using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportThenImportWithDifferentFilename)))
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
{
try
{
@@ -257,7 +259,7 @@ namespace osu.Game.Tests.Beatmaps.IO
public async Task TestImportCorruptThenImport()
{
// unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here.
- using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportCorruptThenImport)))
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
{
try
{
@@ -299,7 +301,7 @@ namespace osu.Game.Tests.Beatmaps.IO
public async Task TestRollbackOnFailure()
{
// unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here.
- using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestRollbackOnFailure)))
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
{
try
{
@@ -376,7 +378,7 @@ namespace osu.Game.Tests.Beatmaps.IO
public async Task TestImportThenDeleteThenImport()
{
// unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here.
- using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportThenDeleteThenImport)))
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
{
try
{
@@ -404,7 +406,7 @@ namespace osu.Game.Tests.Beatmaps.IO
public async Task TestImportThenDeleteThenImportWithOnlineIDMismatch(bool set)
{
// unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here.
- using (HeadlessGameHost host = new CleanRunHeadlessGameHost($"{nameof(TestImportThenDeleteThenImportWithOnlineIDMismatch)}-{set}"))
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost(set.ToString()))
{
try
{
@@ -438,7 +440,7 @@ namespace osu.Game.Tests.Beatmaps.IO
public async Task TestImportWithDuplicateBeatmapIDs()
{
// unfortunately for the time being we need to reference osu.Framework.Desktop for a game host here.
- using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportWithDuplicateBeatmapIDs)))
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
{
try
{
@@ -524,7 +526,7 @@ namespace osu.Game.Tests.Beatmaps.IO
[Test]
public async Task TestImportWhenFileOpen()
{
- using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportWhenFileOpen)))
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
{
try
{
@@ -546,7 +548,7 @@ namespace osu.Game.Tests.Beatmaps.IO
[Test]
public async Task TestImportWithDuplicateHashes()
{
- using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportWithDuplicateHashes)))
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
{
try
{
@@ -588,7 +590,7 @@ namespace osu.Game.Tests.Beatmaps.IO
[Test]
public async Task TestImportNestedStructure()
{
- using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportNestedStructure)))
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
{
try
{
@@ -633,7 +635,7 @@ namespace osu.Game.Tests.Beatmaps.IO
[Test]
public async Task TestImportWithIgnoredDirectoryInArchive()
{
- using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestImportWithIgnoredDirectoryInArchive)))
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
{
try
{
@@ -687,7 +689,7 @@ namespace osu.Game.Tests.Beatmaps.IO
[Test]
public async Task TestUpdateBeatmapInfo()
{
- using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestUpdateBeatmapInfo)))
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
{
try
{
@@ -717,7 +719,7 @@ namespace osu.Game.Tests.Beatmaps.IO
[Test]
public async Task TestUpdateBeatmapFile()
{
- using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestUpdateBeatmapFile)))
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
{
try
{
@@ -756,6 +758,63 @@ namespace osu.Game.Tests.Beatmaps.IO
}
}
+ [Test]
+ public void TestCreateNewEmptyBeatmap()
+ {
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
+ {
+ try
+ {
+ var osu = loadOsu(host);
+ var manager = osu.Dependencies.Get();
+
+ var working = manager.CreateNew(new OsuRuleset().RulesetInfo, User.SYSTEM_USER);
+
+ manager.Save(working.BeatmapInfo, working.Beatmap);
+
+ var retrievedSet = manager.GetAllUsableBeatmapSets()[0];
+
+ // Check that the new file is referenced correctly by attempting a retrieval
+ Beatmap updatedBeatmap = (Beatmap)manager.GetWorkingBeatmap(retrievedSet.Beatmaps[0]).Beatmap;
+ Assert.That(updatedBeatmap.HitObjects.Count, Is.EqualTo(0));
+ }
+ finally
+ {
+ host.Exit();
+ }
+ }
+ }
+
+ [Test]
+ public void TestCreateNewBeatmapWithObject()
+ {
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
+ {
+ try
+ {
+ var osu = loadOsu(host);
+ var manager = osu.Dependencies.Get();
+
+ var working = manager.CreateNew(new OsuRuleset().RulesetInfo, User.SYSTEM_USER);
+
+ ((Beatmap)working.Beatmap).HitObjects.Add(new HitCircle { StartTime = 5000 });
+
+ manager.Save(working.BeatmapInfo, working.Beatmap);
+
+ var retrievedSet = manager.GetAllUsableBeatmapSets()[0];
+
+ // Check that the new file is referenced correctly by attempting a retrieval
+ Beatmap updatedBeatmap = (Beatmap)manager.GetWorkingBeatmap(retrievedSet.Beatmaps[0]).Beatmap;
+ Assert.That(updatedBeatmap.HitObjects.Count, Is.EqualTo(1));
+ Assert.That(updatedBeatmap.HitObjects[0].StartTime, Is.EqualTo(5000));
+ }
+ finally
+ {
+ host.Exit();
+ }
+ }
+ }
+
public static async Task LoadOszIntoOsu(OsuGameBase osu, string path = null, bool virtualTrack = false)
{
var temp = path ?? TestResources.GetTestBeatmapForImport(virtualTrack);
diff --git a/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs b/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs
new file mode 100644
index 0000000000..a79e0d0338
--- /dev/null
+++ b/osu.Game.Tests/Collections/IO/ImportCollectionsTest.cs
@@ -0,0 +1,221 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.IO;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Platform;
+using osu.Game.Collections;
+using osu.Game.Tests.Resources;
+
+namespace osu.Game.Tests.Collections.IO
+{
+ [TestFixture]
+ public class ImportCollectionsTest
+ {
+ [Test]
+ public async Task TestImportEmptyDatabase()
+ {
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
+ {
+ try
+ {
+ var osu = loadOsu(host);
+
+ await osu.CollectionManager.Import(new MemoryStream());
+
+ Assert.That(osu.CollectionManager.Collections.Count, Is.Zero);
+ }
+ finally
+ {
+ host.Exit();
+ }
+ }
+ }
+
+ [Test]
+ public async Task TestImportWithNoBeatmaps()
+ {
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
+ {
+ try
+ {
+ var osu = loadOsu(host);
+
+ await osu.CollectionManager.Import(TestResources.OpenResource("Collections/collections.db"));
+
+ Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2));
+
+ Assert.That(osu.CollectionManager.Collections[0].Name.Value, Is.EqualTo("First"));
+ Assert.That(osu.CollectionManager.Collections[0].Beatmaps.Count, Is.Zero);
+
+ Assert.That(osu.CollectionManager.Collections[1].Name.Value, Is.EqualTo("Second"));
+ Assert.That(osu.CollectionManager.Collections[1].Beatmaps.Count, Is.Zero);
+ }
+ finally
+ {
+ host.Exit();
+ }
+ }
+ }
+
+ [Test]
+ public async Task TestImportWithBeatmaps()
+ {
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
+ {
+ try
+ {
+ var osu = loadOsu(host, true);
+
+ await osu.CollectionManager.Import(TestResources.OpenResource("Collections/collections.db"));
+
+ Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2));
+
+ Assert.That(osu.CollectionManager.Collections[0].Name.Value, Is.EqualTo("First"));
+ Assert.That(osu.CollectionManager.Collections[0].Beatmaps.Count, Is.EqualTo(1));
+
+ Assert.That(osu.CollectionManager.Collections[1].Name.Value, Is.EqualTo("Second"));
+ Assert.That(osu.CollectionManager.Collections[1].Beatmaps.Count, Is.EqualTo(12));
+ }
+ finally
+ {
+ host.Exit();
+ }
+ }
+ }
+
+ [Test]
+ public async Task TestImportMalformedDatabase()
+ {
+ bool exceptionThrown = false;
+ UnhandledExceptionEventHandler setException = (_, __) => exceptionThrown = true;
+
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
+ {
+ try
+ {
+ AppDomain.CurrentDomain.UnhandledException += setException;
+
+ var osu = loadOsu(host, true);
+
+ using (var ms = new MemoryStream())
+ {
+ using (var bw = new BinaryWriter(ms, Encoding.UTF8, true))
+ {
+ for (int i = 0; i < 10000; i++)
+ bw.Write((byte)i);
+ }
+
+ ms.Seek(0, SeekOrigin.Begin);
+
+ await osu.CollectionManager.Import(ms);
+ }
+
+ Assert.That(host.UpdateThread.Running, Is.True);
+ Assert.That(exceptionThrown, Is.False);
+ Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(0));
+ }
+ finally
+ {
+ host.Exit();
+ AppDomain.CurrentDomain.UnhandledException -= setException;
+ }
+ }
+ }
+
+ [Test]
+ public async Task TestSaveAndReload()
+ {
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
+ {
+ try
+ {
+ var osu = loadOsu(host, true);
+
+ await osu.CollectionManager.Import(TestResources.OpenResource("Collections/collections.db"));
+
+ // Move first beatmap from second collection into the first.
+ osu.CollectionManager.Collections[0].Beatmaps.Add(osu.CollectionManager.Collections[1].Beatmaps[0]);
+ osu.CollectionManager.Collections[1].Beatmaps.RemoveAt(0);
+
+ // Rename the second collecction.
+ osu.CollectionManager.Collections[1].Name.Value = "Another";
+ }
+ finally
+ {
+ host.Exit();
+ }
+ }
+
+ using (HeadlessGameHost host = new HeadlessGameHost("TestSaveAndReload"))
+ {
+ try
+ {
+ var osu = loadOsu(host, true);
+
+ Assert.That(osu.CollectionManager.Collections.Count, Is.EqualTo(2));
+
+ Assert.That(osu.CollectionManager.Collections[0].Name.Value, Is.EqualTo("First"));
+ Assert.That(osu.CollectionManager.Collections[0].Beatmaps.Count, Is.EqualTo(2));
+
+ Assert.That(osu.CollectionManager.Collections[1].Name.Value, Is.EqualTo("Another"));
+ Assert.That(osu.CollectionManager.Collections[1].Beatmaps.Count, Is.EqualTo(11));
+ }
+ finally
+ {
+ host.Exit();
+ }
+ }
+ }
+
+ private TestOsuGameBase loadOsu(GameHost host, bool withBeatmap = false)
+ {
+ var osu = new TestOsuGameBase(withBeatmap);
+
+#pragma warning disable 4014
+ Task.Run(() => host.Run(osu));
+#pragma warning restore 4014
+
+ waitForOrAssert(() => osu.IsLoaded, @"osu! failed to start in a reasonable amount of time");
+
+ return osu;
+ }
+
+ private void waitForOrAssert(Func result, string failureMessage, int timeout = 60000)
+ {
+ Task task = Task.Run(() =>
+ {
+ while (!result()) Thread.Sleep(200);
+ });
+
+ Assert.IsTrue(task.Wait(timeout), failureMessage);
+ }
+
+ private class TestOsuGameBase : OsuGameBase
+ {
+ public CollectionManager CollectionManager { get; private set; }
+
+ private readonly bool withBeatmap;
+
+ public TestOsuGameBase(bool withBeatmap)
+ {
+ this.withBeatmap = withBeatmap;
+ }
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ // Beatmap must be imported before the collection manager is loaded.
+ if (withBeatmap)
+ BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).Wait();
+
+ AddInternal(CollectionManager = new CollectionManager(Storage));
+ }
+ }
+ }
+}
diff --git a/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs b/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs
index b0baf0385e..c9ab4fa489 100644
--- a/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs
+++ b/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs
@@ -28,6 +28,20 @@ namespace osu.Game.Tests.Gameplay
Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(0.0));
}
+ [Test]
+ public void TestOnlyBonusScore()
+ {
+ var beatmap = new Beatmap { HitObjects = { new TestBonusHitObject() } };
+
+ var scoreProcessor = new ScoreProcessor();
+ scoreProcessor.ApplyBeatmap(beatmap);
+
+ // Apply a judgement
+ scoreProcessor.ApplyResult(new JudgementResult(new TestBonusHitObject(), new TestBonusJudgement()) { Type = HitResult.Perfect });
+
+ Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(100));
+ }
+
private class TestHitObject : HitObject
{
public override Judgement CreateJudgement() => new TestJudgement();
@@ -37,5 +51,17 @@ namespace osu.Game.Tests.Gameplay
{
protected override int NumericResultFor(HitResult result) => 100;
}
+
+ private class TestBonusHitObject : HitObject
+ {
+ public override Judgement CreateJudgement() => new TestBonusJudgement();
+ }
+
+ private class TestBonusJudgement : Judgement
+ {
+ public override bool AffectsCombo => false;
+
+ protected override int NumericResultFor(HitResult result) => 100;
+ }
}
}
diff --git a/osu.Game.Tests/Resources/Collections/collections.db b/osu.Game.Tests/Resources/Collections/collections.db
new file mode 100644
index 0000000000..83e1c0f10a
Binary files /dev/null and b/osu.Game.Tests/Resources/Collections/collections.db differ
diff --git a/osu.Game.Tests/Scores/IO/ImportScoreTest.cs b/osu.Game.Tests/Scores/IO/ImportScoreTest.cs
index 57f0d7e957..a4d20714fa 100644
--- a/osu.Game.Tests/Scores/IO/ImportScoreTest.cs
+++ b/osu.Game.Tests/Scores/IO/ImportScoreTest.cs
@@ -27,7 +27,7 @@ namespace osu.Game.Tests.Scores.IO
[Test]
public async Task TestBasicImport()
{
- using (HeadlessGameHost host = new CleanRunHeadlessGameHost("TestBasicImport"))
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
{
try
{
@@ -66,7 +66,7 @@ namespace osu.Game.Tests.Scores.IO
[Test]
public async Task TestImportMods()
{
- using (HeadlessGameHost host = new CleanRunHeadlessGameHost("TestImportMods"))
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
{
try
{
@@ -92,7 +92,7 @@ namespace osu.Game.Tests.Scores.IO
[Test]
public async Task TestImportStatistics()
{
- using (HeadlessGameHost host = new CleanRunHeadlessGameHost("TestImportStatistics"))
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
{
try
{
@@ -122,7 +122,7 @@ namespace osu.Game.Tests.Scores.IO
[Test]
public async Task TestImportWithDeletedBeatmapSet()
{
- using (HeadlessGameHost host = new CleanRunHeadlessGameHost("TestImportWithDeletedBeatmapSet"))
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
{
try
{
@@ -159,7 +159,7 @@ namespace osu.Game.Tests.Scores.IO
[Test]
public async Task TestOnlineScoreIsAvailableLocally()
{
- using (HeadlessGameHost host = new CleanRunHeadlessGameHost("TestOnlineScoreIsAvailableLocally"))
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
{
try
{
diff --git a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs
new file mode 100644
index 0000000000..54ab20af7f
--- /dev/null
+++ b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs
@@ -0,0 +1,244 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.UserInterface;
+using osu.Framework.Platform;
+using osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Collections;
+using osu.Game.Overlays;
+using osu.Game.Overlays.Dialog;
+using osu.Game.Rulesets;
+using osu.Game.Tests.Resources;
+using osuTK;
+using osuTK.Input;
+
+namespace osu.Game.Tests.Visual.Collections
+{
+ public class TestSceneManageCollectionsDialog : OsuManualInputManagerTestScene
+ {
+ protected override Container Content => content;
+
+ private readonly Container content;
+ private readonly DialogOverlay dialogOverlay;
+ private readonly CollectionManager manager;
+
+ private RulesetStore rulesets;
+ private BeatmapManager beatmapManager;
+
+ private ManageCollectionsDialog dialog;
+
+ public TestSceneManageCollectionsDialog()
+ {
+ base.Content.AddRange(new Drawable[]
+ {
+ manager = new CollectionManager(LocalStorage),
+ content = new Container { RelativeSizeAxes = Axes.Both },
+ dialogOverlay = new DialogOverlay()
+ });
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(GameHost host)
+ {
+ Dependencies.Cache(rulesets = new RulesetStore(ContextFactory));
+ Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, Audio, host, Beatmap.Default));
+
+ beatmapManager.Import(TestResources.GetTestBeatmapForImport()).Wait();
+ }
+
+ protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
+ {
+ var dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
+ dependencies.Cache(manager);
+ dependencies.Cache(dialogOverlay);
+ return dependencies;
+ }
+
+ [SetUp]
+ public void SetUp() => Schedule(() =>
+ {
+ manager.Collections.Clear();
+ Child = dialog = new ManageCollectionsDialog();
+ });
+
+ [SetUpSteps]
+ public void SetUpSteps()
+ {
+ AddStep("show dialog", () => dialog.Show());
+ }
+
+ [Test]
+ public void TestHideDialog()
+ {
+ AddWaitStep("wait for animation", 3);
+ AddStep("hide dialog", () => dialog.Hide());
+ }
+
+ [Test]
+ public void TestLastItemIsPlaceholder()
+ {
+ AddAssert("last item is placeholder", () => !manager.Collections.Contains(dialog.ChildrenOfType().Last().Model));
+ }
+
+ [Test]
+ public void TestAddCollectionExternal()
+ {
+ AddStep("add collection", () => manager.Collections.Add(new BeatmapCollection { Name = { Value = "First collection" } }));
+ assertCollectionCount(1);
+ assertCollectionName(0, "First collection");
+
+ AddStep("add another collection", () => manager.Collections.Add(new BeatmapCollection { Name = { Value = "Second collection" } }));
+ assertCollectionCount(2);
+ assertCollectionName(1, "Second collection");
+ }
+
+ [Test]
+ public void TestFocusPlaceholderDoesNotCreateCollection()
+ {
+ AddStep("focus placeholder", () =>
+ {
+ InputManager.MoveMouseTo(dialog.ChildrenOfType().Last());
+ InputManager.Click(MouseButton.Left);
+ });
+
+ assertCollectionCount(0);
+ }
+
+ [Test]
+ public void TestAddCollectionViaPlaceholder()
+ {
+ DrawableCollectionListItem placeholderItem = null;
+
+ AddStep("focus placeholder", () =>
+ {
+ InputManager.MoveMouseTo(placeholderItem = dialog.ChildrenOfType().Last());
+ InputManager.Click(MouseButton.Left);
+ });
+
+ // Done directly via the collection since InputManager methods cannot add text to textbox...
+ AddStep("change collection name", () => placeholderItem.Model.Name.Value = "a");
+ assertCollectionCount(1);
+ AddAssert("collection now exists", () => manager.Collections.Contains(placeholderItem.Model));
+
+ AddAssert("last item is placeholder", () => !manager.Collections.Contains(dialog.ChildrenOfType().Last().Model));
+ }
+
+ [Test]
+ public void TestRemoveCollectionExternal()
+ {
+ AddStep("add two collections", () => manager.Collections.AddRange(new[]
+ {
+ new BeatmapCollection { Name = { Value = "1" } },
+ new BeatmapCollection { Name = { Value = "2" } },
+ }));
+
+ AddStep("remove first collection", () => manager.Collections.RemoveAt(0));
+ assertCollectionCount(1);
+ assertCollectionName(0, "2");
+ }
+
+ [Test]
+ public void TestRemoveCollectionViaButton()
+ {
+ AddStep("add two collections", () => manager.Collections.AddRange(new[]
+ {
+ new BeatmapCollection { Name = { Value = "1" } },
+ new BeatmapCollection { Name = { Value = "2" }, Beatmaps = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0] } },
+ }));
+
+ assertCollectionCount(2);
+
+ AddStep("click first delete button", () =>
+ {
+ InputManager.MoveMouseTo(dialog.ChildrenOfType().First(), new Vector2(5, 0));
+ InputManager.Click(MouseButton.Left);
+ });
+
+ AddAssert("dialog not displayed", () => dialogOverlay.CurrentDialog == null);
+ assertCollectionCount(1);
+ assertCollectionName(0, "2");
+
+ AddStep("click first delete button", () =>
+ {
+ InputManager.MoveMouseTo(dialog.ChildrenOfType().First(), new Vector2(5, 0));
+ InputManager.Click(MouseButton.Left);
+ });
+
+ AddAssert("dialog displayed", () => dialogOverlay.CurrentDialog is DeleteCollectionDialog);
+ AddStep("click confirmation", () =>
+ {
+ InputManager.MoveMouseTo(dialogOverlay.CurrentDialog.ChildrenOfType().First());
+ InputManager.Click(MouseButton.Left);
+ });
+
+ assertCollectionCount(0);
+ }
+
+ [Test]
+ public void TestCollectionNotRemovedWhenDialogCancelled()
+ {
+ AddStep("add two collections", () => manager.Collections.AddRange(new[]
+ {
+ new BeatmapCollection { Name = { Value = "1" }, Beatmaps = { beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0] } },
+ }));
+
+ assertCollectionCount(1);
+
+ AddStep("click first delete button", () =>
+ {
+ InputManager.MoveMouseTo(dialog.ChildrenOfType().First(), new Vector2(5, 0));
+ InputManager.Click(MouseButton.Left);
+ });
+
+ AddAssert("dialog displayed", () => dialogOverlay.CurrentDialog is DeleteCollectionDialog);
+ AddStep("click cancellation", () =>
+ {
+ InputManager.MoveMouseTo(dialogOverlay.CurrentDialog.ChildrenOfType().Last());
+ InputManager.Click(MouseButton.Left);
+ });
+
+ assertCollectionCount(1);
+ }
+
+ [Test]
+ public void TestCollectionRenamedExternal()
+ {
+ AddStep("add two collections", () => manager.Collections.AddRange(new[]
+ {
+ new BeatmapCollection { Name = { Value = "1" } },
+ new BeatmapCollection { Name = { Value = "2" } },
+ }));
+
+ AddStep("change first collection name", () => manager.Collections[0].Name.Value = "First");
+
+ assertCollectionName(0, "First");
+ }
+
+ [Test]
+ public void TestCollectionRenamedOnTextChange()
+ {
+ AddStep("add two collections", () => manager.Collections.AddRange(new[]
+ {
+ new BeatmapCollection { Name = { Value = "1" } },
+ new BeatmapCollection { Name = { Value = "2" } },
+ }));
+
+ assertCollectionCount(2);
+
+ AddStep("change first collection name", () => dialog.ChildrenOfType().First().Text = "First");
+ AddAssert("collection has new name", () => manager.Collections[0].Name.Value == "First");
+ }
+
+ private void assertCollectionCount(int count)
+ => AddUntilStep($"{count} collections shown", () => dialog.ChildrenOfType().Count(i => i.IsCreated.Value) == count);
+
+ private void assertCollectionName(int index, string name)
+ => AddUntilStep($"item {index + 1} has correct name", () => dialog.ChildrenOfType().ElementAt(index).ChildrenOfType().First().Text == name);
+ }
+}
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorChangeStates.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorChangeStates.cs
index 293a6e6869..c8a32d966f 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneEditorChangeStates.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorChangeStates.cs
@@ -18,6 +18,8 @@ namespace osu.Game.Tests.Visual.Editing
protected override Ruleset CreateEditorRuleset() => new OsuRuleset();
+ protected new TestEditor Editor => (TestEditor)base.Editor;
+
public override void SetUpSteps()
{
base.SetUpSteps();
@@ -35,6 +37,7 @@ namespace osu.Game.Tests.Visual.Editing
addUndoSteps();
AddAssert("no change occurred", () => hitObjectCount == editorBeatmap.HitObjects.Count);
+ AddAssert("no unsaved changes", () => !Editor.HasUnsavedChanges);
}
[Test]
@@ -47,6 +50,7 @@ namespace osu.Game.Tests.Visual.Editing
addRedoSteps();
AddAssert("no change occurred", () => hitObjectCount == editorBeatmap.HitObjects.Count);
+ AddAssert("no unsaved changes", () => !Editor.HasUnsavedChanges);
}
[Test]
@@ -64,9 +68,11 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("add hitobject", () => editorBeatmap.Add(expectedObject = new HitCircle { StartTime = 1000 }));
AddAssert("hitobject added", () => addedObject == expectedObject);
+ AddAssert("unsaved changes", () => Editor.HasUnsavedChanges);
addUndoSteps();
AddAssert("hitobject removed", () => removedObject == expectedObject);
+ AddAssert("no unsaved changes", () => !Editor.HasUnsavedChanges);
}
[Test]
@@ -94,6 +100,17 @@ namespace osu.Game.Tests.Visual.Editing
addRedoSteps();
AddAssert("hitobject added", () => addedObject.StartTime == expectedObject.StartTime); // Can't compare via equality (new hitobject instance)
AddAssert("no hitobject removed", () => removedObject == null);
+ AddAssert("unsaved changes", () => Editor.HasUnsavedChanges);
+ }
+
+ [Test]
+ public void TestAddObjectThenSaveHasNoUnsavedChanges()
+ {
+ AddStep("add hitobject", () => editorBeatmap.Add(new HitCircle { StartTime = 1000 }));
+
+ AddAssert("unsaved changes", () => Editor.HasUnsavedChanges);
+ AddStep("save changes", () => Editor.Save());
+ AddAssert("no unsaved changes", () => !Editor.HasUnsavedChanges);
}
[Test]
@@ -120,6 +137,7 @@ namespace osu.Game.Tests.Visual.Editing
addUndoSteps();
AddAssert("hitobject added", () => addedObject.StartTime == expectedObject.StartTime); // Can't compare via equality (new hitobject instance)
AddAssert("no hitobject removed", () => removedObject == null);
+ AddAssert("unsaved changes", () => Editor.HasUnsavedChanges); // 2 steps performed, 1 undone
}
[Test]
@@ -148,19 +166,24 @@ namespace osu.Game.Tests.Visual.Editing
addRedoSteps();
AddAssert("hitobject removed", () => removedObject.StartTime == expectedObject.StartTime); // Can't compare via equality (new hitobject instance after undo)
AddAssert("no hitobject added", () => addedObject == null);
+ AddAssert("no changes", () => !Editor.HasUnsavedChanges); // end result is empty beatmap, matching original state
}
- private void addUndoSteps() => AddStep("undo", () => ((TestEditor)Editor).Undo());
+ private void addUndoSteps() => AddStep("undo", () => Editor.Undo());
- private void addRedoSteps() => AddStep("redo", () => ((TestEditor)Editor).Redo());
+ private void addRedoSteps() => AddStep("redo", () => Editor.Redo());
protected override Editor CreateEditor() => new TestEditor();
- private class TestEditor : Editor
+ protected class TestEditor : Editor
{
public new void Undo() => base.Undo();
public new void Redo() => base.Redo();
+
+ public new void Save() => base.Save();
+
+ public new bool HasUnsavedChanges => base.HasUnsavedChanges;
}
}
}
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs
index e4d7e025a8..0b52ae2b95 100644
--- a/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs
+++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs
@@ -3,6 +3,7 @@
using NUnit.Framework;
using osu.Framework.Graphics;
+using osu.Framework.Graphics.Sprites;
using osu.Game.Screens.Edit.Components.RadioButtons;
namespace osu.Game.Tests.Visual.Editing
@@ -22,7 +23,7 @@ namespace osu.Game.Tests.Visual.Editing
{
new RadioButton("Item 1", () => { }),
new RadioButton("Item 2", () => { }),
- new RadioButton("Item 3", () => { }),
+ new RadioButton("Item 3", () => { }, () => new SpriteIcon { Icon = FontAwesome.Regular.Angry }),
new RadioButton("Item 4", () => { }),
new RadioButton("Item 5", () => { })
}
diff --git a/osu.Game.Tests/Visual/Editing/TestSceneSetupScreen.cs b/osu.Game.Tests/Visual/Editing/TestSceneSetupScreen.cs
new file mode 100644
index 0000000000..62e12158ab
--- /dev/null
+++ b/osu.Game.Tests/Visual/Editing/TestSceneSetupScreen.cs
@@ -0,0 +1,32 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Game.Rulesets.Edit;
+using osu.Game.Rulesets.Osu.Beatmaps;
+using osu.Game.Screens.Edit;
+using osu.Game.Screens.Edit.Setup;
+
+namespace osu.Game.Tests.Visual.Editing
+{
+ [TestFixture]
+ public class TestSceneSetupScreen : EditorClockTestScene
+ {
+ [Cached(typeof(EditorBeatmap))]
+ [Cached(typeof(IBeatSnapProvider))]
+ private readonly EditorBeatmap editorBeatmap;
+
+ public TestSceneSetupScreen()
+ {
+ editorBeatmap = new EditorBeatmap(new OsuBeatmap());
+ }
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ Beatmap.Value = CreateWorkingBeatmap(editorBeatmap.PlayableBeatmap);
+ Child = new SetupScreen();
+ }
+ }
+}
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplaySettingsOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplaySettingsOverlay.cs
index cdfb3beb19..f8fab784cc 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplaySettingsOverlay.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplaySettingsOverlay.cs
@@ -48,7 +48,10 @@ namespace osu.Game.Tests.Visual.Gameplay
private class ExampleContainer : PlayerSettingsGroup
{
- protected override string Title => @"example";
+ public ExampleContainer()
+ : base("example")
+ {
+ }
}
}
}
diff --git a/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs b/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs
new file mode 100644
index 0000000000..4cad2b19d5
--- /dev/null
+++ b/osu.Game.Tests/Visual/Menus/TestSceneMusicActionHandling.cs
@@ -0,0 +1,83 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Input.Bindings;
+using osu.Game.Overlays;
+using osu.Game.Tests.Resources;
+using osu.Game.Tests.Visual.Navigation;
+
+namespace osu.Game.Tests.Visual.Menus
+{
+ public class TestSceneMusicActionHandling : OsuGameTestScene
+ {
+ private GlobalActionContainer globalActionContainer => Game.ChildrenOfType().First();
+
+ [Test]
+ public void TestMusicPlayAction()
+ {
+ AddStep("ensure playing something", () => Game.MusicController.EnsurePlayingSomething());
+ AddStep("toggle playback", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPlay));
+ AddAssert("music paused", () => !Game.MusicController.IsPlaying && Game.MusicController.IsUserPaused);
+ AddStep("toggle playback", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPlay));
+ AddAssert("music resumed", () => Game.MusicController.IsPlaying && !Game.MusicController.IsUserPaused);
+ }
+
+ [Test]
+ public void TestMusicNavigationActions()
+ {
+ int importId = 0;
+ Queue<(WorkingBeatmap working, TrackChangeDirection changeDirection)> trackChangeQueue = null;
+
+ // ensure we have at least two beatmaps available to identify the direction the music controller navigated to.
+ AddRepeatStep("import beatmap", () => Game.BeatmapManager.Import(new BeatmapSetInfo
+ {
+ Beatmaps = new List
+ {
+ new BeatmapInfo
+ {
+ BaseDifficulty = new BeatmapDifficulty(),
+ }
+ },
+ Metadata = new BeatmapMetadata
+ {
+ Artist = $"a test map {importId++}",
+ Title = "title",
+ }
+ }).Wait(), 5);
+
+ AddStep("import beatmap with track", () =>
+ {
+ var setWithTrack = Game.BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).Result;
+ Beatmap.Value = Game.BeatmapManager.GetWorkingBeatmap(setWithTrack.Beatmaps.First());
+ });
+
+ AddStep("bind to track change", () =>
+ {
+ trackChangeQueue = new Queue<(WorkingBeatmap, TrackChangeDirection)>();
+ Game.MusicController.TrackChanged += (working, changeDirection) => trackChangeQueue.Enqueue((working, changeDirection));
+ });
+
+ AddStep("seek track to 6 second", () => Game.MusicController.SeekTo(6000));
+ AddUntilStep("wait for current time to update", () => Game.MusicController.CurrentTrack.CurrentTime > 5000);
+
+ AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev));
+ AddAssert("no track change", () => trackChangeQueue.Count == 0);
+ AddUntilStep("track restarted", () => Game.MusicController.CurrentTrack.CurrentTime < 5000);
+
+ AddStep("press previous", () => globalActionContainer.TriggerPressed(GlobalAction.MusicPrev));
+ AddAssert("track changed to previous", () =>
+ trackChangeQueue.Count == 1 &&
+ trackChangeQueue.Dequeue().changeDirection == TrackChangeDirection.Prev);
+
+ AddStep("press next", () => globalActionContainer.TriggerPressed(GlobalAction.MusicNext));
+ AddAssert("track changed to next", () =>
+ trackChangeQueue.Count == 1 &&
+ trackChangeQueue.Dequeue().changeDirection == TrackChangeDirection.Next);
+ }
+ }
+}
diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs
new file mode 100644
index 0000000000..23feb1466e
--- /dev/null
+++ b/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs
@@ -0,0 +1,237 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Sprites;
+using osu.Framework.Graphics.UserInterface;
+using osu.Framework.Platform;
+using osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Collections;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Rulesets;
+using osu.Game.Screens.Select;
+using osu.Game.Tests.Resources;
+using osuTK.Input;
+
+namespace osu.Game.Tests.Visual.SongSelect
+{
+ public class TestSceneFilterControl : OsuManualInputManagerTestScene
+ {
+ protected override Container Content => content;
+ private readonly Container content;
+
+ private readonly CollectionManager collectionManager;
+
+ private RulesetStore rulesets;
+ private BeatmapManager beatmapManager;
+
+ private FilterControl control;
+
+ public TestSceneFilterControl()
+ {
+ base.Content.AddRange(new Drawable[]
+ {
+ collectionManager = new CollectionManager(LocalStorage),
+ content = new Container { RelativeSizeAxes = Axes.Both }
+ });
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(GameHost host)
+ {
+ Dependencies.Cache(rulesets = new RulesetStore(ContextFactory));
+ Dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, Audio, host, Beatmap.Default));
+
+ beatmapManager.Import(TestResources.GetTestBeatmapForImport()).Wait();
+ }
+
+ protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
+ {
+ var dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
+ dependencies.Cache(collectionManager);
+ return dependencies;
+ }
+
+ [SetUp]
+ public void SetUp() => Schedule(() =>
+ {
+ collectionManager.Collections.Clear();
+
+ Child = control = new FilterControl
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ RelativeSizeAxes = Axes.X,
+ Height = FilterControl.HEIGHT,
+ };
+ });
+
+ [Test]
+ public void TestEmptyCollectionFilterContainsAllBeatmaps()
+ {
+ assertCollectionDropdownContains("All beatmaps");
+ assertCollectionHeaderDisplays("All beatmaps");
+ }
+
+ [Test]
+ public void TestCollectionAddedToDropdown()
+ {
+ AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } }));
+ AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "2" } }));
+ assertCollectionDropdownContains("1");
+ assertCollectionDropdownContains("2");
+ }
+
+ [Test]
+ public void TestCollectionRemovedFromDropdown()
+ {
+ AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } }));
+ AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "2" } }));
+ AddStep("remove collection", () => collectionManager.Collections.RemoveAt(0));
+
+ assertCollectionDropdownContains("1", false);
+ assertCollectionDropdownContains("2");
+ }
+
+ [Test]
+ public void TestCollectionRenamed()
+ {
+ AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } }));
+ AddStep("select collection", () =>
+ {
+ var dropdown = control.ChildrenOfType().Single();
+ dropdown.Current.Value = dropdown.ItemSource.ElementAt(1);
+ });
+
+ addExpandHeaderStep();
+
+ AddStep("change name", () => collectionManager.Collections[0].Name.Value = "First");
+
+ assertCollectionDropdownContains("First");
+ assertCollectionHeaderDisplays("First");
+ }
+
+ [Test]
+ public void TestAllBeatmapFilterDoesNotHaveAddButton()
+ {
+ addExpandHeaderStep();
+ AddStep("hover all beatmaps", () => InputManager.MoveMouseTo(getAddOrRemoveButton(0)));
+ AddAssert("'All beatmaps' filter does not have add button", () => !getAddOrRemoveButton(0).IsPresent);
+ }
+
+ [Test]
+ public void TestCollectionFilterHasAddButton()
+ {
+ addExpandHeaderStep();
+ AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } }));
+ AddStep("hover collection", () => InputManager.MoveMouseTo(getAddOrRemoveButton(1)));
+ AddAssert("collection has add button", () => getAddOrRemoveButton(1).IsPresent);
+ }
+
+ [Test]
+ public void TestButtonDisabledAndEnabledWithBeatmapChanges()
+ {
+ addExpandHeaderStep();
+
+ AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } }));
+
+ AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0]));
+ AddAssert("button enabled", () => getAddOrRemoveButton(1).Enabled.Value);
+
+ AddStep("set dummy beatmap", () => Beatmap.SetDefault());
+ AddAssert("button disabled", () => !getAddOrRemoveButton(1).Enabled.Value);
+ }
+
+ [Test]
+ public void TestButtonChangesWhenAddedAndRemovedFromCollection()
+ {
+ addExpandHeaderStep();
+
+ AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0]));
+
+ AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } }));
+ AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare));
+
+ AddStep("add beatmap to collection", () => collectionManager.Collections[0].Beatmaps.Add(Beatmap.Value.BeatmapInfo));
+ AddAssert("button is minus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.MinusSquare));
+
+ AddStep("remove beatmap from collection", () => collectionManager.Collections[0].Beatmaps.Clear());
+ AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare));
+ }
+
+ [Test]
+ public void TestButtonAddsAndRemovesBeatmap()
+ {
+ addExpandHeaderStep();
+
+ AddStep("select available beatmap", () => Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps[0]));
+
+ AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } }));
+ AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare));
+
+ addClickAddOrRemoveButtonStep(1);
+ AddAssert("collection contains beatmap", () => collectionManager.Collections[0].Beatmaps.Contains(Beatmap.Value.BeatmapInfo));
+ AddAssert("button is minus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.MinusSquare));
+
+ addClickAddOrRemoveButtonStep(1);
+ AddAssert("collection does not contain beatmap", () => !collectionManager.Collections[0].Beatmaps.Contains(Beatmap.Value.BeatmapInfo));
+ AddAssert("button is plus", () => getAddOrRemoveButton(1).Icon.Equals(FontAwesome.Solid.PlusSquare));
+ }
+
+ [Test]
+ public void TestManageCollectionsFilterIsNotSelected()
+ {
+ addExpandHeaderStep();
+
+ AddStep("add collection", () => collectionManager.Collections.Add(new BeatmapCollection { Name = { Value = "1" } }));
+ AddStep("select collection", () =>
+ {
+ InputManager.MoveMouseTo(getCollectionDropdownItems().ElementAt(1));
+ InputManager.Click(MouseButton.Left);
+ });
+
+ addExpandHeaderStep();
+
+ AddStep("click manage collections filter", () =>
+ {
+ InputManager.MoveMouseTo(getCollectionDropdownItems().Last());
+ InputManager.Click(MouseButton.Left);
+ });
+
+ AddAssert("collection filter still selected", () => control.CreateCriteria().Collection?.Name.Value == "1");
+ }
+
+ private void assertCollectionHeaderDisplays(string collectionName, bool shouldDisplay = true)
+ => AddAssert($"collection dropdown header displays '{collectionName}'",
+ () => shouldDisplay == (control.ChildrenOfType().Single().ChildrenOfType().First().Text == collectionName));
+
+ private void assertCollectionDropdownContains(string collectionName, bool shouldContain = true) =>
+ AddAssert($"collection dropdown {(shouldContain ? "contains" : "does not contain")} '{collectionName}'",
+ // A bit of a roundabout way of going about this, see: https://github.com/ppy/osu-framework/issues/3871 + https://github.com/ppy/osu-framework/issues/3872
+ () => shouldContain == (getCollectionDropdownItems().Any(i => i.ChildrenOfType().OfType().First().Text == collectionName)));
+
+ private IconButton getAddOrRemoveButton(int index)
+ => getCollectionDropdownItems().ElementAt(index).ChildrenOfType().Single();
+
+ private void addExpandHeaderStep() => AddStep("expand header", () =>
+ {
+ InputManager.MoveMouseTo(control.ChildrenOfType().Single());
+ InputManager.Click(MouseButton.Left);
+ });
+
+ private void addClickAddOrRemoveButtonStep(int index) => AddStep("click add or remove button", () =>
+ {
+ InputManager.MoveMouseTo(getAddOrRemoveButton(index));
+ InputManager.Click(MouseButton.Left);
+ });
+
+ private IEnumerable.DropdownMenu.DrawableDropdownMenuItem> getCollectionDropdownItems()
+ => control.ChildrenOfType().Single().ChildrenOfType.DropdownMenu.DrawableDropdownMenuItem>();
+ }
+}
diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneNowPlayingOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneNowPlayingOverlay.cs
index c14a1ddbf2..475ab0c414 100644
--- a/osu.Game.Tests/Visual/UserInterface/TestSceneNowPlayingOverlay.cs
+++ b/osu.Game.Tests/Visual/UserInterface/TestSceneNowPlayingOverlay.cs
@@ -1,18 +1,11 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
-using System.Collections.Generic;
-using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
-using osu.Framework.Audio;
using osu.Framework.Graphics;
-using osu.Framework.Platform;
-using osu.Game.Beatmaps;
using osu.Game.Overlays;
-using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu;
-using osu.Game.Tests.Resources;
namespace osu.Game.Tests.Visual.UserInterface
{
@@ -24,14 +17,9 @@ namespace osu.Game.Tests.Visual.UserInterface
private NowPlayingOverlay nowPlayingOverlay;
- private RulesetStore rulesets;
-
[BackgroundDependencyLoader]
- private void load(AudioManager audio, GameHost host)
+ private void load()
{
- Dependencies.Cache(rulesets = new RulesetStore(ContextFactory));
- Dependencies.Cache(manager = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, host, Beatmap.Default));
-
Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo);
nowPlayingOverlay = new NowPlayingOverlay
@@ -51,49 +39,5 @@ namespace osu.Game.Tests.Visual.UserInterface
AddToggleStep(@"toggle beatmap lock", state => Beatmap.Disabled = state);
AddStep(@"hide", () => nowPlayingOverlay.Hide());
}
-
- private BeatmapManager manager { get; set; }
-
- private int importId;
-
- [Test]
- public void TestPrevTrackBehavior()
- {
- // ensure we have at least two beatmaps available.
- AddRepeatStep("import beatmap", () => manager.Import(new BeatmapSetInfo
- {
- Beatmaps = new List
- {
- new BeatmapInfo
- {
- BaseDifficulty = new BeatmapDifficulty(),
- }
- },
- Metadata = new BeatmapMetadata
- {
- Artist = $"a test map {importId++}",
- Title = "title",
- }
- }).Wait(), 5);
-
- WorkingBeatmap currentBeatmap = null;
-
- AddStep("import beatmap with track", () =>
- {
- var setWithTrack = manager.Import(TestResources.GetTestBeatmapForImport()).Result;
- Beatmap.Value = currentBeatmap = manager.GetWorkingBeatmap(setWithTrack.Beatmaps.First());
- });
-
- AddStep(@"Seek track to 6 second", () => musicController.SeekTo(6000));
- AddUntilStep(@"Wait for current time to update", () => musicController.CurrentTrack.CurrentTime > 5000);
-
- AddStep(@"Set previous", () => musicController.PreviousTrack());
-
- AddAssert(@"Check beatmap didn't change", () => currentBeatmap == Beatmap.Value);
- AddUntilStep("Wait for current time to update", () => musicController.CurrentTrack.CurrentTime < 5000);
-
- AddStep(@"Set previous", () => musicController.PreviousTrack());
- AddAssert(@"Check beatmap did change", () => currentBeatmap != Beatmap.Value);
- }
}
}
diff --git a/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs b/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs
index fa530ea2c4..b60eb814e5 100644
--- a/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs
+++ b/osu.Game.Tournament/Screens/Ladder/Components/LadderEditorSettings.cs
@@ -20,8 +20,6 @@ namespace osu.Game.Tournament.Screens.Ladder.Components
{
private const int padding = 10;
- protected override string Title => @"ladder";
-
private SettingsDropdown roundDropdown;
private PlayerCheckbox losersCheckbox;
private DateTextBox dateTimeBox;
@@ -34,6 +32,11 @@ namespace osu.Game.Tournament.Screens.Ladder.Components
[Resolved]
private LadderInfo ladderInfo { get; set; }
+ public LadderEditorSettings()
+ : base("ladder")
+ {
+ }
+
[BackgroundDependencyLoader]
private void load()
{
diff --git a/osu.Game/Beatmaps/BeatmapDifficultyManager.cs b/osu.Game/Beatmaps/BeatmapDifficultyManager.cs
index 0100c9b210..e9d26683c3 100644
--- a/osu.Game/Beatmaps/BeatmapDifficultyManager.cs
+++ b/osu.Game/Beatmaps/BeatmapDifficultyManager.cs
@@ -207,11 +207,11 @@ namespace osu.Game.Beatmaps
var calculator = ruleset.CreateDifficultyCalculator(beatmapManager.GetWorkingBeatmap(beatmapInfo));
var attributes = calculator.Calculate(key.Mods);
- return difficultyCache[key] = new StarDifficulty(attributes.StarRating);
+ return difficultyCache[key] = new StarDifficulty(attributes.StarRating, attributes.MaxCombo);
}
catch
{
- return difficultyCache[key] = new StarDifficulty(0);
+ return difficultyCache[key] = new StarDifficulty();
}
}
@@ -233,7 +233,7 @@ namespace osu.Game.Beatmaps
if (beatmapInfo.ID == 0 || rulesetInfo.ID == null)
{
// If not, fall back to the existing star difficulty (e.g. from an online source).
- existingDifficulty = new StarDifficulty(beatmapInfo.StarDifficulty);
+ existingDifficulty = new StarDifficulty(beatmapInfo.StarDifficulty, beatmapInfo.MaxCombo ?? 0);
key = default;
return true;
@@ -298,10 +298,12 @@ namespace osu.Game.Beatmaps
public readonly struct StarDifficulty
{
public readonly double Stars;
+ public readonly int MaxCombo;
- public StarDifficulty(double stars)
+ public StarDifficulty(double stars, int maxCombo)
{
Stars = stars;
+ MaxCombo = maxCombo;
// Todo: Add more members (BeatmapInfo.DifficultyRating? Attributes? Etc...)
}
diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs
index 35b9b90ce7..e9f41f6bff 100644
--- a/osu.Game/Beatmaps/BeatmapManager.cs
+++ b/osu.Game/Beatmaps/BeatmapManager.cs
@@ -27,6 +27,7 @@ using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Objects;
+using osu.Game.Users;
using osu.Game.Skinning;
using Decoder = osu.Game.Beatmaps.Formats.Decoder;
@@ -65,12 +66,14 @@ namespace osu.Game.Beatmaps
private readonly RulesetStore rulesets;
private readonly BeatmapStore beatmaps;
private readonly AudioManager audioManager;
- private readonly BeatmapOnlineLookupQueue onlineLookupQueue;
private readonly TextureStore textureStore;
private readonly ITrackStore trackStore;
+ [CanBeNull]
+ private readonly BeatmapOnlineLookupQueue onlineLookupQueue;
+
public BeatmapManager(Storage storage, IDatabaseContextFactory contextFactory, RulesetStore rulesets, IAPIProvider api, [NotNull] AudioManager audioManager, GameHost host = null,
- WorkingBeatmap defaultBeatmap = null)
+ WorkingBeatmap defaultBeatmap = null, bool performOnlineLookups = false)
: base(storage, contextFactory, api, new BeatmapStore(contextFactory), host)
{
this.rulesets = rulesets;
@@ -84,7 +87,8 @@ namespace osu.Game.Beatmaps
beatmaps.ItemRemoved += removeWorkingCache;
beatmaps.ItemUpdated += removeWorkingCache;
- onlineLookupQueue = new BeatmapOnlineLookupQueue(api, storage);
+ if (performOnlineLookups)
+ onlineLookupQueue = new BeatmapOnlineLookupQueue(api, storage);
textureStore = new LargeTextureStore(host?.CreateTextureLoaderStore(Files.Store));
trackStore = audioManager.GetTrackStore(Files.Store);
@@ -95,6 +99,34 @@ namespace osu.Game.Beatmaps
protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path)?.ToLowerInvariant() == ".osz";
+ public WorkingBeatmap CreateNew(RulesetInfo ruleset, User user)
+ {
+ var metadata = new BeatmapMetadata
+ {
+ Artist = "artist",
+ Title = "title",
+ Author = user,
+ };
+
+ var set = new BeatmapSetInfo
+ {
+ Metadata = metadata,
+ Beatmaps = new List
+ {
+ new BeatmapInfo
+ {
+ BaseDifficulty = new BeatmapDifficulty(),
+ Ruleset = ruleset,
+ Metadata = metadata,
+ Version = "difficulty"
+ }
+ }
+ };
+
+ var working = Import(set).Result;
+ return GetWorkingBeatmap(working.Beatmaps.First());
+ }
+
protected override async Task Populate(BeatmapSetInfo beatmapSet, ArchiveReader archive, CancellationToken cancellationToken = default)
{
if (archive != null)
@@ -113,7 +145,8 @@ namespace osu.Game.Beatmaps
bool hadOnlineBeatmapIDs = beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0);
- await onlineLookupQueue.UpdateAsync(beatmapSet, cancellationToken);
+ if (onlineLookupQueue != null)
+ await onlineLookupQueue.UpdateAsync(beatmapSet, cancellationToken);
// ensure at least one beatmap was able to retrieve or keep an online ID, else drop the set ID.
if (hadOnlineBeatmapIDs && !beatmapSet.Beatmaps.Any(b => b.OnlineBeatmapID > 0))
@@ -202,7 +235,7 @@ namespace osu.Game.Beatmaps
/// The beatmap content to write, null if to be omitted.
public void Save(BeatmapInfo info, IBeatmap beatmapContent, ISkin beatmapSkin = null)
{
- var setInfo = QueryBeatmapSet(s => s.Beatmaps.Any(b => b.ID == info.ID));
+ var setInfo = info.BeatmapSet;
using (var stream = new MemoryStream())
{
@@ -214,10 +247,20 @@ namespace osu.Game.Beatmaps
using (ContextFactory.GetForWrite())
{
var beatmapInfo = setInfo.Beatmaps.Single(b => b.ID == info.ID);
+ var metadata = beatmapInfo.Metadata ?? setInfo.Metadata;
+
+ // grab the original file (or create a new one if not found).
+ var fileInfo = setInfo.Files.SingleOrDefault(f => string.Equals(f.Filename, beatmapInfo.Path, StringComparison.OrdinalIgnoreCase)) ?? new BeatmapSetFileInfo();
+
+ // metadata may have changed; update the path with the standard format.
+ beatmapInfo.Path = $"{metadata.Artist} - {metadata.Title} ({metadata.Author}) [{beatmapInfo.Version}].osu";
beatmapInfo.MD5Hash = stream.ComputeMD5Hash();
+ // update existing or populate new file's filename.
+ fileInfo.Filename = beatmapInfo.Path;
+
stream.Seek(0, SeekOrigin.Begin);
- UpdateFile(setInfo, setInfo.Files.Single(f => string.Equals(f.Filename, info.Path, StringComparison.OrdinalIgnoreCase)), stream);
+ UpdateFile(setInfo, fileInfo, stream);
}
}
diff --git a/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs b/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs
index 92199789ec..362c99ea3f 100644
--- a/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs
+++ b/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs
@@ -33,6 +33,9 @@ namespace osu.Game.Beatmaps
protected override IBeatmap GetBeatmap()
{
+ if (BeatmapInfo.Path == null)
+ return new Beatmap { BeatmapInfo = BeatmapInfo };
+
try
{
using (var stream = new LineBufferedReader(store.GetStream(getPathForFile(BeatmapInfo.Path))))
@@ -67,6 +70,9 @@ namespace osu.Game.Beatmaps
protected override Track GetBeatmapTrack()
{
+ if (Metadata?.AudioFile == null)
+ return null;
+
try
{
return trackStore.Get(getPathForFile(Metadata.AudioFile));
@@ -80,6 +86,9 @@ namespace osu.Game.Beatmaps
protected override Waveform GetWaveform()
{
+ if (Metadata?.AudioFile == null)
+ return null;
+
try
{
var trackData = store.GetStream(getPathForFile(Metadata.AudioFile));
diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs
index 19b54e1783..d9780233d1 100644
--- a/osu.Game/Beatmaps/WorkingBeatmap.cs
+++ b/osu.Game/Beatmaps/WorkingBeatmap.cs
@@ -55,7 +55,7 @@ namespace osu.Game.Beatmaps
{
const double excess_length = 1000;
- var lastObject = Beatmap.HitObjects.LastOrDefault();
+ var lastObject = Beatmap?.HitObjects.LastOrDefault();
double length;
diff --git a/osu.Game/Collections/BeatmapCollection.cs b/osu.Game/Collections/BeatmapCollection.cs
new file mode 100644
index 0000000000..7e4b15ecf9
--- /dev/null
+++ b/osu.Game/Collections/BeatmapCollection.cs
@@ -0,0 +1,47 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using osu.Framework.Bindables;
+using osu.Game.Beatmaps;
+
+namespace osu.Game.Collections
+{
+ ///
+ /// A collection of beatmaps grouped by a name.
+ ///
+ public class BeatmapCollection
+ {
+ ///
+ /// Invoked whenever any change occurs on this .
+ ///
+ public event Action Changed;
+
+ ///
+ /// The collection's name.
+ ///
+ public readonly Bindable Name = new Bindable();
+
+ ///
+ /// The beatmaps contained by the collection.
+ ///
+ public readonly BindableList Beatmaps = new BindableList();
+
+ ///
+ /// The date when this collection was last modified.
+ ///
+ public DateTimeOffset LastModifyDate { get; private set; } = DateTimeOffset.UtcNow;
+
+ public BeatmapCollection()
+ {
+ Beatmaps.CollectionChanged += (_, __) => onChange();
+ Name.ValueChanged += _ => onChange();
+ }
+
+ private void onChange()
+ {
+ LastModifyDate = DateTimeOffset.Now;
+ Changed?.Invoke();
+ }
+ }
+}
diff --git a/osu.Game/Collections/CollectionManager.cs b/osu.Game/Collections/CollectionManager.cs
new file mode 100644
index 0000000000..a50ab5b07a
--- /dev/null
+++ b/osu.Game/Collections/CollectionManager.cs
@@ -0,0 +1,303 @@
+// 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.Collections.Specialized;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using osu.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Logging;
+using osu.Framework.Platform;
+using osu.Game.Beatmaps;
+using osu.Game.IO.Legacy;
+using osu.Game.Overlays.Notifications;
+
+namespace osu.Game.Collections
+{
+ ///
+ /// Handles user-defined collections of beatmaps.
+ ///
+ ///
+ /// This is currently reading and writing from the osu-stable file format. This is a temporary arrangement until we refactor the
+ /// database backing the game. Going forward writing should be done in a similar way to other model stores.
+ ///
+ public class CollectionManager : Component
+ {
+ ///
+ /// Database version in stable-compatible YYYYMMDD format.
+ ///
+ private const int database_version = 30000000;
+
+ private const string database_name = "collection.db";
+
+ public readonly BindableList Collections = new BindableList();
+
+ public bool SupportsImportFromStable => RuntimeInfo.IsDesktop;
+
+ [Resolved]
+ private GameHost host { get; set; }
+
+ [Resolved]
+ private BeatmapManager beatmaps { get; set; }
+
+ private readonly Storage storage;
+
+ public CollectionManager(Storage storage)
+ {
+ this.storage = storage;
+ }
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ Collections.CollectionChanged += collectionsChanged;
+
+ if (storage.Exists(database_name))
+ {
+ using (var stream = storage.GetStream(database_name))
+ importCollections(readCollections(stream));
+ }
+ }
+
+ private void collectionsChanged(object sender, NotifyCollectionChangedEventArgs e)
+ {
+ switch (e.Action)
+ {
+ case NotifyCollectionChangedAction.Add:
+ foreach (var c in e.NewItems.Cast())
+ c.Changed += backgroundSave;
+ break;
+
+ case NotifyCollectionChangedAction.Remove:
+ foreach (var c in e.OldItems.Cast())
+ c.Changed -= backgroundSave;
+ break;
+
+ case NotifyCollectionChangedAction.Replace:
+ foreach (var c in e.OldItems.Cast())
+ c.Changed -= backgroundSave;
+
+ foreach (var c in e.NewItems.Cast())
+ c.Changed += backgroundSave;
+ break;
+ }
+
+ backgroundSave();
+ }
+
+ ///
+ /// Set an endpoint for notifications to be posted to.
+ ///
+ public Action PostNotification { protected get; set; }
+
+ ///
+ /// Set a storage with access to an osu-stable install for import purposes.
+ ///
+ public Func GetStableStorage { private get; set; }
+
+ ///
+ /// This is a temporary method and will likely be replaced by a full-fledged (and more correctly placed) migration process in the future.
+ ///
+ public Task ImportFromStableAsync()
+ {
+ var stable = GetStableStorage?.Invoke();
+
+ if (stable == null)
+ {
+ Logger.Log("No osu!stable installation available!", LoggingTarget.Information, LogLevel.Error);
+ return Task.CompletedTask;
+ }
+
+ if (!stable.Exists(database_name))
+ {
+ // This handles situations like when the user does not have a collections.db file
+ Logger.Log($"No {database_name} available in osu!stable installation", LoggingTarget.Information, LogLevel.Error);
+ return Task.CompletedTask;
+ }
+
+ return Task.Run(async () =>
+ {
+ using (var stream = stable.GetStream(database_name))
+ await Import(stream);
+ });
+ }
+
+ public async Task Import(Stream stream)
+ {
+ var notification = new ProgressNotification
+ {
+ State = ProgressNotificationState.Active,
+ Text = "Collections import is initialising..."
+ };
+
+ PostNotification?.Invoke(notification);
+
+ var collection = readCollections(stream, notification);
+ bool importCompleted = false;
+
+ Schedule(() =>
+ {
+ importCollections(collection);
+ importCompleted = true;
+ });
+
+ while (!IsDisposed && !importCompleted)
+ await Task.Delay(10);
+
+ notification.CompletionText = $"Imported {collection.Count} collections";
+ notification.State = ProgressNotificationState.Completed;
+ }
+
+ private void importCollections(List newCollections)
+ {
+ foreach (var newCol in newCollections)
+ {
+ var existing = Collections.FirstOrDefault(c => c.Name == newCol.Name);
+ if (existing == null)
+ Collections.Add(existing = new BeatmapCollection { Name = { Value = newCol.Name.Value } });
+
+ foreach (var newBeatmap in newCol.Beatmaps)
+ {
+ if (!existing.Beatmaps.Contains(newBeatmap))
+ existing.Beatmaps.Add(newBeatmap);
+ }
+ }
+ }
+
+ private List readCollections(Stream stream, ProgressNotification notification = null)
+ {
+ if (notification != null)
+ {
+ notification.Text = "Reading collections...";
+ notification.Progress = 0;
+ }
+
+ var result = new List();
+
+ try
+ {
+ using (var sr = new SerializationReader(stream))
+ {
+ sr.ReadInt32(); // Version
+
+ int collectionCount = sr.ReadInt32();
+ result.Capacity = collectionCount;
+
+ for (int i = 0; i < collectionCount; i++)
+ {
+ if (notification?.CancellationToken.IsCancellationRequested == true)
+ return result;
+
+ var collection = new BeatmapCollection { Name = { Value = sr.ReadString() } };
+ int mapCount = sr.ReadInt32();
+
+ for (int j = 0; j < mapCount; j++)
+ {
+ if (notification?.CancellationToken.IsCancellationRequested == true)
+ return result;
+
+ string checksum = sr.ReadString();
+
+ var beatmap = beatmaps.QueryBeatmap(b => b.MD5Hash == checksum);
+ if (beatmap != null)
+ collection.Beatmaps.Add(beatmap);
+ }
+
+ if (notification != null)
+ {
+ notification.Text = $"Imported {i + 1} of {collectionCount} collections";
+ notification.Progress = (float)(i + 1) / collectionCount;
+ }
+
+ result.Add(collection);
+ }
+ }
+ }
+ catch (Exception e)
+ {
+ Logger.Error(e, "Failed to read collection database.");
+ }
+
+ return result;
+ }
+
+ public void DeleteAll()
+ {
+ Collections.Clear();
+ PostNotification?.Invoke(new SimpleNotification { Text = "Deleted all collections!" });
+ }
+
+ private readonly object saveLock = new object();
+ private int lastSave;
+ private int saveFailures;
+
+ ///
+ /// Perform a save with debounce.
+ ///
+ private void backgroundSave()
+ {
+ var current = Interlocked.Increment(ref lastSave);
+ Task.Delay(100).ContinueWith(task =>
+ {
+ if (current != lastSave)
+ return;
+
+ if (!save())
+ backgroundSave();
+ });
+ }
+
+ private bool save()
+ {
+ lock (saveLock)
+ {
+ Interlocked.Increment(ref lastSave);
+
+ try
+ {
+ // This is NOT thread-safe!!
+
+ using (var sw = new SerializationWriter(storage.GetStream(database_name, FileAccess.Write)))
+ {
+ sw.Write(database_version);
+ sw.Write(Collections.Count);
+
+ foreach (var c in Collections)
+ {
+ sw.Write(c.Name.Value);
+ sw.Write(c.Beatmaps.Count);
+
+ foreach (var b in c.Beatmaps)
+ sw.Write(b.MD5Hash);
+ }
+ }
+
+ if (saveFailures < 10)
+ saveFailures = 0;
+ return true;
+ }
+ catch (Exception e)
+ {
+ // Since this code is not thread-safe, we may run into random exceptions (such as collection enumeration or out of range indexing).
+ // Failures are thus only alerted if they exceed a threshold (once) to indicate "actual" errors having occurred.
+ if (++saveFailures == 10)
+ Logger.Error(e, "Failed to save collection database!");
+ }
+
+ return false;
+ }
+ }
+
+ protected override void Dispose(bool isDisposing)
+ {
+ base.Dispose(isDisposing);
+ save();
+ }
+ }
+}
diff --git a/osu.Game/Collections/DeleteCollectionDialog.cs b/osu.Game/Collections/DeleteCollectionDialog.cs
new file mode 100644
index 0000000000..e5a2f6fb81
--- /dev/null
+++ b/osu.Game/Collections/DeleteCollectionDialog.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;
+using Humanizer;
+using osu.Framework.Graphics.Sprites;
+using osu.Game.Overlays.Dialog;
+
+namespace osu.Game.Collections
+{
+ public class DeleteCollectionDialog : PopupDialog
+ {
+ public DeleteCollectionDialog(BeatmapCollection collection, Action deleteAction)
+ {
+ HeaderText = "Confirm deletion of";
+ BodyText = $"{collection.Name.Value} ({"beatmap".ToQuantity(collection.Beatmaps.Count)})";
+
+ Icon = FontAwesome.Regular.TrashAlt;
+
+ Buttons = new PopupDialogButton[]
+ {
+ new PopupDialogOkButton
+ {
+ Text = @"Yes. Go for it.",
+ Action = deleteAction
+ },
+ new PopupDialogCancelButton
+ {
+ Text = @"No! Abort mission!",
+ },
+ };
+ }
+ }
+}
diff --git a/osu.Game/Collections/DrawableCollectionList.cs b/osu.Game/Collections/DrawableCollectionList.cs
new file mode 100644
index 0000000000..3c664a11d9
--- /dev/null
+++ b/osu.Game/Collections/DrawableCollectionList.cs
@@ -0,0 +1,122 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Linq;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Game.Graphics.Containers;
+using osuTK;
+
+namespace osu.Game.Collections
+{
+ ///
+ /// Visualises a list of s.
+ ///
+ public class DrawableCollectionList : OsuRearrangeableListContainer
+ {
+ private Scroll scroll;
+
+ protected override ScrollContainer CreateScrollContainer() => scroll = new Scroll();
+
+ protected override FillFlowContainer> CreateListFillFlowContainer() => new Flow
+ {
+ DragActive = { BindTarget = DragActive }
+ };
+
+ protected override OsuRearrangeableListItem CreateOsuDrawable(BeatmapCollection item)
+ {
+ if (item == scroll.PlaceholderItem.Model)
+ return scroll.ReplacePlaceholder();
+
+ return new DrawableCollectionListItem(item, true);
+ }
+
+ ///
+ /// The scroll container for this .
+ /// Contains the main flow of and attaches a placeholder item to the end of the list.
+ ///
+ ///
+ /// Use to transfer the placeholder into the main list.
+ ///
+ private class Scroll : OsuScrollContainer
+ {
+ ///
+ /// The currently-displayed placeholder item.
+ ///
+ public DrawableCollectionListItem PlaceholderItem { get; private set; }
+
+ protected override Container Content => content;
+ private readonly Container content;
+
+ private readonly Container placeholderContainer;
+
+ public Scroll()
+ {
+ ScrollbarVisible = false;
+ Padding = new MarginPadding(10);
+
+ base.Content.Add(new FillFlowContainer
+ {
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ LayoutDuration = 200,
+ LayoutEasing = Easing.OutQuint,
+ Children = new Drawable[]
+ {
+ content = new Container { RelativeSizeAxes = Axes.X },
+ placeholderContainer = new Container
+ {
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y
+ }
+ }
+ });
+
+ ReplacePlaceholder();
+ }
+
+ protected override void Update()
+ {
+ base.Update();
+
+ // AutoSizeAxes cannot be used as the height should represent the post-layout-transform height at all times, so that the placeholder doesn't bounce around.
+ content.Height = ((Flow)Child).Children.Sum(c => c.DrawHeight + 5);
+ }
+
+ ///
+ /// Replaces the current with a new one, and returns the previous.
+ ///
+ /// The current .
+ public DrawableCollectionListItem ReplacePlaceholder()
+ {
+ var previous = PlaceholderItem;
+
+ placeholderContainer.Clear(false);
+ placeholderContainer.Add(PlaceholderItem = new DrawableCollectionListItem(new BeatmapCollection(), false));
+
+ return previous;
+ }
+ }
+
+ ///
+ /// The flow of . Disables layout easing unless a drag is in progress.
+ ///
+ private class Flow : FillFlowContainer>
+ {
+ public readonly IBindable DragActive = new Bindable();
+
+ public Flow()
+ {
+ Spacing = new Vector2(0, 5);
+ LayoutEasing = Easing.OutQuint;
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+ DragActive.BindValueChanged(active => LayoutDuration = active.NewValue ? 200 : 0);
+ }
+ }
+ }
+}
diff --git a/osu.Game/Collections/DrawableCollectionListItem.cs b/osu.Game/Collections/DrawableCollectionListItem.cs
new file mode 100644
index 0000000000..988a3443c3
--- /dev/null
+++ b/osu.Game/Collections/DrawableCollectionListItem.cs
@@ -0,0 +1,237 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Extensions.Color4Extensions;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Graphics.Sprites;
+using osu.Framework.Input.Events;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Containers;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Overlays;
+using osuTK;
+using osuTK.Graphics;
+
+namespace osu.Game.Collections
+{
+ ///
+ /// Visualises a inside a .
+ ///
+ public class DrawableCollectionListItem : OsuRearrangeableListItem
+ {
+ private const float item_height = 35;
+ private const float button_width = item_height * 0.75f;
+
+ ///
+ /// Whether the currently exists inside the .
+ ///
+ public IBindable IsCreated => isCreated;
+
+ private readonly Bindable isCreated = new Bindable();
+
+ ///
+ /// Creates a new .
+ ///
+ /// The .
+ /// Whether currently exists inside the .
+ public DrawableCollectionListItem(BeatmapCollection item, bool isCreated)
+ : base(item)
+ {
+ this.isCreated.Value = isCreated;
+
+ ShowDragHandle.BindTo(this.isCreated);
+ }
+
+ protected override Drawable CreateContent() => new ItemContent(Model)
+ {
+ IsCreated = { BindTarget = isCreated }
+ };
+
+ ///
+ /// The main content of the .
+ ///
+ private class ItemContent : CircularContainer
+ {
+ public readonly Bindable IsCreated = new Bindable();
+
+ private readonly IBindable collectionName;
+ private readonly BeatmapCollection collection;
+
+ [Resolved(CanBeNull = true)]
+ private CollectionManager collectionManager { get; set; }
+
+ private Container textBoxPaddingContainer;
+ private ItemTextBox textBox;
+
+ public ItemContent(BeatmapCollection collection)
+ {
+ this.collection = collection;
+
+ RelativeSizeAxes = Axes.X;
+ Height = item_height;
+ Masking = true;
+
+ collectionName = collection.Name.GetBoundCopy();
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(OsuColour colours)
+ {
+ Children = new Drawable[]
+ {
+ new DeleteButton(collection)
+ {
+ Anchor = Anchor.CentreRight,
+ Origin = Anchor.CentreRight,
+ IsCreated = { BindTarget = IsCreated },
+ IsTextBoxHovered = v => textBox.ReceivePositionalInputAt(v)
+ },
+ textBoxPaddingContainer = new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ Padding = new MarginPadding { Right = button_width },
+ Children = new Drawable[]
+ {
+ textBox = new ItemTextBox
+ {
+ RelativeSizeAxes = Axes.Both,
+ Size = Vector2.One,
+ CornerRadius = item_height / 2,
+ Current = collection.Name,
+ PlaceholderText = IsCreated.Value ? string.Empty : "Create a new collection"
+ },
+ }
+ },
+ };
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ collectionName.BindValueChanged(_ => createNewCollection(), true);
+ IsCreated.BindValueChanged(created => textBoxPaddingContainer.Padding = new MarginPadding { Right = created.NewValue ? button_width : 0 }, true);
+ }
+
+ private void createNewCollection()
+ {
+ if (IsCreated.Value)
+ return;
+
+ if (string.IsNullOrEmpty(collectionName.Value))
+ return;
+
+ // Add the new collection and disable our placeholder. If all text is removed, the placeholder should not show back again.
+ collectionManager?.Collections.Add(collection);
+ textBox.PlaceholderText = string.Empty;
+
+ // When this item changes from placeholder to non-placeholder (via changing containers), its textbox will lose focus, so it needs to be re-focused.
+ Schedule(() => GetContainingInputManager().ChangeFocus(textBox));
+
+ IsCreated.Value = true;
+ }
+ }
+
+ private class ItemTextBox : OsuTextBox
+ {
+ protected override float LeftRightPadding => item_height / 2;
+
+ [BackgroundDependencyLoader]
+ private void load(OsuColour colours)
+ {
+ BackgroundUnfocused = colours.GreySeafoamDarker.Darken(0.5f);
+ BackgroundFocused = colours.GreySeafoam;
+ }
+ }
+
+ public class DeleteButton : CompositeDrawable
+ {
+ public readonly IBindable IsCreated = new Bindable();
+
+ public Func IsTextBoxHovered;
+
+ [Resolved(CanBeNull = true)]
+ private DialogOverlay dialogOverlay { get; set; }
+
+ [Resolved(CanBeNull = true)]
+ private CollectionManager collectionManager { get; set; }
+
+ private readonly BeatmapCollection collection;
+
+ private Drawable fadeContainer;
+ private Drawable background;
+
+ public DeleteButton(BeatmapCollection collection)
+ {
+ this.collection = collection;
+ RelativeSizeAxes = Axes.Y;
+
+ Width = button_width + item_height / 2; // add corner radius to cover with fill
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(OsuColour colours)
+ {
+ InternalChild = fadeContainer = new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ Alpha = 0.1f,
+ Children = new[]
+ {
+ background = new Box
+ {
+ RelativeSizeAxes = Axes.Both,
+ Colour = colours.Red
+ },
+ new SpriteIcon
+ {
+ Anchor = Anchor.CentreRight,
+ Origin = Anchor.Centre,
+ X = -button_width * 0.6f,
+ Size = new Vector2(10),
+ Icon = FontAwesome.Solid.Trash
+ }
+ }
+ };
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+ IsCreated.BindValueChanged(created => Alpha = created.NewValue ? 1 : 0, true);
+ }
+
+ public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => base.ReceivePositionalInputAt(screenSpacePos) && !IsTextBoxHovered(screenSpacePos);
+
+ protected override bool OnHover(HoverEvent e)
+ {
+ fadeContainer.FadeTo(1f, 100, Easing.Out);
+ return false;
+ }
+
+ protected override void OnHoverLost(HoverLostEvent e)
+ {
+ fadeContainer.FadeTo(0.1f, 100);
+ }
+
+ protected override bool OnClick(ClickEvent e)
+ {
+ background.FlashColour(Color4.White, 150);
+
+ if (collection.Beatmaps.Count == 0)
+ deleteCollection();
+ else
+ dialogOverlay?.Push(new DeleteCollectionDialog(collection, deleteCollection));
+
+ return true;
+ }
+
+ private void deleteCollection() => collectionManager?.Collections.Remove(collection);
+ }
+ }
+}
diff --git a/osu.Game/Collections/ManageCollectionsDialog.cs b/osu.Game/Collections/ManageCollectionsDialog.cs
new file mode 100644
index 0000000000..680fec904f
--- /dev/null
+++ b/osu.Game/Collections/ManageCollectionsDialog.cs
@@ -0,0 +1,134 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Graphics.Sprites;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Containers;
+using osu.Game.Graphics.Sprites;
+using osu.Game.Graphics.UserInterface;
+using osuTK;
+
+namespace osu.Game.Collections
+{
+ public class ManageCollectionsDialog : OsuFocusedOverlayContainer
+ {
+ private const double enter_duration = 500;
+ private const double exit_duration = 200;
+
+ [Resolved(CanBeNull = true)]
+ private CollectionManager collectionManager { get; set; }
+
+ public ManageCollectionsDialog()
+ {
+ Anchor = Anchor.Centre;
+ Origin = Anchor.Centre;
+
+ RelativeSizeAxes = Axes.Both;
+ Size = new Vector2(0.5f, 0.8f);
+
+ Masking = true;
+ CornerRadius = 10;
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(OsuColour colours)
+ {
+ Children = new Drawable[]
+ {
+ new Box
+ {
+ Colour = colours.GreySeafoamDark,
+ RelativeSizeAxes = Axes.Both,
+ },
+ new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ Child = new GridContainer
+ {
+ RelativeSizeAxes = Axes.Both,
+ RowDimensions = new[]
+ {
+ new Dimension(GridSizeMode.AutoSize),
+ },
+ Content = new[]
+ {
+ new Drawable[]
+ {
+ new Container
+ {
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ Children = new Drawable[]
+ {
+ new OsuSpriteText
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Text = "Manage collections",
+ Font = OsuFont.GetFont(size: 30),
+ Padding = new MarginPadding { Vertical = 10 },
+ },
+ new IconButton
+ {
+ Anchor = Anchor.CentreRight,
+ Origin = Anchor.CentreRight,
+ Icon = FontAwesome.Solid.Times,
+ Colour = colours.GreySeafoamDarker,
+ Scale = new Vector2(0.8f),
+ X = -10,
+ Action = () => State.Value = Visibility.Hidden
+ }
+ }
+ }
+ },
+ new Drawable[]
+ {
+ new Container
+ {
+ RelativeSizeAxes = Axes.Both,
+ Children = new Drawable[]
+ {
+ new Box
+ {
+ RelativeSizeAxes = Axes.Both,
+ Colour = colours.GreySeafoamDarker
+ },
+ new DrawableCollectionList
+ {
+ RelativeSizeAxes = Axes.Both,
+ Items = { BindTarget = collectionManager?.Collections ?? new BindableList() }
+ }
+ }
+ }
+ },
+ }
+ }
+ }
+ };
+ }
+
+ protected override void PopIn()
+ {
+ base.PopIn();
+
+ this.FadeIn(enter_duration, Easing.OutQuint);
+ this.ScaleTo(0.9f).Then().ScaleTo(1f, enter_duration, Easing.OutQuint);
+ }
+
+ protected override void PopOut()
+ {
+ base.PopOut();
+
+ this.FadeOut(exit_duration, Easing.OutQuint);
+ this.ScaleTo(0.9f, exit_duration);
+
+ // Ensure that textboxes commit
+ GetContainingInputManager()?.TriggerFocusContention(this);
+ }
+ }
+}
diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs
index 915d980d24..49d7edd56c 100644
--- a/osu.Game/Database/ArchiveModelManager.cs
+++ b/osu.Game/Database/ArchiveModelManager.cs
@@ -397,15 +397,24 @@ namespace osu.Game.Database
}
}
+ ///
+ /// Update an existing file, or create a new entry if not already part of the 's files.
+ ///
+ /// The item to operate on.
+ /// The file model to be updated or added.
+ /// The new file contents.
public void UpdateFile(TModel model, TFileModel file, Stream contents)
{
using (var usage = ContextFactory.GetForWrite())
{
// Dereference the existing file info, since the file model will be removed.
- Files.Dereference(file.FileInfo);
+ if (file.FileInfo != null)
+ {
+ Files.Dereference(file.FileInfo);
- // Remove the file model.
- usage.Context.Set().Remove(file);
+ // Remove the file model.
+ usage.Context.Set().Remove(file);
+ }
// Add the new file info and containing file model.
model.Files.Remove(file);
diff --git a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs
index 751ccc8f15..41fd37a0d7 100644
--- a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs
+++ b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs
@@ -103,6 +103,8 @@ namespace osu.Game.Graphics.Containers
{
}
+ private bool playedPopInSound;
+
protected override void UpdateState(ValueChangedEvent state)
{
switch (state.NewValue)
@@ -110,16 +112,24 @@ namespace osu.Game.Graphics.Containers
case Visibility.Visible:
if (OverlayActivationMode.Value == OverlayActivation.Disabled)
{
+ // todo: visual/audible feedback that this operation could not complete.
State.Value = Visibility.Hidden;
return;
}
samplePopIn?.Play();
+ playedPopInSound = true;
+
if (BlockScreenWideMouse && DimMainContent) game?.AddBlockingOverlay(this);
break;
case Visibility.Hidden:
- samplePopOut?.Play();
+ if (playedPopInSound)
+ {
+ samplePopOut?.Play();
+ playedPopInSound = false;
+ }
+
if (BlockScreenWideMouse) game?.RemoveBlockingOverlay(this);
break;
}
diff --git a/osu.Game/Graphics/Containers/OsuRearrangeableListContainer.cs b/osu.Game/Graphics/Containers/OsuRearrangeableListContainer.cs
index 47aed1c500..1048fd094c 100644
--- a/osu.Game/Graphics/Containers/OsuRearrangeableListContainer.cs
+++ b/osu.Game/Graphics/Containers/OsuRearrangeableListContainer.cs
@@ -12,13 +12,13 @@ namespace osu.Game.Graphics.Containers
///
/// Whether any item is currently being dragged. Used to hide other items' drag handles.
///
- private readonly BindableBool playlistDragActive = new BindableBool();
+ protected readonly BindableBool DragActive = new BindableBool();
protected override ScrollContainer CreateScrollContainer() => new OsuScrollContainer();
protected sealed override RearrangeableListItem CreateDrawable(TModel item) => CreateOsuDrawable(item).With(d =>
{
- d.PlaylistDragActive.BindTo(playlistDragActive);
+ d.DragActive.BindTo(DragActive);
});
protected abstract OsuRearrangeableListItem CreateOsuDrawable(TModel item);
diff --git a/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs b/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs
index 29553954fe..9cdcb19a81 100644
--- a/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs
+++ b/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs
@@ -19,7 +19,7 @@ namespace osu.Game.Graphics.Containers
///
/// Whether any item is currently being dragged. Used to hide other items' drag handles.
///
- public readonly BindableBool PlaylistDragActive = new BindableBool();
+ public readonly BindableBool DragActive = new BindableBool();
private Color4 handleColour = Color4.White;
@@ -44,8 +44,9 @@ namespace osu.Game.Graphics.Containers
///
/// Whether the drag handle should be shown.
///
- protected virtual bool ShowDragHandle => true;
+ protected readonly Bindable ShowDragHandle = new Bindable();
+ private Container handleContainer;
private PlaylistItemHandle handle;
protected OsuRearrangeableListItem(TModel item)
@@ -58,8 +59,6 @@ namespace osu.Game.Graphics.Containers
[BackgroundDependencyLoader]
private void load()
{
- Container handleContainer;
-
InternalChild = new GridContainer
{
RelativeSizeAxes = Axes.X,
@@ -88,9 +87,12 @@ namespace osu.Game.Graphics.Containers
ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) },
RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }
};
+ }
- if (!ShowDragHandle)
- handleContainer.Alpha = 0;
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+ ShowDragHandle.BindValueChanged(show => handleContainer.Alpha = show.NewValue ? 1 : 0, true);
}
protected override bool OnDragStart(DragStartEvent e)
@@ -98,13 +100,13 @@ namespace osu.Game.Graphics.Containers
if (!base.OnDragStart(e))
return false;
- PlaylistDragActive.Value = true;
+ DragActive.Value = true;
return true;
}
protected override void OnDragEnd(DragEndEvent e)
{
- PlaylistDragActive.Value = false;
+ DragActive.Value = false;
base.OnDragEnd(e);
}
@@ -112,7 +114,7 @@ namespace osu.Game.Graphics.Containers
protected override bool OnHover(HoverEvent e)
{
- handle.UpdateHoverState(IsDragged || !PlaylistDragActive.Value);
+ handle.UpdateHoverState(IsDragged || !DragActive.Value);
return base.OnHover(e);
}
diff --git a/osu.Game/Graphics/Sprites/GlowingSpriteText.cs b/osu.Game/Graphics/Sprites/GlowingSpriteText.cs
index 4aea5aa518..85df2d167f 100644
--- a/osu.Game/Graphics/Sprites/GlowingSpriteText.cs
+++ b/osu.Game/Graphics/Sprites/GlowingSpriteText.cs
@@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers;
@@ -55,6 +56,12 @@ namespace osu.Game.Graphics.Sprites
set => spriteText.UseFullGlyphHeight = blurredText.UseFullGlyphHeight = value;
}
+ public Bindable Current
+ {
+ get => spriteText.Current;
+ set => spriteText.Current = value;
+ }
+
public GlowingSpriteText()
{
AutoSizeAxes = Axes.Both;
diff --git a/osu.Game/Graphics/UserInterface/IconButton.cs b/osu.Game/Graphics/UserInterface/IconButton.cs
index d7e5666545..858f517985 100644
--- a/osu.Game/Graphics/UserInterface/IconButton.cs
+++ b/osu.Game/Graphics/UserInterface/IconButton.cs
@@ -24,7 +24,7 @@ namespace osu.Game.Graphics.UserInterface
set
{
iconColour = value;
- icon.Colour = value;
+ icon.FadeColour(value);
}
}
diff --git a/osu.Game/Graphics/UserInterface/OsuContextMenu.cs b/osu.Game/Graphics/UserInterface/OsuContextMenu.cs
index 4b629080e1..8c7b44f952 100644
--- a/osu.Game/Graphics/UserInterface/OsuContextMenu.cs
+++ b/osu.Game/Graphics/UserInterface/OsuContextMenu.cs
@@ -26,6 +26,8 @@ namespace osu.Game.Graphics.UserInterface
};
ItemsContainer.Padding = new MarginPadding { Vertical = DrawableOsuMenuItem.MARGIN_VERTICAL };
+
+ MaxHeight = 250;
}
[BackgroundDependencyLoader]
diff --git a/osu.Game/Graphics/UserInterface/OsuDropdown.cs b/osu.Game/Graphics/UserInterface/OsuDropdown.cs
index fc3a7229fa..cc76c12975 100644
--- a/osu.Game/Graphics/UserInterface/OsuDropdown.cs
+++ b/osu.Game/Graphics/UserInterface/OsuDropdown.cs
@@ -17,6 +17,8 @@ namespace osu.Game.Graphics.UserInterface
{
public class OsuDropdown : Dropdown, IHasAccentColour
{
+ private const float corner_radius = 4;
+
private Color4 accentColour;
public Color4 AccentColour
@@ -57,9 +59,11 @@ namespace osu.Game.Graphics.UserInterface
// todo: this uses the same styling as OsuMenu. hopefully we can just use OsuMenu in the future with some refactoring
public OsuDropdownMenu()
{
- CornerRadius = 4;
+ CornerRadius = corner_radius;
BackgroundColour = Color4.Black.Opacity(0.5f);
+ MaskingContainer.CornerRadius = corner_radius;
+
// todo: this uses the same styling as OsuMenu. hopefully we can just use OsuMenu in the future with some refactoring
ItemsContainer.Padding = new MarginPadding(5);
}
@@ -138,7 +142,7 @@ namespace osu.Game.Graphics.UserInterface
Foreground.Padding = new MarginPadding(2);
Masking = true;
- CornerRadius = 6;
+ CornerRadius = corner_radius;
}
[BackgroundDependencyLoader]
@@ -237,7 +241,7 @@ namespace osu.Game.Graphics.UserInterface
AutoSizeAxes = Axes.None;
Margin = new MarginPadding { Bottom = 4 };
- CornerRadius = 4;
+ CornerRadius = corner_radius;
Height = 40;
Foreground.Children = new Drawable[]
diff --git a/osu.Game/Graphics/UserInterface/RollingCounter.cs b/osu.Game/Graphics/UserInterface/RollingCounter.cs
index ece1b8e22c..91a557094d 100644
--- a/osu.Game/Graphics/UserInterface/RollingCounter.cs
+++ b/osu.Game/Graphics/UserInterface/RollingCounter.cs
@@ -9,16 +9,20 @@ using System;
using System.Collections.Generic;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
+using osu.Framework.Graphics.UserInterface;
namespace osu.Game.Graphics.UserInterface
{
- public abstract class RollingCounter : Container
+ public abstract class RollingCounter : Container, IHasCurrentValue
where T : struct, IEquatable
{
- ///
- /// The current value.
- ///
- public Bindable Current = new Bindable();
+ private readonly BindableWithCurrent current = new BindableWithCurrent();
+
+ public Bindable Current
+ {
+ get => current.Current;
+ set => current.Current = value;
+ }
private SpriteText displayedCountSpriteText;
diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs
index 2cbe095d0b..290aba3468 100644
--- a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs
+++ b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs
@@ -3,6 +3,7 @@
using osu.Framework.Allocation;
using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.UserInterface;
using osu.Game.Graphics.UserInterface;
@@ -32,6 +33,11 @@ namespace osu.Game.Graphics.UserInterfaceV2
set => Component.Text = value;
}
+ public Container TabbableContentContainer
+ {
+ set => Component.TabbableContentContainer = value;
+ }
+
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
diff --git a/osu.Game/IO/WrappedStorage.cs b/osu.Game/IO/WrappedStorage.cs
index 1dd3afbfae..5b2549d2ee 100644
--- a/osu.Game/IO/WrappedStorage.cs
+++ b/osu.Game/IO/WrappedStorage.cs
@@ -25,7 +25,13 @@ namespace osu.Game.IO
this.subPath = subPath;
}
- protected virtual string MutatePath(string path) => !string.IsNullOrEmpty(subPath) ? Path.Combine(subPath, path) : path;
+ protected virtual string MutatePath(string path)
+ {
+ if (path == null)
+ return null;
+
+ return !string.IsNullOrEmpty(subPath) ? Path.Combine(subPath, path) : path;
+ }
protected virtual void ChangeTargetStorage(Storage newStorage)
{
diff --git a/osu.Game/Online/Leaderboards/Leaderboard.cs b/osu.Game/Online/Leaderboards/Leaderboard.cs
index db0f835c67..084ba89f6e 100644
--- a/osu.Game/Online/Leaderboards/Leaderboard.cs
+++ b/osu.Game/Online/Leaderboards/Leaderboard.cs
@@ -56,13 +56,14 @@ namespace osu.Game.Online.Leaderboards
scrollFlow?.FadeOut(fade_duration, Easing.OutQuint).Expire();
scrollFlow = null;
- loading.Hide();
-
showScoresDelegate?.Cancel();
showScoresCancellationSource?.Cancel();
if (scores == null || !scores.Any())
+ {
+ loading.Hide();
return;
+ }
// ensure placeholder is hidden when displaying scores
PlaceholderState = PlaceholderState.Successful;
@@ -84,6 +85,7 @@ namespace osu.Game.Online.Leaderboards
}
scrollContainer.ScrollTo(0f, false);
+ loading.Hide();
}, (showScoresCancellationSource = new CancellationTokenSource()).Token));
}
}
diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs
index 87b283f6b5..dcd0cb435a 100644
--- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs
+++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs
@@ -72,7 +72,7 @@ namespace osu.Game.Online.Leaderboards
}
[BackgroundDependencyLoader]
- private void load(IAPIProvider api, OsuColour colour)
+ private void load(IAPIProvider api, OsuColour colour, ScoreManager scoreManager)
{
var user = score.User;
@@ -194,7 +194,7 @@ namespace osu.Game.Online.Leaderboards
{
TextColour = Color4.White,
GlowColour = Color4Extensions.FromHex(@"83ccfa"),
- Text = score.TotalScore.ToString(@"N0"),
+ Current = scoreManager.GetBindableTotalScoreString(score),
Font = OsuFont.Numeric.With(size: 23),
},
RankContainer = new Container
diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs
index a8722d03ab..4a699dc82e 100644
--- a/osu.Game/OsuGame.cs
+++ b/osu.Game/OsuGame.cs
@@ -31,6 +31,7 @@ using osu.Framework.Input.Events;
using osu.Framework.Platform;
using osu.Framework.Threading;
using osu.Game.Beatmaps;
+using osu.Game.Collections;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
@@ -38,6 +39,7 @@ using osu.Game.Input;
using osu.Game.Overlays.Notifications;
using osu.Game.Input.Bindings;
using osu.Game.Online.Chat;
+using osu.Game.Overlays.Music;
using osu.Game.Skinning;
using osuTK.Graphics;
using osu.Game.Overlays.Volume;
@@ -609,12 +611,19 @@ namespace osu.Game
d.Origin = Anchor.TopRight;
}), rightFloatingOverlayContent.Add, true);
+ loadComponentSingleFile(new CollectionManager(Storage)
+ {
+ PostNotification = n => notifications.Post(n),
+ GetStableStorage = GetStorageForStableInstall
+ }, Add, true);
+
loadComponentSingleFile(screenshotManager, Add);
// dependency on notification overlay, dependent by settings overlay
loadComponentSingleFile(CreateUpdateManager(), Add, true);
// overlay elements
+ loadComponentSingleFile(new ManageCollectionsDialog(), overlayContent.Add, true);
loadComponentSingleFile(beatmapListing = new BeatmapListingOverlay(), overlayContent.Add, true);
loadComponentSingleFile(dashboard = new DashboardOverlay(), overlayContent.Add, true);
loadComponentSingleFile(news = new NewsOverlay(), overlayContent.Add, true);
@@ -647,6 +656,7 @@ namespace osu.Game
chatOverlay.State.ValueChanged += state => channelManager.HighPollRate.Value = state.NewValue == Visibility.Visible;
Add(externalLinkOpener = new ExternalLinkOpener());
+ Add(new MusicKeyBindingHandler());
// side overlays which cancel each other.
var singleDisplaySideOverlays = new OverlayContainer[] { Settings, notifications };
diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs
index 51b9b7278d..b1269e9300 100644
--- a/osu.Game/OsuGameBase.cs
+++ b/osu.Game/OsuGameBase.cs
@@ -58,6 +58,8 @@ namespace osu.Game
protected ScoreManager ScoreManager;
+ protected BeatmapDifficultyManager DifficultyManager;
+
protected SkinManager SkinManager;
protected RulesetStore RulesetStore;
@@ -197,8 +199,8 @@ namespace osu.Game
dependencies.Cache(FileStore = new FileStore(contextFactory, Storage));
// ordering is important here to ensure foreign keys rules are not broken in ModelStore.Cleanup()
- dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, API, contextFactory, Host));
- dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, contextFactory, RulesetStore, API, Audio, Host, defaultBeatmap));
+ dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, API, contextFactory, Host, () => DifficultyManager, LocalConfig));
+ dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, contextFactory, RulesetStore, API, Audio, Host, defaultBeatmap, true));
// this should likely be moved to ArchiveModelManager when another case appers where it is necessary
// to have inter-dependent model managers. this could be obtained with an IHasForeign interface to
@@ -221,9 +223,8 @@ namespace osu.Game
ScoreManager.Undelete(getBeatmapScores(item), true);
});
- var difficultyManager = new BeatmapDifficultyManager();
- dependencies.Cache(difficultyManager);
- AddInternal(difficultyManager);
+ dependencies.Cache(DifficultyManager = new BeatmapDifficultyManager());
+ AddInternal(DifficultyManager);
dependencies.Cache(KeyBindingStore = new KeyBindingStore(contextFactory, RulesetStore));
dependencies.Cache(SettingsStore = new SettingsStore(contextFactory));
@@ -250,10 +251,11 @@ namespace osu.Game
AddInternal(apiAccess);
AddInternal(RulesetConfigCache);
- GlobalActionContainer globalBinding;
-
MenuCursorContainer = new MenuCursorContainer { RelativeSizeAxes = Axes.Both };
- MenuCursorContainer.Child = globalBinding = new GlobalActionContainer(this)
+
+ GlobalActionContainer globalBindings;
+
+ MenuCursorContainer.Child = globalBindings = new GlobalActionContainer(this)
{
RelativeSizeAxes = Axes.Both,
Child = content = new OsuTooltipContainer(MenuCursorContainer.Cursor) { RelativeSizeAxes = Axes.Both }
@@ -261,8 +263,8 @@ namespace osu.Game
base.Content.Add(CreateScalingContainer().WithChild(MenuCursorContainer));
- KeyBindingStore.Register(globalBinding);
- dependencies.Cache(globalBinding);
+ KeyBindingStore.Register(globalBindings);
+ dependencies.Cache(globalBindings);
PreviewTrackManager previewTrackManager;
dependencies.Cache(previewTrackManager = new PreviewTrackManager());
diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs
index 097ca27bf7..56866765b6 100644
--- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs
+++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs
@@ -25,6 +25,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
private const float row_height = 22;
private const int text_size = 12;
+ [Resolved]
+ private ScoreManager scoreManager { get; set; }
+
private readonly FillFlowContainer backgroundFlow;
private Color4 highAccuracyColour;
@@ -121,7 +124,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
new OsuSpriteText
{
Margin = new MarginPadding { Right = horizontal_inset },
- Text = $@"{score.TotalScore:N0}",
+ Current = scoreManager.GetBindableTotalScoreString(score),
Font = OsuFont.GetFont(size: text_size, weight: index == 0 ? FontWeight.Bold : FontWeight.Medium)
},
new OsuSpriteText
diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs
index a92346e0fe..3a842d0a43 100644
--- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs
+++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs
@@ -4,6 +4,7 @@
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
+using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@@ -38,6 +39,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
private readonly FillFlowContainer statisticsColumns;
private readonly ModsInfoColumn modsColumn;
+ [Resolved]
+ private ScoreManager scoreManager { get; set; }
+
public TopScoreStatisticsSection()
{
RelativeSizeAxes = Axes.X;
@@ -87,6 +91,15 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
};
}
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ if (score != null)
+ totalScoreColumn.Current = scoreManager.GetBindableTotalScoreString(score);
+ }
+
+ private ScoreInfo score;
+
///
/// Sets the score to be displayed.
///
@@ -94,7 +107,11 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
{
set
{
- totalScoreColumn.Text = $@"{value.TotalScore:N0}";
+ if (score == value)
+ return;
+
+ score = value;
+
accuracyColumn.Text = value.DisplayAccuracy;
maxComboColumn.Text = $@"{value.MaxCombo:N0}x";
ppColumn.Alpha = value.Beatmap?.Status == BeatmapSetOnlineStatus.Ranked ? 1 : 0;
@@ -102,6 +119,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
statisticsColumns.ChildrenEnumerable = value.SortedStatistics.Select(kvp => createStatisticsColumn(kvp.Key, kvp.Value));
modsColumn.Mods = value.Mods;
+
+ if (scoreManager != null)
+ totalScoreColumn.Current = scoreManager.GetBindableTotalScoreString(value);
}
}
@@ -190,6 +210,12 @@ namespace osu.Game.Overlays.BeatmapSet.Scores
{
set => text.Text = value;
}
+
+ public Bindable Current
+ {
+ get => text.Current;
+ set => text.Current = value;
+ }
}
private class ModsInfoColumn : InfoColumn
diff --git a/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs b/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs
new file mode 100644
index 0000000000..e6edfb1e3e
--- /dev/null
+++ b/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs
@@ -0,0 +1,81 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Input.Bindings;
+using osu.Game.Beatmaps;
+using osu.Game.Input.Bindings;
+using osu.Game.Overlays.OSD;
+
+namespace osu.Game.Overlays.Music
+{
+ ///
+ /// Handles s related to music playback, and displays s via the global accordingly.
+ ///
+ public class MusicKeyBindingHandler : Component, IKeyBindingHandler
+ {
+ [Resolved]
+ private IBindable beatmap { get; set; }
+
+ [Resolved]
+ private MusicController musicController { get; set; }
+
+ [Resolved(canBeNull: true)]
+ private OnScreenDisplay onScreenDisplay { get; set; }
+
+ public bool OnPressed(GlobalAction action)
+ {
+ if (beatmap.Disabled)
+ return false;
+
+ switch (action)
+ {
+ case GlobalAction.MusicPlay:
+ // use previous state as TogglePause may not update the track's state immediately (state update is run on the audio thread see https://github.com/ppy/osu/issues/9880#issuecomment-674668842)
+ bool wasPlaying = musicController.IsPlaying;
+
+ if (musicController.TogglePause())
+ onScreenDisplay?.Display(new MusicActionToast(wasPlaying ? "Pause track" : "Play track"));
+ return true;
+
+ case GlobalAction.MusicNext:
+ musicController.NextTrack(() => onScreenDisplay?.Display(new MusicActionToast("Next track")));
+
+ return true;
+
+ case GlobalAction.MusicPrev:
+ musicController.PreviousTrack(res =>
+ {
+ switch (res)
+ {
+ case PreviousTrackResult.Restart:
+ onScreenDisplay?.Display(new MusicActionToast("Restart track"));
+ break;
+
+ case PreviousTrackResult.Previous:
+ onScreenDisplay?.Display(new MusicActionToast("Previous track"));
+ break;
+ }
+ });
+
+ return true;
+ }
+
+ return false;
+ }
+
+ public void OnReleased(GlobalAction action)
+ {
+ }
+
+ private class MusicActionToast : Toast
+ {
+ public MusicActionToast(string action)
+ : base("Music Playback", action, string.Empty)
+ {
+ }
+ }
+ }
+}
diff --git a/osu.Game/Overlays/MusicController.cs b/osu.Game/Overlays/MusicController.cs
index 31bd80d6f3..b568e4d02b 100644
--- a/osu.Game/Overlays/MusicController.cs
+++ b/osu.Game/Overlays/MusicController.cs
@@ -12,12 +12,9 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Audio;
using osu.Framework.Graphics.Containers;
-using osu.Framework.Input.Bindings;
using osu.Framework.Utils;
using osu.Framework.Threading;
using osu.Game.Beatmaps;
-using osu.Game.Input.Bindings;
-using osu.Game.Overlays.OSD;
using osu.Game.Rulesets.Mods;
namespace osu.Game.Overlays
@@ -25,7 +22,7 @@ namespace osu.Game.Overlays
///
/// Handles playback of the global music track.
///
- public class MusicController : CompositeDrawable, IKeyBindingHandler
+ public class MusicController : CompositeDrawable
{
[Resolved]
private BeatmapManager beatmaps { get; set; }
@@ -62,9 +59,6 @@ namespace osu.Game.Overlays
[Resolved]
private IBindable> mods { get; set; }
- [Resolved(canBeNull: true)]
- private OnScreenDisplay onScreenDisplay { get; set; }
-
[NotNull]
public DrawableTrack CurrentTrack { get; private set; } = new DrawableTrack(new TrackVirtual(1000));
@@ -207,7 +201,13 @@ namespace osu.Game.Overlays
///
/// Play the previous track or restart the current track if it's current time below .
///
- public void PreviousTrack() => Schedule(() => prev());
+ /// Invoked when the operation has been performed successfully.
+ public void PreviousTrack(Action onSuccess = null) => Schedule(() =>
+ {
+ PreviousTrackResult res = prev();
+ if (res != PreviousTrackResult.None)
+ onSuccess?.Invoke(res);
+ });
///
/// Play the previous track or restart the current track if it's current time below .
@@ -243,7 +243,14 @@ namespace osu.Game.Overlays
///
/// Play the next random or playlist track.
///
- public void NextTrack() => Schedule(() => next());
+ /// Invoked when the operation has been performed successfully.
+ /// A of the operation.
+ public void NextTrack(Action onSuccess = null) => Schedule(() =>
+ {
+ bool res = next();
+ if (res)
+ onSuccess?.Invoke();
+ });
private bool next()
{
@@ -407,54 +414,6 @@ namespace osu.Game.Overlays
mod.ApplyToTrack(CurrentTrack);
}
}
-
- public bool OnPressed(GlobalAction action)
- {
- if (beatmap.Disabled)
- return false;
-
- switch (action)
- {
- case GlobalAction.MusicPlay:
- if (TogglePause())
- onScreenDisplay?.Display(new MusicControllerToast(IsPlaying ? "Play track" : "Pause track"));
- return true;
-
- case GlobalAction.MusicNext:
- if (next())
- onScreenDisplay?.Display(new MusicControllerToast("Next track"));
-
- return true;
-
- case GlobalAction.MusicPrev:
- switch (prev())
- {
- case PreviousTrackResult.Restart:
- onScreenDisplay?.Display(new MusicControllerToast("Restart track"));
- break;
-
- case PreviousTrackResult.Previous:
- onScreenDisplay?.Display(new MusicControllerToast("Previous track"));
- break;
- }
-
- return true;
- }
-
- return false;
- }
-
- public void OnReleased(GlobalAction action)
- {
- }
-
- public class MusicControllerToast : Toast
- {
- public MusicControllerToast(string action)
- : base("Music Playback", action, string.Empty)
- {
- }
- }
}
public enum TrackChangeDirection
diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs
index 832673703b..848ce381a9 100644
--- a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs
+++ b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs
@@ -3,9 +3,11 @@
using System.Linq;
using System.Threading.Tasks;
+using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Beatmaps;
+using osu.Game.Collections;
using osu.Game.Graphics.UserInterface;
using osu.Game.Scoring;
using osu.Game.Skinning;
@@ -19,14 +21,15 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
private TriangleButton importBeatmapsButton;
private TriangleButton importScoresButton;
private TriangleButton importSkinsButton;
+ private TriangleButton importCollectionsButton;
private TriangleButton deleteBeatmapsButton;
private TriangleButton deleteScoresButton;
private TriangleButton deleteSkinsButton;
private TriangleButton restoreButton;
private TriangleButton undeleteButton;
- [BackgroundDependencyLoader]
- private void load(BeatmapManager beatmaps, ScoreManager scores, SkinManager skins, DialogOverlay dialogOverlay)
+ [BackgroundDependencyLoader(permitNulls: true)]
+ private void load(BeatmapManager beatmaps, ScoreManager scores, SkinManager skins, [CanBeNull] CollectionManager collectionManager, DialogOverlay dialogOverlay)
{
if (beatmaps.SupportsImportFromStable)
{
@@ -93,20 +96,46 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance
});
}
- AddRange(new Drawable[]
+ Add(deleteSkinsButton = new DangerousSettingsButton
{
- deleteSkinsButton = new DangerousSettingsButton
+ Text = "Delete ALL skins",
+ Action = () =>
{
- Text = "Delete ALL skins",
+ dialogOverlay?.Push(new DeleteAllBeatmapsDialog(() =>
+ {
+ deleteSkinsButton.Enabled.Value = false;
+ Task.Run(() => skins.Delete(skins.GetAllUserSkins())).ContinueWith(t => Schedule(() => deleteSkinsButton.Enabled.Value = true));
+ }));
+ }
+ });
+
+ if (collectionManager != null)
+ {
+ if (collectionManager.SupportsImportFromStable)
+ {
+ Add(importCollectionsButton = new SettingsButton
+ {
+ Text = "Import collections from stable",
+ Action = () =>
+ {
+ importCollectionsButton.Enabled.Value = false;
+ collectionManager.ImportFromStableAsync().ContinueWith(t => Schedule(() => importCollectionsButton.Enabled.Value = true));
+ }
+ });
+ }
+
+ Add(new DangerousSettingsButton
+ {
+ Text = "Delete ALL collections",
Action = () =>
{
- dialogOverlay?.Push(new DeleteAllBeatmapsDialog(() =>
- {
- deleteSkinsButton.Enabled.Value = false;
- Task.Run(() => skins.Delete(skins.GetAllUserSkins())).ContinueWith(t => Schedule(() => deleteSkinsButton.Enabled.Value = true));
- }));
+ dialogOverlay?.Push(new DeleteAllBeatmapsDialog(collectionManager.DeleteAll));
}
- },
+ });
+ }
+
+ AddRange(new Drawable[]
+ {
restoreButton = new SettingsButton
{
Text = "Restore all hidden difficulties",
diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs
index b4b4bb9cd1..732dc772b7 100644
--- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs
+++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs
@@ -12,6 +12,7 @@ namespace osu.Game.Rulesets.Difficulty
public Skill[] Skills;
public double StarRating;
+ public int MaxCombo;
public DifficultyAttributes()
{
diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs
index f134db1ffe..b9cc054ed3 100644
--- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs
+++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs
@@ -6,6 +6,7 @@ using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using osu.Framework.Allocation;
+using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input;
@@ -13,6 +14,7 @@ using osu.Framework.Input.Events;
using osu.Framework.Logging;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Overlays.Settings;
using osu.Game.Rulesets.Configuration;
using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Mods;
@@ -92,9 +94,18 @@ namespace osu.Game.Rulesets.Edit
Name = "Sidebar",
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Right = 10 },
+ Spacing = new Vector2(10),
Children = new Drawable[]
{
- new ToolboxGroup { Child = toolboxCollection = new RadioButtonCollection { RelativeSizeAxes = Axes.X } }
+ new ToolboxGroup("toolbox") { Child = toolboxCollection = new RadioButtonCollection { RelativeSizeAxes = Axes.X } },
+ new ToolboxGroup("toggles")
+ {
+ ChildrenEnumerable = Toggles.Select(b => new SettingsCheckbox
+ {
+ Bindable = b,
+ LabelText = b?.Description ?? "unknown"
+ })
+ }
}
},
new Container
@@ -126,7 +137,7 @@ namespace osu.Game.Rulesets.Edit
toolboxCollection.Items = CompositionTools
.Prepend(new SelectTool())
- .Select(t => new RadioButton(t.Name, () => toolSelected(t)))
+ .Select(t => new RadioButton(t.Name, () => toolSelected(t), t.CreateIcon))
.ToList();
setSelectTool();
@@ -156,6 +167,12 @@ namespace osu.Game.Rulesets.Edit
///
protected abstract IReadOnlyList CompositionTools { get; }
+ ///
+ /// A collection of toggles which will be displayed to the user.
+ /// The display name will be decided by .
+ ///
+ protected virtual IEnumerable Toggles => Enumerable.Empty();
+
///
/// Construct a relevant blueprint container. This will manage hitobject selection/placement input handling and display logic.
///
diff --git a/osu.Game/Rulesets/Edit/ToolboxGroup.cs b/osu.Game/Rulesets/Edit/ToolboxGroup.cs
index eabb834616..22b2b05657 100644
--- a/osu.Game/Rulesets/Edit/ToolboxGroup.cs
+++ b/osu.Game/Rulesets/Edit/ToolboxGroup.cs
@@ -8,9 +8,8 @@ namespace osu.Game.Rulesets.Edit
{
public class ToolboxGroup : PlayerSettingsGroup
{
- protected override string Title => "toolbox";
-
- public ToolboxGroup()
+ public ToolboxGroup(string title)
+ : base(title)
{
RelativeSizeAxes = Axes.X;
Width = 1;
diff --git a/osu.Game/Rulesets/Edit/Tools/HitObjectCompositionTool.cs b/osu.Game/Rulesets/Edit/Tools/HitObjectCompositionTool.cs
index 0631031302..0a01ac4320 100644
--- a/osu.Game/Rulesets/Edit/Tools/HitObjectCompositionTool.cs
+++ b/osu.Game/Rulesets/Edit/Tools/HitObjectCompositionTool.cs
@@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using osu.Framework.Graphics;
+
namespace osu.Game.Rulesets.Edit.Tools
{
public abstract class HitObjectCompositionTool
@@ -14,6 +16,8 @@ namespace osu.Game.Rulesets.Edit.Tools
public abstract PlacementBlueprint CreatePlacementBlueprint();
+ public virtual Drawable CreateIcon() => null;
+
public override string ToString() => Name;
}
}
diff --git a/osu.Game/Rulesets/Edit/Tools/SelectTool.cs b/osu.Game/Rulesets/Edit/Tools/SelectTool.cs
index b96eeb0790..c050766b23 100644
--- a/osu.Game/Rulesets/Edit/Tools/SelectTool.cs
+++ b/osu.Game/Rulesets/Edit/Tools/SelectTool.cs
@@ -1,6 +1,9 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Sprites;
+
namespace osu.Game.Rulesets.Edit.Tools
{
public class SelectTool : HitObjectCompositionTool
@@ -10,6 +13,8 @@ namespace osu.Game.Rulesets.Edit.Tools
{
}
+ public override Drawable CreateIcon() => new SpriteIcon { Icon = FontAwesome.Solid.MousePointer };
+
public override PlacementBlueprint CreatePlacementBlueprint() => null;
}
}
diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs
index eac47aa089..6fa5a87c8e 100644
--- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs
+++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs
@@ -67,7 +67,7 @@ namespace osu.Game.Rulesets.Scoring
private readonly double accuracyPortion;
private readonly double comboPortion;
- private double maxHighestCombo;
+ private int maxHighestCombo;
private double maxBaseScore;
private double rollingMaxBaseScore;
private double baseScore;
@@ -203,19 +203,36 @@ namespace osu.Game.Rulesets.Scoring
}
private double getScore(ScoringMode mode)
+ {
+ return GetScore(mode, maxHighestCombo,
+ maxBaseScore > 0 ? baseScore / maxBaseScore : 0,
+ maxHighestCombo > 0 ? (double)HighestCombo.Value / maxHighestCombo : 0,
+ bonusScore);
+ }
+
+ ///
+ /// Computes the total score.
+ ///
+ /// The to compute the total score in.
+ /// The maximum combo achievable in the beatmap.
+ /// The accuracy percentage achieved by the player.
+ /// The proportion of achieved by the player.
+ /// Any bonus score to be added.
+ /// The total score.
+ public double GetScore(ScoringMode mode, int maxCombo, double accuracyRatio, double comboRatio, double bonusScore)
{
switch (mode)
{
default:
case ScoringMode.Standardised:
- double accuracyScore = accuracyPortion * baseScore / maxBaseScore;
- double comboScore = comboPortion * HighestCombo.Value / maxHighestCombo;
+ double accuracyScore = accuracyPortion * accuracyRatio;
+ double comboScore = comboPortion * comboRatio;
return (max_score * (accuracyScore + comboScore) + bonusScore) * scoreMultiplier;
case ScoringMode.Classic:
// should emulate osu-stable's scoring as closely as we can (https://osu.ppy.sh/help/wiki/Score/ScoreV1)
- return bonusScore + baseScore * (1 + Math.Max(0, HighestCombo.Value - 1) * scoreMultiplier / 25);
+ return bonusScore + (accuracyRatio * maxCombo * 300) * (1 + Math.Max(0, (comboRatio * maxCombo) - 1) * scoreMultiplier / 25);
}
}
diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs
index d5bd486e43..619ca76598 100644
--- a/osu.Game/Scoring/ScoreManager.cs
+++ b/osu.Game/Scoring/ScoreManager.cs
@@ -6,15 +6,20 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Linq.Expressions;
+using System.Threading;
+using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
+using osu.Framework.Bindables;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Beatmaps;
+using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.IO.Archives;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Rulesets;
+using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring.Legacy;
namespace osu.Game.Scoring
@@ -30,11 +35,20 @@ namespace osu.Game.Scoring
private readonly RulesetStore rulesets;
private readonly Func beatmaps;
- public ScoreManager(RulesetStore rulesets, Func beatmaps, Storage storage, IAPIProvider api, IDatabaseContextFactory contextFactory, IIpcHost importHost = null)
+ [CanBeNull]
+ private readonly Func difficulties;
+
+ [CanBeNull]
+ private readonly OsuConfigManager configManager;
+
+ public ScoreManager(RulesetStore rulesets, Func beatmaps, Storage storage, IAPIProvider api, IDatabaseContextFactory contextFactory, IIpcHost importHost = null,
+ Func difficulties = null, OsuConfigManager configManager = null)
: base(storage, contextFactory, api, new ScoreStore(contextFactory, storage), importHost)
{
this.rulesets = rulesets;
this.beatmaps = beatmaps;
+ this.difficulties = difficulties;
+ this.configManager = configManager;
}
protected override ScoreInfo CreateModel(ArchiveReader archive)
@@ -72,5 +86,118 @@ namespace osu.Game.Scoring
protected override bool CheckLocalAvailability(ScoreInfo model, IQueryable items)
=> base.CheckLocalAvailability(model, items)
|| (model.OnlineScoreID != null && items.Any(i => i.OnlineScoreID == model.OnlineScoreID));
+
+ ///
+ /// Retrieves a bindable that represents the total score of a .
+ ///
+ ///
+ /// Responds to changes in the currently-selected .
+ ///
+ /// The to retrieve the bindable for.
+ /// The bindable containing the total score.
+ public Bindable GetBindableTotalScore(ScoreInfo score)
+ {
+ var bindable = new TotalScoreBindable(score, difficulties);
+ configManager?.BindWith(OsuSetting.ScoreDisplayMode, bindable.ScoringMode);
+ return bindable;
+ }
+
+ ///
+ /// Retrieves a bindable that represents the formatted total score string of a .
+ ///
+ ///
+ /// Responds to changes in the currently-selected .
+ ///
+ /// The to retrieve the bindable for.
+ /// The bindable containing the formatted total score string.
+ public Bindable GetBindableTotalScoreString(ScoreInfo score) => new TotalScoreStringBindable(GetBindableTotalScore(score));
+
+ ///
+ /// Provides the total score of a . Responds to changes in the currently-selected .
+ ///
+ private class TotalScoreBindable : Bindable
+ {
+ public readonly Bindable ScoringMode = new Bindable();
+
+ private readonly ScoreInfo score;
+ private readonly Func difficulties;
+
+ ///
+ /// Creates a new .
+ ///
+ /// The to provide the total score of.
+ /// A function to retrieve the .
+ public TotalScoreBindable(ScoreInfo score, Func difficulties)
+ {
+ this.score = score;
+ this.difficulties = difficulties;
+
+ ScoringMode.BindValueChanged(onScoringModeChanged, true);
+ }
+
+ private IBindable difficultyBindable;
+ private CancellationTokenSource difficultyCancellationSource;
+
+ private void onScoringModeChanged(ValueChangedEvent mode)
+ {
+ difficultyCancellationSource?.Cancel();
+ difficultyCancellationSource = null;
+
+ if (score.Beatmap == null)
+ {
+ Value = score.TotalScore;
+ return;
+ }
+
+ int? beatmapMaxCombo = score.Beatmap.MaxCombo;
+
+ if (beatmapMaxCombo == null)
+ {
+ if (score.Beatmap.ID == 0 || difficulties == null)
+ {
+ // We don't have enough information (max combo) to compute the score, so let's use the provided score.
+ Value = score.TotalScore;
+ return;
+ }
+
+ // We can compute the max combo locally after the async beatmap difficulty computation.
+ difficultyBindable = difficulties().GetBindableDifficulty(score.Beatmap, score.Ruleset, score.Mods, (difficultyCancellationSource = new CancellationTokenSource()).Token);
+ difficultyBindable.BindValueChanged(d => updateScore(d.NewValue.MaxCombo), true);
+ }
+ else
+ updateScore(beatmapMaxCombo.Value);
+ }
+
+ private void updateScore(int beatmapMaxCombo)
+ {
+ if (beatmapMaxCombo == 0)
+ {
+ Value = 0;
+ return;
+ }
+
+ var ruleset = score.Ruleset.CreateInstance();
+ var scoreProcessor = ruleset.CreateScoreProcessor();
+
+ scoreProcessor.Mods.Value = score.Mods;
+
+ Value = (long)Math.Round(scoreProcessor.GetScore(ScoringMode.Value, beatmapMaxCombo, score.Accuracy, (double)score.MaxCombo / beatmapMaxCombo, 0));
+ }
+ }
+
+ ///
+ /// Provides the total score of a as a formatted string. Responds to changes in the currently-selected .
+ ///
+ private class TotalScoreStringBindable : Bindable
+ {
+ // ReSharper disable once PrivateFieldCanBeConvertedToLocalVariable (need to hold a reference)
+ private readonly IBindable totalScore;
+
+ public TotalScoreStringBindable(IBindable totalScore)
+ {
+ this.totalScore = totalScore;
+ this.totalScore.BindValueChanged(v => Value = v.NewValue.ToString("N0"), true);
+ }
+ }
}
}
diff --git a/osu.Game/Screens/Edit/Components/Menus/ScreenSelectionTabControl.cs b/osu.Game/Screens/Edit/Components/Menus/ScreenSelectionTabControl.cs
index 089da4f222..b8bc5cdf36 100644
--- a/osu.Game/Screens/Edit/Components/Menus/ScreenSelectionTabControl.cs
+++ b/osu.Game/Screens/Edit/Components/Menus/ScreenSelectionTabControl.cs
@@ -32,8 +32,6 @@ namespace osu.Game.Screens.Edit.Components.Menus
Height = 1,
Colour = Color4.White.Opacity(0.2f),
});
-
- Current.Value = EditorScreenMode.Compose;
}
[BackgroundDependencyLoader]
diff --git a/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs b/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs
index 7be91f4e8e..0cf7b83f3b 100644
--- a/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs
+++ b/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs
@@ -5,7 +5,6 @@ using System;
using osu.Framework.Allocation;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
@@ -29,7 +28,7 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons
private Color4 selectedBackgroundColour;
private Color4 selectedBubbleColour;
- private readonly Drawable bubble;
+ private Drawable icon;
private readonly RadioButton button;
public DrawableRadioButton(RadioButton button)
@@ -40,19 +39,6 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons
Action = button.Select;
RelativeSizeAxes = Axes.X;
-
- bubble = new CircularContainer
- {
- Anchor = Anchor.CentreLeft,
- Origin = Anchor.CentreLeft,
- RelativeSizeAxes = Axes.Both,
- FillMode = FillMode.Fit,
- Scale = new Vector2(0.5f),
- X = 10,
- Masking = true,
- Blending = BlendingParameters.Additive,
- Child = new Box { RelativeSizeAxes = Axes.Both }
- };
}
[BackgroundDependencyLoader]
@@ -73,7 +59,14 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons
Colour = Color4.Black.Opacity(0.5f)
};
- Add(bubble);
+ Add(icon = (button.CreateIcon?.Invoke() ?? new Circle()).With(b =>
+ {
+ b.Blending = BlendingParameters.Additive;
+ b.Anchor = Anchor.CentreLeft;
+ b.Origin = Anchor.CentreLeft;
+ b.Size = new Vector2(20);
+ b.X = 10;
+ }));
}
protected override void LoadComplete()
@@ -96,7 +89,7 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons
return;
BackgroundColour = button.Selected.Value ? selectedBackgroundColour : defaultBackgroundColour;
- bubble.Colour = button.Selected.Value ? selectedBubbleColour : defaultBubbleColour;
+ icon.Colour = button.Selected.Value ? selectedBubbleColour : defaultBubbleColour;
}
protected override SpriteText CreateText() => new OsuSpriteText
diff --git a/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs b/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs
index b515d7c8bd..a7b0fb05e3 100644
--- a/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs
+++ b/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs
@@ -3,6 +3,7 @@
using System;
using osu.Framework.Bindables;
+using osu.Framework.Graphics;
namespace osu.Game.Screens.Edit.Components.RadioButtons
{
@@ -19,11 +20,17 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons
///
public object Item;
+ ///
+ /// A function which creates a drawable icon to represent this item. If null, a sane default should be used.
+ ///
+ public readonly Func CreateIcon;
+
private readonly Action action;
- public RadioButton(object item, Action action)
+ public RadioButton(object item, Action action, Func createIcon = null)
{
Item = item;
+ CreateIcon = createIcon;
this.action = action;
Selected = new BindableBool();
}
diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs
index fcff672045..865e225645 100644
--- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs
+++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs
@@ -61,7 +61,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
AddRangeInternal(new[]
{
- DragBox = CreateDragBox(select),
+ DragBox = CreateDragBox(selectBlueprintsFromDragRectangle),
selectionHandler,
SelectionBlueprints = CreateSelectionBlueprintContainer(),
selectionHandler.CreateProxy(),
@@ -326,7 +326,7 @@ namespace osu.Game.Screens.Edit.Compose.Components
/// Select all masks in a given rectangle selection area.
///
/// The rectangle to perform a selection on in screen-space coordinates.
- private void select(RectangleF rect)
+ private void selectBlueprintsFromDragRectangle(RectangleF rect)
{
foreach (var blueprint in SelectionBlueprints)
{
diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs
index 768e30c6b6..23eb704920 100644
--- a/osu.Game/Screens/Edit/Editor.cs
+++ b/osu.Game/Screens/Edit/Editor.cs
@@ -2,38 +2,40 @@
// See the LICENCE file in the repository root for full licence text.
using System;
-using osuTK.Graphics;
-using osu.Framework.Screens;
+using System.Collections.Generic;
+using osu.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
-using osu.Game.Graphics;
-using osu.Game.Screens.Edit.Components.Timelines.Summary;
-using osu.Framework.Allocation;
-using osu.Framework.Bindables;
using osu.Framework.Graphics.UserInterface;
-using osu.Framework.Input.Events;
-using osu.Framework.Platform;
-using osu.Framework.Timing;
-using osu.Game.Graphics.UserInterface;
-using osu.Game.Screens.Edit.Components;
-using osu.Game.Screens.Edit.Components.Menus;
-using osu.Game.Screens.Edit.Design;
-using osuTK.Input;
-using System.Collections.Generic;
-using osu.Framework;
using osu.Framework.Input;
using osu.Framework.Input.Bindings;
+using osu.Framework.Input.Events;
using osu.Framework.Logging;
+using osu.Framework.Platform;
+using osu.Framework.Screens;
+using osu.Framework.Timing;
using osu.Game.Beatmaps;
+using osu.Game.Graphics;
using osu.Game.Graphics.Cursor;
+using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
+using osu.Game.Online.API;
+using osu.Game.Overlays;
using osu.Game.Rulesets.Edit;
+using osu.Game.Screens.Edit.Components;
+using osu.Game.Screens.Edit.Components.Menus;
+using osu.Game.Screens.Edit.Components.Timelines.Summary;
using osu.Game.Screens.Edit.Compose;
+using osu.Game.Screens.Edit.Design;
using osu.Game.Screens.Edit.Setup;
using osu.Game.Screens.Edit.Timing;
using osu.Game.Screens.Play;
using osu.Game.Users;
+using osuTK.Graphics;
+using osuTK.Input;
namespace osu.Game.Screens.Edit
{
@@ -50,9 +52,18 @@ namespace osu.Game.Screens.Edit
public override bool AllowRateAdjustments => false;
+ protected bool HasUnsavedChanges => lastSavedHash != changeHandler.CurrentStateHash;
+
[Resolved]
private BeatmapManager beatmapManager { get; set; }
+ [Resolved(canBeNull: true)]
+ private DialogOverlay dialogOverlay { get; set; }
+
+ private bool exitConfirmed;
+
+ private string lastSavedHash;
+
private Box bottomBackground;
private Container screenContainer;
@@ -72,6 +83,9 @@ namespace osu.Game.Screens.Edit
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
=> dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
+ [Resolved]
+ private IAPIProvider api { get; set; }
+
[BackgroundDependencyLoader]
private void load(OsuColour colours, GameHost host)
{
@@ -89,6 +103,14 @@ namespace osu.Game.Screens.Edit
// todo: remove caching of this and consume via editorBeatmap?
dependencies.Cache(beatDivisor);
+ bool isNewBeatmap = false;
+
+ if (Beatmap.Value is DummyWorkingBeatmap)
+ {
+ isNewBeatmap = true;
+ Beatmap.Value = beatmapManager.CreateNew(Ruleset.Value, api.LocalUser.Value);
+ }
+
try
{
playableBeatmap = Beatmap.Value.GetPlayableBeatmap(Beatmap.Value.BeatmapInfo.Ruleset);
@@ -106,13 +128,15 @@ namespace osu.Game.Screens.Edit
changeHandler = new EditorChangeHandler(editorBeatmap);
dependencies.CacheAs(changeHandler);
+ updateLastSavedHash();
+
EditorMenuBar menuBar;
OsuMenuItem undoMenuItem;
OsuMenuItem redoMenuItem;
var fileMenuItems = new List