diff --git a/osu.Android.props b/osu.Android.props
index 69f897128c..650ebde54d 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -51,7 +51,7 @@
-
+
diff --git a/osu.Game.Rulesets.Catch.Tests/CatchSkinColourDecodingTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchSkinColourDecodingTest.cs
new file mode 100644
index 0000000000..7deeec527f
--- /dev/null
+++ b/osu.Game.Rulesets.Catch.Tests/CatchSkinColourDecodingTest.cs
@@ -0,0 +1,36 @@
+// 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.IO.Stores;
+using osu.Game.Rulesets.Catch.Skinning;
+using osu.Game.Skinning;
+using osuTK.Graphics;
+
+namespace osu.Game.Rulesets.Catch.Tests
+{
+ [TestFixture]
+ public class CatchSkinColourDecodingTest
+ {
+ [Test]
+ public void TestCatchSkinColourDecoding()
+ {
+ var store = new NamespacedResourceStore(new DllResourceStore(GetType().Assembly), "Resources/special-skin");
+ var rawSkin = new TestLegacySkin(new SkinInfo { Name = "special-skin" }, store);
+ var skin = new CatchLegacySkinTransformer(rawSkin);
+
+ Assert.AreEqual(new Color4(232, 185, 35, 255), skin.GetConfig(CatchSkinColour.HyperDash)?.Value);
+ Assert.AreEqual(new Color4(232, 74, 35, 255), skin.GetConfig(CatchSkinColour.HyperDashAfterImage)?.Value);
+ Assert.AreEqual(new Color4(0, 255, 255, 255), skin.GetConfig(CatchSkinColour.HyperDashFruit)?.Value);
+ }
+
+ private class TestLegacySkin : LegacySkin
+ {
+ public TestLegacySkin(SkinInfo skin, IResourceStore storage)
+ // Bypass LegacySkinResourceStore to avoid returning null for retrieving files due to bad skin info (SkinInfo.Files = null).
+ : base(skin, storage, null, "skin.ini")
+ {
+ }
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/skin.ini b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/skin.ini
new file mode 100644
index 0000000000..96d50f1451
--- /dev/null
+++ b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/skin.ini
@@ -0,0 +1,4 @@
+[CatchTheBeat]
+HyperDash: 232,185,35
+HyperDashFruit: 0,255,255
+HyperDashAfterImage: 232,74,35
diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs
index acc5f4e428..3a3e664690 100644
--- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs
+++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs
@@ -8,6 +8,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
namespace osu.Game.Rulesets.Catch.Tests
{
@@ -23,7 +24,7 @@ namespace osu.Game.Rulesets.Catch.Tests
[BackgroundDependencyLoader]
private void load()
{
- SetContents(() => new Catcher
+ SetContents(() => new Catcher(new Container())
{
RelativePositionAxes = Axes.None,
Anchor = Anchor.Centre,
diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs
index a48ecb9b79..1e708cce4b 100644
--- a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs
+++ b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs
@@ -26,6 +26,48 @@ namespace osu.Game.Rulesets.Catch.Tests
[Resolved]
private SkinManager skins { get; set; }
+ [Test]
+ public void TestDefaultCatcherColour()
+ {
+ var skin = new TestSkin();
+
+ checkHyperDashCatcherColour(skin, Catcher.DEFAULT_HYPER_DASH_COLOUR);
+ }
+
+ [Test]
+ public void TestCustomCatcherColour()
+ {
+ var skin = new TestSkin
+ {
+ HyperDashColour = Color4.Goldenrod
+ };
+
+ checkHyperDashCatcherColour(skin, skin.HyperDashColour);
+ }
+
+ [Test]
+ public void TestCustomEndGlowColour()
+ {
+ var skin = new TestSkin
+ {
+ HyperDashAfterImageColour = Color4.Lime
+ };
+
+ checkHyperDashCatcherColour(skin, Catcher.DEFAULT_HYPER_DASH_COLOUR, skin.HyperDashAfterImageColour);
+ }
+
+ [Test]
+ public void TestCustomEndGlowColourPriority()
+ {
+ var skin = new TestSkin
+ {
+ HyperDashColour = Color4.Goldenrod,
+ HyperDashAfterImageColour = Color4.Lime
+ };
+
+ checkHyperDashCatcherColour(skin, skin.HyperDashColour, skin.HyperDashAfterImageColour);
+ }
+
[Test]
public void TestDefaultFruitColour()
{
@@ -68,6 +110,38 @@ namespace osu.Game.Rulesets.Catch.Tests
checkHyperDashFruitColour(skin, skin.HyperDashColour);
}
+ private void checkHyperDashCatcherColour(ISkin skin, Color4 expectedCatcherColour, Color4? expectedEndGlowColour = null)
+ {
+ CatcherArea catcherArea = null;
+ CatcherTrailDisplay trails = null;
+
+ AddStep("create hyper-dashing catcher", () =>
+ {
+ Child = setupSkinHierarchy(catcherArea = new CatcherArea
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Scale = new Vector2(4f),
+ }, skin);
+
+ trails = catcherArea.OfType().Single();
+ catcherArea.MovableCatcher.SetHyperDashState(2);
+ });
+
+ AddUntilStep("catcher colour is correct", () => catcherArea.MovableCatcher.Colour == expectedCatcherColour);
+
+ AddAssert("catcher trails colours are correct", () => trails.HyperDashTrailsColour == expectedCatcherColour);
+ AddAssert("catcher end-glow colours are correct", () => trails.EndGlowSpritesColour == (expectedEndGlowColour ?? expectedCatcherColour));
+
+ AddStep("finish hyper-dashing", () =>
+ {
+ catcherArea.MovableCatcher.SetHyperDashState(1);
+ catcherArea.MovableCatcher.FinishTransforms();
+ });
+
+ AddAssert("catcher colour returned to white", () => catcherArea.MovableCatcher.Colour == Color4.White);
+ }
+
private void checkHyperDashFruitColour(ISkin skin, Color4 expectedColour)
{
DrawableFruit drawableFruit = null;
diff --git a/osu.Game.Rulesets.Catch/Skinning/CatchLegacySkinTransformer.cs b/osu.Game.Rulesets.Catch/Skinning/CatchLegacySkinTransformer.cs
index 4a87eb95e7..954f2dfc5f 100644
--- a/osu.Game.Rulesets.Catch/Skinning/CatchLegacySkinTransformer.cs
+++ b/osu.Game.Rulesets.Catch/Skinning/CatchLegacySkinTransformer.cs
@@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.Catch.Skinning
{
private readonly ISkin source;
- public CatchLegacySkinTransformer(ISkinSource source)
+ public CatchLegacySkinTransformer(ISkin source)
{
this.source = source;
}
diff --git a/osu.Game.Rulesets.Catch/UI/Catcher.cs b/osu.Game.Rulesets.Catch/UI/Catcher.cs
index daf9456919..9cce46d730 100644
--- a/osu.Game.Rulesets.Catch/UI/Catcher.cs
+++ b/osu.Game.Rulesets.Catch/UI/Catcher.cs
@@ -3,26 +3,37 @@
using System;
using System.Linq;
+using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Animations;
using osu.Framework.Graphics.Containers;
-using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Bindings;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.Objects.Drawables;
+using osu.Game.Rulesets.Catch.Skinning;
using osu.Game.Rulesets.Objects.Drawables;
+using osu.Game.Skinning;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Catch.UI
{
- public class Catcher : Container, IKeyBindingHandler
+ public class Catcher : SkinReloadableDrawable, IKeyBindingHandler
{
+ ///
+ /// The default colour used to tint hyper-dash fruit, along with the moving catcher, its trail
+ /// and end glow/after-image during a hyper-dash.
+ ///
public static readonly Color4 DEFAULT_HYPER_DASH_COLOUR = Color4.Red;
+ ///
+ /// The duration between transitioning to hyper-dash state.
+ ///
+ public const double HYPER_DASH_TRANSITION_DURATION = 180;
+
///
/// Whether we are hyper-dashing or not.
///
@@ -35,7 +46,10 @@ namespace osu.Game.Rulesets.Catch.UI
public Container ExplodingFruitTarget;
- public Container AdditiveTarget;
+ [NotNull]
+ private readonly Container trailsTarget;
+
+ private CatcherTrailDisplay trails;
public CatcherAnimationState CurrentState { get; private set; }
@@ -44,33 +58,23 @@ namespace osu.Game.Rulesets.Catch.UI
///
private const float allowed_catch_range = 0.8f;
- protected bool Dashing
+ ///
+ /// The drawable catcher for .
+ ///
+ internal Drawable CurrentDrawableCatcher => currentCatcher.Drawable;
+
+ private bool dashing;
+
+ public bool Dashing
{
get => dashing;
- set
+ protected set
{
if (value == dashing) return;
dashing = value;
- Trail |= dashing;
- }
- }
-
- ///
- /// Activate or deactivate the trail. Will be automatically deactivated when conditions to keep the trail displayed are no longer met.
- ///
- protected bool Trail
- {
- get => trail;
- set
- {
- if (value == trail || AdditiveTarget == null) return;
-
- trail = value;
-
- if (Trail)
- beginTrail();
+ updateTrailVisibility();
}
}
@@ -87,18 +91,19 @@ namespace osu.Game.Rulesets.Catch.UI
private CatcherSprite currentCatcher;
+ private Color4 hyperDashColour = DEFAULT_HYPER_DASH_COLOUR;
+ private Color4 hyperDashEndGlowColour = DEFAULT_HYPER_DASH_COLOUR;
+
private int currentDirection;
- private bool dashing;
-
- private bool trail;
-
private double hyperDashModifier = 1;
private int hyperDashDirection;
private float hyperDashTargetPosition;
- public Catcher(BeatmapDifficulty difficulty = null)
+ public Catcher([NotNull] Container trailsTarget, BeatmapDifficulty difficulty = null)
{
+ this.trailsTarget = trailsTarget;
+
RelativePositionAxes = Axes.X;
X = 0.5f;
@@ -114,7 +119,7 @@ namespace osu.Game.Rulesets.Catch.UI
[BackgroundDependencyLoader]
private void load()
{
- Children = new Drawable[]
+ InternalChildren = new Drawable[]
{
caughtFruit = new Container
{
@@ -138,6 +143,8 @@ namespace osu.Game.Rulesets.Catch.UI
}
};
+ trailsTarget.Add(trails = new CatcherTrailDisplay(this));
+
updateCatcher();
}
@@ -185,7 +192,7 @@ namespace osu.Game.Rulesets.Catch.UI
caughtFruit.Add(fruit);
- Add(new HitExplosion(fruit)
+ AddInternal(new HitExplosion(fruit)
{
X = fruit.X,
Scale = new Vector2(fruit.HitObject.Scale)
@@ -240,8 +247,6 @@ namespace osu.Game.Rulesets.Catch.UI
/// When this catcher crosses this position, this catcher ends hyper-dashing.
public void SetHyperDashState(double modifier = 1, float targetPosition = -1)
{
- const float hyper_dash_transition_length = 180;
-
var wasHyperDashing = HyperDashing;
if (modifier <= 1 || X == targetPosition)
@@ -250,11 +255,7 @@ namespace osu.Game.Rulesets.Catch.UI
hyperDashDirection = 0;
if (wasHyperDashing)
- {
- this.FadeColour(Color4.White, hyper_dash_transition_length, Easing.OutQuint);
- this.FadeTo(1, hyper_dash_transition_length, Easing.OutQuint);
- Trail &= Dashing;
- }
+ runHyperDashStateTransition(false);
}
else
{
@@ -264,20 +265,32 @@ namespace osu.Game.Rulesets.Catch.UI
if (!wasHyperDashing)
{
- this.FadeColour(Color4.OrangeRed, hyper_dash_transition_length, Easing.OutQuint);
- this.FadeTo(0.2f, hyper_dash_transition_length, Easing.OutQuint);
- Trail = true;
-
- var hyperDashEndGlow = createAdditiveSprite();
-
- hyperDashEndGlow.MoveToOffset(new Vector2(0, -10), 1200, Easing.In);
- hyperDashEndGlow.ScaleTo(hyperDashEndGlow.Scale * 0.95f).ScaleTo(hyperDashEndGlow.Scale * 1.2f, 1200, Easing.In);
- hyperDashEndGlow.FadeOut(1200);
- hyperDashEndGlow.Expire(true);
+ trails.DisplayEndGlow();
+ runHyperDashStateTransition(true);
}
}
}
+ private void runHyperDashStateTransition(bool hyperDashing)
+ {
+ trails.HyperDashTrailsColour = hyperDashColour;
+ trails.EndGlowSpritesColour = hyperDashEndGlowColour;
+ updateTrailVisibility();
+
+ if (hyperDashing)
+ {
+ this.FadeColour(hyperDashColour, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint);
+ this.FadeTo(0.2f, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint);
+ }
+ else
+ {
+ this.FadeColour(Color4.White, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint);
+ this.FadeTo(1f, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint);
+ }
+ }
+
+ private void updateTrailVisibility() => trails.DisplayTrail = Dashing || HyperDashing;
+
public bool OnPressed(CatchAction action)
{
switch (action)
@@ -366,6 +379,21 @@ namespace osu.Game.Rulesets.Catch.UI
});
}
+ protected override void SkinChanged(ISkinSource skin, bool allowFallback)
+ {
+ base.SkinChanged(skin, allowFallback);
+
+ hyperDashColour =
+ skin.GetConfig(CatchSkinColour.HyperDash)?.Value ??
+ DEFAULT_HYPER_DASH_COLOUR;
+
+ hyperDashEndGlowColour =
+ skin.GetConfig(CatchSkinColour.HyperDashAfterImage)?.Value ??
+ hyperDashColour;
+
+ runHyperDashStateTransition(HyperDashing);
+ }
+
protected override void Update()
{
base.Update();
@@ -411,22 +439,6 @@ namespace osu.Game.Rulesets.Catch.UI
(currentCatcher.Drawable as IFramedAnimation)?.GotoFrame(0);
}
- private void beginTrail()
- {
- if (!dashing && !HyperDashing)
- {
- Trail = false;
- return;
- }
-
- var additive = createAdditiveSprite();
-
- additive.FadeTo(0.4f).FadeOut(800, Easing.OutQuint);
- additive.Expire(true);
-
- Scheduler.AddDelayed(beginTrail, HyperDashing ? 25 : 50);
- }
-
private void updateState(CatcherAnimationState state)
{
if (CurrentState == state)
@@ -436,25 +448,6 @@ namespace osu.Game.Rulesets.Catch.UI
updateCatcher();
}
- private CatcherTrailSprite createAdditiveSprite()
- {
- var tex = (currentCatcher.Drawable as TextureAnimation)?.CurrentFrame ?? ((Sprite)currentCatcher.Drawable).Texture;
-
- var sprite = new CatcherTrailSprite(tex)
- {
- Anchor = Anchor,
- Scale = Scale,
- Colour = HyperDashing ? Color4.Red : Color4.White,
- Blending = BlendingParameters.Additive,
- RelativePositionAxes = RelativePositionAxes,
- Position = Position
- };
-
- AdditiveTarget?.Add(sprite);
-
- return sprite;
- }
-
private void removeFromPlateWithTransform(DrawableHitObject fruit, Action action)
{
if (ExplodingFruitTarget != null)
diff --git a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs
index e0d9ff759d..37d177b936 100644
--- a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs
+++ b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs
@@ -33,10 +33,7 @@ namespace osu.Game.Rulesets.Catch.UI
{
RelativeSizeAxes = Axes.X;
Height = CATCHER_SIZE;
- Child = MovableCatcher = new Catcher(difficulty)
- {
- AdditiveTarget = this,
- };
+ Child = MovableCatcher = new Catcher(this, difficulty);
}
public static float GetCatcherSize(BeatmapDifficulty difficulty)
diff --git a/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs
new file mode 100644
index 0000000000..bab3cb748b
--- /dev/null
+++ b/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs
@@ -0,0 +1,135 @@
+// 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 JetBrains.Annotations;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Animations;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Sprites;
+using osuTK;
+using osuTK.Graphics;
+
+namespace osu.Game.Rulesets.Catch.UI
+{
+ ///
+ /// Represents a component responsible for displaying
+ /// the appropriate catcher trails when requested to.
+ ///
+ public class CatcherTrailDisplay : CompositeDrawable
+ {
+ private readonly Catcher catcher;
+
+ private readonly Container dashTrails;
+ private readonly Container hyperDashTrails;
+ private readonly Container endGlowSprites;
+
+ private Color4 hyperDashTrailsColour;
+
+ public Color4 HyperDashTrailsColour
+ {
+ get => hyperDashTrailsColour;
+ set
+ {
+ if (hyperDashTrailsColour == value)
+ return;
+
+ hyperDashTrailsColour = value;
+ hyperDashTrails.FadeColour(hyperDashTrailsColour, Catcher.HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint);
+ }
+ }
+
+ private Color4 endGlowSpritesColour;
+
+ public Color4 EndGlowSpritesColour
+ {
+ get => endGlowSpritesColour;
+ set
+ {
+ if (endGlowSpritesColour == value)
+ return;
+
+ endGlowSpritesColour = value;
+ endGlowSprites.FadeColour(endGlowSpritesColour, Catcher.HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint);
+ }
+ }
+
+ private bool trail;
+
+ ///
+ /// Whether to start displaying trails following the catcher.
+ ///
+ public bool DisplayTrail
+ {
+ get => trail;
+ set
+ {
+ if (trail == value)
+ return;
+
+ trail = value;
+
+ if (trail)
+ displayTrail();
+ }
+ }
+
+ public CatcherTrailDisplay([NotNull] Catcher catcher)
+ {
+ this.catcher = catcher ?? throw new ArgumentNullException(nameof(catcher));
+
+ RelativeSizeAxes = Axes.Both;
+
+ InternalChildren = new[]
+ {
+ dashTrails = new Container { RelativeSizeAxes = Axes.Both },
+ hyperDashTrails = new Container { RelativeSizeAxes = Axes.Both, Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR },
+ endGlowSprites = new Container { RelativeSizeAxes = Axes.Both, Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR },
+ };
+ }
+
+ ///
+ /// Displays a single end-glow catcher sprite.
+ ///
+ public void DisplayEndGlow()
+ {
+ var endGlow = createTrailSprite(endGlowSprites);
+
+ endGlow.MoveToOffset(new Vector2(0, -10), 1200, Easing.In);
+ endGlow.ScaleTo(endGlow.Scale * 0.95f).ScaleTo(endGlow.Scale * 1.2f, 1200, Easing.In);
+ endGlow.FadeOut(1200);
+ endGlow.Expire(true);
+ }
+
+ private void displayTrail()
+ {
+ if (!DisplayTrail)
+ return;
+
+ var sprite = createTrailSprite(catcher.HyperDashing ? hyperDashTrails : dashTrails);
+
+ sprite.FadeTo(0.4f).FadeOut(800, Easing.OutQuint);
+ sprite.Expire(true);
+
+ Scheduler.AddDelayed(displayTrail, catcher.HyperDashing ? 25 : 50);
+ }
+
+ private CatcherTrailSprite createTrailSprite(Container target)
+ {
+ var texture = (catcher.CurrentDrawableCatcher as TextureAnimation)?.CurrentFrame ?? ((Sprite)catcher.CurrentDrawableCatcher).Texture;
+
+ var sprite = new CatcherTrailSprite(texture)
+ {
+ Anchor = catcher.Anchor,
+ Scale = catcher.Scale,
+ Blending = BlendingParameters.Additive,
+ RelativePositionAxes = catcher.RelativePositionAxes,
+ Position = catcher.Position
+ };
+
+ target.Add(sprite);
+
+ return sprite;
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs
index b3dd392202..c63e30e98a 100644
--- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs
+++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs
@@ -7,6 +7,7 @@ using osu.Framework.Input.Events;
using osu.Game.Rulesets.Mania.Edit.Blueprints.Components;
using osu.Game.Rulesets.Mania.Objects;
using osuTK;
+using osuTK.Input;
namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{
@@ -49,6 +50,9 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
protected override void OnMouseUp(MouseUpEvent e)
{
+ if (e.Button != MouseButton.Left)
+ return;
+
base.OnMouseUp(e);
EndPlacement(true);
}
diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs
index 400abb6380..3fb03d642f 100644
--- a/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs
+++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/ManiaPlacementBlueprint.cs
@@ -11,6 +11,7 @@ using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.UI.Scrolling;
using osuTK;
+using osuTK.Input;
namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{
@@ -46,6 +47,9 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
protected override bool OnMouseDown(MouseDownEvent e)
{
+ if (e.Button != MouseButton.Left)
+ return false;
+
if (Column == null)
return base.OnMouseDown(e);
diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs
index 2b7b383dbe..a4c0791253 100644
--- a/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs
+++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/NotePlacementBlueprint.cs
@@ -5,6 +5,7 @@ using osu.Framework.Graphics;
using osu.Framework.Input.Events;
using osu.Game.Rulesets.Mania.Edit.Blueprints.Components;
using osu.Game.Rulesets.Mania.Objects;
+using osuTK.Input;
namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{
@@ -30,6 +31,9 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
protected override bool OnMouseDown(MouseDownEvent e)
{
+ if (e.Button != MouseButton.Left)
+ return false;
+
base.OnMouseDown(e);
// Place the note immediately.
diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs
new file mode 100644
index 0000000000..bd3b360577
--- /dev/null
+++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs
@@ -0,0 +1,223 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Humanizer;
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Extensions.IEnumerableExtensions;
+using osu.Framework.Graphics;
+using osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Rulesets.Judgements;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Scoring;
+using osu.Game.Rulesets.Taiko.Judgements;
+using osu.Game.Rulesets.Taiko.Objects;
+using osu.Game.Rulesets.Taiko.Scoring;
+using osu.Game.Rulesets.Taiko.UI;
+using osu.Game.Rulesets.UI.Scrolling;
+using osu.Game.Tests.Visual;
+
+namespace osu.Game.Rulesets.Taiko.Tests.Skinning
+{
+ [TestFixture]
+ public class TestSceneDrawableTaikoMascot : TaikoSkinnableTestScene
+ {
+ public override IReadOnlyList RequiredTypes => base.RequiredTypes.Concat(new[]
+ {
+ typeof(DrawableTaikoMascot),
+ typeof(TaikoMascotAnimation)
+ }).ToList();
+
+ [Cached(typeof(IScrollingInfo))]
+ private ScrollingTestContainer.TestScrollingInfo info = new ScrollingTestContainer.TestScrollingInfo
+ {
+ Direction = { Value = ScrollingDirection.Left },
+ TimeRange = { Value = 5000 },
+ };
+
+ private TaikoScoreProcessor scoreProcessor;
+
+ private IEnumerable mascots => this.ChildrenOfType();
+ private IEnumerable playfields => this.ChildrenOfType();
+
+ [SetUp]
+ public void SetUp()
+ {
+ scoreProcessor = new TaikoScoreProcessor();
+ }
+
+ [Test]
+ public void TestStateAnimations()
+ {
+ AddStep("set beatmap", () => setBeatmap());
+
+ AddStep("clear state", () => SetContents(() => new TaikoMascotAnimation(TaikoMascotAnimationState.Clear)));
+ AddStep("idle state", () => SetContents(() => new TaikoMascotAnimation(TaikoMascotAnimationState.Idle)));
+ AddStep("kiai state", () => SetContents(() => new TaikoMascotAnimation(TaikoMascotAnimationState.Kiai)));
+ AddStep("fail state", () => SetContents(() => new TaikoMascotAnimation(TaikoMascotAnimationState.Fail)));
+ }
+
+ [Test]
+ public void TestInitialState()
+ {
+ AddStep("create mascot", () => SetContents(() => new DrawableTaikoMascot { RelativeSizeAxes = Axes.Both }));
+
+ AddAssert("mascot initially idle", () => allMascotsIn(TaikoMascotAnimationState.Idle));
+ }
+
+ [Test]
+ public void TestClearStateTransition()
+ {
+ AddStep("set beatmap", () => setBeatmap());
+
+ AddStep("create mascot", () => SetContents(() => new DrawableTaikoMascot { RelativeSizeAxes = Axes.Both }));
+
+ AddStep("set clear state", () => mascots.ForEach(mascot => mascot.State.Value = TaikoMascotAnimationState.Clear));
+ AddStep("miss", () => mascots.ForEach(mascot => mascot.LastResult.Value = new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Miss }));
+ AddAssert("skins with animations remain in clear state", () => someMascotsIn(TaikoMascotAnimationState.Clear));
+ AddUntilStep("state reverts to fail", () => allMascotsIn(TaikoMascotAnimationState.Fail));
+
+ AddStep("set clear state again", () => mascots.ForEach(mascot => mascot.State.Value = TaikoMascotAnimationState.Clear));
+ AddAssert("skins with animations change to clear", () => someMascotsIn(TaikoMascotAnimationState.Clear));
+ }
+
+ [Test]
+ public void TestIdleState()
+ {
+ AddStep("set beatmap", () => setBeatmap());
+
+ createDrawableRuleset();
+
+ assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Great }, TaikoMascotAnimationState.Idle);
+ assertStateAfterResult(new JudgementResult(new StrongHitObject(), new TaikoStrongJudgement()) { Type = HitResult.Miss }, TaikoMascotAnimationState.Idle);
+ }
+
+ [Test]
+ public void TestKiaiState()
+ {
+ AddStep("set beatmap", () => setBeatmap(true));
+
+ createDrawableRuleset();
+
+ assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Good }, TaikoMascotAnimationState.Kiai);
+ assertStateAfterResult(new JudgementResult(new Hit(), new TaikoStrongJudgement()) { Type = HitResult.Miss }, TaikoMascotAnimationState.Kiai);
+ assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Miss }, TaikoMascotAnimationState.Fail);
+ }
+
+ [Test]
+ public void TestMissState()
+ {
+ AddStep("set beatmap", () => setBeatmap());
+
+ createDrawableRuleset();
+
+ assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Great }, TaikoMascotAnimationState.Idle);
+ assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Miss }, TaikoMascotAnimationState.Fail);
+ assertStateAfterResult(new JudgementResult(new DrumRoll(), new TaikoDrumRollJudgement()) { Type = HitResult.Great }, TaikoMascotAnimationState.Fail);
+ assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Good }, TaikoMascotAnimationState.Idle);
+ }
+
+ [TestCase(true)]
+ [TestCase(false)]
+ public void TestClearStateOnComboMilestone(bool kiai)
+ {
+ AddStep("set beatmap", () => setBeatmap(kiai));
+
+ createDrawableRuleset();
+
+ AddRepeatStep("reach 49 combo", () => applyNewResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Great }), 49);
+
+ assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Good }, TaikoMascotAnimationState.Clear);
+ }
+
+ [TestCase(true, TaikoMascotAnimationState.Kiai)]
+ [TestCase(false, TaikoMascotAnimationState.Idle)]
+ public void TestClearStateOnClearedSwell(bool kiai, TaikoMascotAnimationState expectedStateAfterClear)
+ {
+ AddStep("set beatmap", () => setBeatmap(kiai));
+
+ createDrawableRuleset();
+
+ assertStateAfterResult(new JudgementResult(new Swell(), new TaikoSwellJudgement()) { Type = HitResult.Great }, TaikoMascotAnimationState.Clear);
+ AddUntilStep($"state reverts to {expectedStateAfterClear.ToString().ToLower()}", () => allMascotsIn(expectedStateAfterClear));
+ }
+
+ private void setBeatmap(bool kiai = false)
+ {
+ var controlPointInfo = new ControlPointInfo();
+ controlPointInfo.Add(0, new TimingControlPoint { BeatLength = 90 });
+
+ if (kiai)
+ controlPointInfo.Add(0, new EffectControlPoint { KiaiMode = true });
+
+ Beatmap.Value = CreateWorkingBeatmap(new Beatmap
+ {
+ HitObjects = new List { new Hit { Type = HitType.Centre } },
+ BeatmapInfo = new BeatmapInfo
+ {
+ BaseDifficulty = new BeatmapDifficulty(),
+ Metadata = new BeatmapMetadata
+ {
+ Artist = "Unknown",
+ Title = "Sample Beatmap",
+ AuthorString = "Craftplacer",
+ },
+ Ruleset = new TaikoRuleset().RulesetInfo
+ },
+ ControlPointInfo = controlPointInfo
+ });
+
+ scoreProcessor.ApplyBeatmap(Beatmap.Value.Beatmap);
+ }
+
+ private void createDrawableRuleset()
+ {
+ AddUntilStep("wait for beatmap to be loaded", () => Beatmap.Value.Track.IsLoaded);
+
+ AddStep("create drawable ruleset", () =>
+ {
+ Beatmap.Value.Track.Start();
+
+ SetContents(() =>
+ {
+ var ruleset = new TaikoRuleset();
+ return new DrawableTaikoRuleset(ruleset, Beatmap.Value.GetPlayableBeatmap(ruleset.RulesetInfo));
+ });
+ });
+ }
+
+ private void assertStateAfterResult(JudgementResult judgementResult, TaikoMascotAnimationState expectedState)
+ {
+ AddStep($"{judgementResult.Type.ToString().ToLower()} result for {judgementResult.Judgement.GetType().Name.Humanize(LetterCasing.LowerCase)}",
+ () => applyNewResult(judgementResult));
+
+ AddAssert($"state is {expectedState.ToString().ToLower()}", () => allMascotsIn(expectedState));
+ }
+
+ private void applyNewResult(JudgementResult judgementResult)
+ {
+ scoreProcessor.ApplyResult(judgementResult);
+
+ foreach (var playfield in playfields)
+ {
+ var hit = new DrawableTestHit(new Hit(), judgementResult.Type);
+ Add(hit);
+
+ playfield.OnNewResult(hit, judgementResult);
+ }
+
+ foreach (var mascot in mascots)
+ {
+ mascot.LastResult.Value = judgementResult;
+ }
+ }
+
+ private bool allMascotsIn(TaikoMascotAnimationState state) => mascots.All(d => d.State.Value == state);
+ private bool someMascotsIn(TaikoMascotAnimationState state) => mascots.Any(d => d.State.Value == state);
+ }
+}
diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoScroller.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoScroller.cs
index e26f410b71..16ef5b968d 100644
--- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoScroller.cs
+++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneTaikoScroller.cs
@@ -3,6 +3,7 @@
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Testing;
+using osu.Framework.Timing;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Skinning;
@@ -12,11 +13,30 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning
{
public class TestSceneTaikoScroller : TaikoSkinnableTestScene
{
+ private readonly ManualClock clock = new ManualClock();
+
+ private bool reversed;
+
public TestSceneTaikoScroller()
{
- AddStep("Load scroller", () => SetContents(() => new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.TaikoScroller), _ => Empty())));
+ AddStep("Load scroller", () => SetContents(() =>
+ new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.Scroller), _ => Empty())
+ {
+ Clock = new FramedClock(clock),
+ Height = 0.4f,
+ }));
+
AddToggleStep("Toggle passing", passing => this.ChildrenOfType().ForEach(s => s.LastResult.Value =
new JudgementResult(null, new Judgement()) { Type = passing ? HitResult.Perfect : HitResult.Miss }));
+
+ AddToggleStep("toggle playback direction", reversed => this.reversed = reversed);
+ }
+
+ protected override void Update()
+ {
+ base.Update();
+
+ clock.CurrentTime += (reversed ? -1 : 1) * Clock.ElapsedFrameTime;
}
}
}
diff --git a/osu.Game.Rulesets.Taiko/Skinning/LegacyTaikoScroller.cs b/osu.Game.Rulesets.Taiko/Skinning/LegacyTaikoScroller.cs
index 1ecdb839fb..03813e0a99 100644
--- a/osu.Game.Rulesets.Taiko/Skinning/LegacyTaikoScroller.cs
+++ b/osu.Game.Rulesets.Taiko/Skinning/LegacyTaikoScroller.cs
@@ -17,6 +17,8 @@ namespace osu.Game.Rulesets.Taiko.Skinning
{
public class LegacyTaikoScroller : CompositeDrawable
{
+ public Bindable LastResult = new Bindable();
+
public LegacyTaikoScroller()
{
RelativeSizeAxes = Axes.Both;
@@ -50,37 +52,38 @@ namespace osu.Game.Rulesets.Taiko.Skinning
}, true);
}
- public Bindable LastResult = new Bindable();
-
protected override void Update()
{
base.Update();
- while (true)
+ // store X before checking wide enough so if we perform layout there is no positional discrepancy.
+ float currentX = (InternalChildren?.FirstOrDefault()?.X ?? 0) - (float)Clock.ElapsedFrameTime * 0.1f;
+
+ // ensure we have enough sprites
+ if (!InternalChildren.Any()
+ || InternalChildren.First().ScreenSpaceDrawQuad.Width * InternalChildren.Count < ScreenSpaceDrawQuad.Width * 2)
+ AddInternal(new ScrollerSprite { Passing = passing });
+
+ var first = InternalChildren.First();
+ var last = InternalChildren.Last();
+
+ foreach (var sprite in InternalChildren)
{
- float? additiveX = null;
+ // add the x coordinates and perform re-layout on all sprites as spacing may change with gameplay scale.
+ sprite.X = currentX;
+ currentX += sprite.DrawWidth;
+ }
- foreach (var sprite in InternalChildren)
- {
- // add the x coordinates and perform re-layout on all sprites as spacing may change with gameplay scale.
- sprite.X = additiveX ??= sprite.X - (float)Time.Elapsed * 0.1f;
+ if (first.ScreenSpaceDrawQuad.TopLeft.X >= ScreenSpaceDrawQuad.TopLeft.X)
+ {
+ foreach (var internalChild in InternalChildren)
+ internalChild.X -= first.DrawWidth;
+ }
- additiveX += sprite.DrawWidth - 1;
-
- if (sprite.X + sprite.DrawWidth < 0)
- sprite.Expire();
- }
-
- var last = InternalChildren.LastOrDefault();
-
- // only break from this loop once we have saturated horizontal space completely.
- if (last != null && last.ScreenSpaceDrawQuad.TopRight.X >= ScreenSpaceDrawQuad.TopRight.X)
- break;
-
- AddInternal(new ScrollerSprite
- {
- Passing = passing
- });
+ if (last.ScreenSpaceDrawQuad.TopRight.X <= ScreenSpaceDrawQuad.TopRight.X)
+ {
+ foreach (var internalChild in InternalChildren)
+ internalChild.X += first.DrawWidth;
}
}
diff --git a/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacySkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacySkinTransformer.cs
index 0212cdfd9e..6e9a37eb93 100644
--- a/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacySkinTransformer.cs
+++ b/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacySkinTransformer.cs
@@ -8,6 +8,7 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Textures;
using osu.Game.Audio;
+using osu.Game.Rulesets.Taiko.UI;
using osu.Game.Skinning;
namespace osu.Game.Rulesets.Taiko.Skinning
@@ -86,11 +87,17 @@ namespace osu.Game.Rulesets.Taiko.Skinning
return null;
- case TaikoSkinComponents.TaikoScroller:
+ case TaikoSkinComponents.Scroller:
if (GetTexture("taiko-slider") != null)
return new LegacyTaikoScroller();
return null;
+
+ case TaikoSkinComponents.Mascot:
+ if (GetTexture("pippidonclear0") != null)
+ return new DrawableTaikoMascot();
+
+ return null;
}
return source.GetDrawableComponent(component);
diff --git a/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs b/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs
index 877351534a..ac4fb51661 100644
--- a/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs
+++ b/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs
@@ -18,6 +18,7 @@ namespace osu.Game.Rulesets.Taiko
TaikoExplosionMiss,
TaikoExplosionGood,
TaikoExplosionGreat,
- TaikoScroller
+ Scroller,
+ Mascot,
}
}
diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoMascot.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoMascot.cs
new file mode 100644
index 0000000000..407ab30e12
--- /dev/null
+++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoMascot.cs
@@ -0,0 +1,123 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System.Collections.Generic;
+using osu.Framework.Allocation;
+using osu.Framework.Audio.Track;
+using osu.Framework.Bindables;
+using osu.Framework.Extensions.IEnumerableExtensions;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Textures;
+using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Graphics.Containers;
+using osu.Game.Rulesets.Judgements;
+using osu.Game.Rulesets.Taiko.Judgements;
+using osu.Game.Screens.Play;
+
+namespace osu.Game.Rulesets.Taiko.UI
+{
+ public class DrawableTaikoMascot : BeatSyncedContainer
+ {
+ public readonly Bindable State;
+ public readonly Bindable LastResult;
+
+ private readonly Dictionary animations;
+ private TaikoMascotAnimation currentAnimation;
+
+ private bool lastObjectHit = true;
+ private bool kiaiMode;
+
+ public DrawableTaikoMascot(TaikoMascotAnimationState startingState = TaikoMascotAnimationState.Idle)
+ {
+ Origin = Anchor = Anchor.BottomLeft;
+
+ State = new Bindable(startingState);
+ LastResult = new Bindable();
+
+ animations = new Dictionary();
+ }
+
+ [BackgroundDependencyLoader(true)]
+ private void load(TextureStore textures, GameplayBeatmap gameplayBeatmap)
+ {
+ InternalChildren = new[]
+ {
+ animations[TaikoMascotAnimationState.Idle] = new TaikoMascotAnimation(TaikoMascotAnimationState.Idle),
+ animations[TaikoMascotAnimationState.Clear] = new TaikoMascotAnimation(TaikoMascotAnimationState.Clear),
+ animations[TaikoMascotAnimationState.Kiai] = new TaikoMascotAnimation(TaikoMascotAnimationState.Kiai),
+ animations[TaikoMascotAnimationState.Fail] = new TaikoMascotAnimation(TaikoMascotAnimationState.Fail),
+ };
+
+ if (gameplayBeatmap != null)
+ ((IBindable)LastResult).BindTo(gameplayBeatmap.LastJudgementResult);
+ }
+
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ animations.Values.ForEach(animation => animation.Hide());
+
+ State.BindValueChanged(mascotStateChanged, true);
+ LastResult.BindValueChanged(onNewResult);
+ }
+
+ private void onNewResult(ValueChangedEvent resultChangedEvent)
+ {
+ var result = resultChangedEvent.NewValue;
+ if (result == null)
+ return;
+
+ // TODO: missing support for clear/fail state transition at end of beatmap gameplay
+
+ if (triggerComboClear(result) || triggerSwellClear(result))
+ {
+ State.Value = TaikoMascotAnimationState.Clear;
+ // always consider a clear equivalent to a hit to avoid clear -> miss transitions
+ lastObjectHit = true;
+ }
+
+ if (!result.Judgement.AffectsCombo)
+ return;
+
+ lastObjectHit = result.IsHit;
+ }
+
+ protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, TrackAmplitudes amplitudes)
+ {
+ kiaiMode = effectPoint.KiaiMode;
+ }
+
+ protected override void Update()
+ {
+ base.Update();
+ State.Value = getNextState();
+ }
+
+ private TaikoMascotAnimationState getNextState()
+ {
+ // don't change state if current animation is still playing (and we haven't rewound before it).
+ // used for clear state - others are manually animated on new beats.
+ if (currentAnimation?.Completed == false && currentAnimation.DisplayTime <= Time.Current)
+ return State.Value;
+
+ if (!lastObjectHit)
+ return TaikoMascotAnimationState.Fail;
+
+ return kiaiMode ? TaikoMascotAnimationState.Kiai : TaikoMascotAnimationState.Idle;
+ }
+
+ private void mascotStateChanged(ValueChangedEvent state)
+ {
+ currentAnimation?.Hide();
+ currentAnimation = animations[state.NewValue];
+ currentAnimation.Show();
+ }
+
+ private bool triggerComboClear(JudgementResult judgementResult)
+ => (judgementResult.ComboAtJudgement + 1) % 50 == 0 && judgementResult.Judgement.AffectsCombo && judgementResult.IsHit;
+
+ private bool triggerSwellClear(JudgementResult judgementResult)
+ => judgementResult.Judgement is TaikoSwellJudgement && judgementResult.IsHit;
+ }
+}
diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs
index c0a6c4582c..e6aacf34dc 100644
--- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs
+++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs
@@ -42,7 +42,7 @@ namespace osu.Game.Rulesets.Taiko.UI
{
new BarLineGenerator(Beatmap).BarLines.ForEach(bar => Playfield.Add(bar.Major ? new DrawableBarLineMajor(bar) : new DrawableBarLine(bar)));
- AddInternal(scroller = new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.TaikoScroller), _ => Empty())
+ FrameStableComponents.Add(scroller = new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.Scroller), _ => Empty())
{
RelativeSizeAxes = Axes.X,
Depth = float.MaxValue
diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimation.cs b/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimation.cs
new file mode 100644
index 0000000000..cce2be7758
--- /dev/null
+++ b/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimation.cs
@@ -0,0 +1,133 @@
+// 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.Audio.Track;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Animations;
+using osu.Framework.Graphics.Textures;
+using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Graphics.Containers;
+using osu.Game.Skinning;
+using osuTK;
+
+namespace osu.Game.Rulesets.Taiko.UI
+{
+ public sealed class TaikoMascotAnimation : BeatSyncedContainer
+ {
+ private readonly TextureAnimation textureAnimation;
+
+ private int currentFrame;
+
+ public double DisplayTime;
+
+ public TaikoMascotAnimation(TaikoMascotAnimationState state)
+ {
+ InternalChild = textureAnimation = createTextureAnimation(state).With(animation =>
+ {
+ animation.Origin = animation.Anchor = Anchor.BottomLeft;
+ animation.Scale = new Vector2(0.51f); // close enough to stable
+ });
+
+ RelativeSizeAxes = Axes.Both;
+ Origin = Anchor = Anchor.BottomLeft;
+
+ // needs to be always present to prevent the animation clock consuming time spent when not present.
+ AlwaysPresent = true;
+ }
+
+ public bool Completed => !textureAnimation.IsPlaying || textureAnimation.PlaybackPosition >= textureAnimation.Duration;
+
+ public override void Show()
+ {
+ base.Show();
+ DisplayTime = Time.Current;
+ textureAnimation.Seek(0);
+ }
+
+ protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, TrackAmplitudes amplitudes)
+ {
+ // assume that if the animation is playing on its own, it's independent from the beat and doesn't need to be touched.
+ if (textureAnimation.FrameCount == 0 || textureAnimation.IsPlaying)
+ return;
+
+ textureAnimation.GotoFrame(currentFrame);
+ currentFrame = (currentFrame + 1) % textureAnimation.FrameCount;
+ }
+
+ private static TextureAnimation createTextureAnimation(TaikoMascotAnimationState state)
+ {
+ switch (state)
+ {
+ case TaikoMascotAnimationState.Clear:
+ return new ClearMascotTextureAnimation();
+
+ case TaikoMascotAnimationState.Idle:
+ case TaikoMascotAnimationState.Kiai:
+ case TaikoMascotAnimationState.Fail:
+ return new ManualMascotTextureAnimation(state);
+
+ default:
+ throw new ArgumentOutOfRangeException(nameof(state), $"Mascot animations for state {state} are not supported");
+ }
+ }
+
+ private class ManualMascotTextureAnimation : TextureAnimation
+ {
+ private readonly TaikoMascotAnimationState state;
+
+ public ManualMascotTextureAnimation(TaikoMascotAnimationState state)
+ {
+ this.state = state;
+
+ IsPlaying = false;
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(ISkinSource skin)
+ {
+ for (int frameIndex = 0; true; frameIndex++)
+ {
+ var texture = getAnimationFrame(skin, state, frameIndex);
+
+ if (texture == null)
+ break;
+
+ AddFrame(texture);
+ }
+ }
+ }
+
+ private class ClearMascotTextureAnimation : TextureAnimation
+ {
+ private const float clear_animation_speed = 1000 / 10f;
+
+ private static readonly int[] clear_animation_sequence = { 0, 1, 2, 3, 4, 5, 6, 5, 6, 5, 4, 3, 2, 1, 0 };
+
+ public ClearMascotTextureAnimation()
+ {
+ DefaultFrameLength = clear_animation_speed;
+ Loop = false;
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(ISkinSource skin)
+ {
+ foreach (var frameIndex in clear_animation_sequence)
+ {
+ var texture = getAnimationFrame(skin, TaikoMascotAnimationState.Clear, frameIndex);
+
+ if (texture == null)
+ // as per https://osu.ppy.sh/help/wiki/Skinning/osu!taiko#pippidon
+ break;
+
+ AddFrame(texture);
+ }
+ }
+ }
+
+ private static Texture getAnimationFrame(ISkin skin, TaikoMascotAnimationState state, int frameIndex)
+ => skin.GetTexture($"pippidon{state.ToString().ToLower()}{frameIndex}");
+ }
+}
diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimationState.cs b/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimationState.cs
new file mode 100644
index 0000000000..02bf245b7b
--- /dev/null
+++ b/osu.Game.Rulesets.Taiko/UI/TaikoMascotAnimationState.cs
@@ -0,0 +1,13 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+namespace osu.Game.Rulesets.Taiko.UI
+{
+ public enum TaikoMascotAnimationState
+ {
+ Idle,
+ Clear,
+ Kiai,
+ Fail
+ }
+}
diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs
index 5c763cb332..dabdfe6f44 100644
--- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs
+++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs
@@ -15,6 +15,7 @@ using osu.Game.Rulesets.Taiko.Objects.Drawables;
using osu.Game.Rulesets.Taiko.Judgements;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Skinning;
+using osuTK;
namespace osu.Game.Rulesets.Taiko.UI
{
@@ -32,6 +33,7 @@ namespace osu.Game.Rulesets.Taiko.UI
private JudgementContainer judgementContainer;
private ScrollingHitObjectContainer drumRollHitContainer;
internal Drawable HitTarget;
+ private SkinnableDrawable mascot;
private ProxyContainer topLevelHitContainer;
private ProxyContainer barlineContainer;
@@ -125,12 +127,20 @@ namespace osu.Game.Rulesets.Taiko.UI
},
}
},
+ mascot = new SkinnableDrawable(new TaikoSkinComponent(TaikoSkinComponents.Mascot), _ => Empty())
+ {
+ Origin = Anchor.BottomLeft,
+ Anchor = Anchor.TopLeft,
+ RelativePositionAxes = Axes.Y,
+ RelativeSizeAxes = Axes.None,
+ Y = 0.2f
+ },
topLevelHitContainer = new ProxyContainer
{
Name = "Top level hit objects",
RelativeSizeAxes = Axes.Both,
},
- drumRollHitContainer.CreateProxy()
+ drumRollHitContainer.CreateProxy(),
};
}
@@ -142,6 +152,8 @@ namespace osu.Game.Rulesets.Taiko.UI
// This is basically allowing for correct alignment as relative pieces move around them.
rightArea.Padding = new MarginPadding { Left = leftArea.DrawWidth };
hitTargetOffsetContent.Padding = new MarginPadding { Left = HitTarget.DrawWidth / 2 };
+
+ mascot.Scale = new Vector2(DrawHeight / DEFAULT_HEIGHT);
}
public override void Add(DrawableHitObject h)
diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs
index 980f5ea340..1041456020 100644
--- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs
+++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfieldAdjustmentContainer.cs
@@ -13,18 +13,16 @@ namespace osu.Game.Rulesets.Taiko.UI
private const float default_relative_height = TaikoPlayfield.DEFAULT_HEIGHT / 768;
private const float default_aspect = 16f / 9f;
- public TaikoPlayfieldAdjustmentContainer()
- {
- Anchor = Anchor.CentreLeft;
- Origin = Anchor.CentreLeft;
- }
-
protected override void Update()
{
base.Update();
float aspectAdjust = Math.Clamp(Parent.ChildSize.X / Parent.ChildSize.Y, 0.4f, 4) / default_aspect;
Size = new Vector2(1, default_relative_height * aspectAdjust);
+
+ // Position the taiko playfield exactly one playfield from the top of the screen.
+ RelativePositionAxes = Axes.Y;
+ Y = Size.Y;
}
}
}
diff --git a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs
index 7c559ea6d2..ef2b20de64 100644
--- a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs
+++ b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs
@@ -8,14 +8,23 @@ using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Allocation;
+using osu.Framework.Configuration;
using osu.Framework.Platform;
using osu.Game.Configuration;
+using osu.Game.IO;
namespace osu.Game.Tests.NonVisual
{
[TestFixture]
public class CustomDataDirectoryTest
{
+ [SetUp]
+ public void SetUp()
+ {
+ if (Directory.Exists(customPath))
+ Directory.Delete(customPath, true);
+ }
+
[Test]
public void TestDefaultDirectory()
{
@@ -108,6 +117,109 @@ namespace osu.Game.Tests.NonVisual
}
}
+ [Test]
+ public void TestMigration()
+ {
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestMigration)))
+ {
+ try
+ {
+ var osu = loadOsu(host);
+ var storage = osu.Dependencies.Get();
+
+ // ensure we perform a save
+ host.Dependencies.Get().Save();
+
+ // ensure we "use" cache
+ host.Storage.GetStorageForDirectory("cache");
+
+ // for testing nested files are not ignored (only top level)
+ host.Storage.GetStorageForDirectory("test-nested").GetStorageForDirectory("cache");
+
+ string defaultStorageLocation = Path.Combine(Environment.CurrentDirectory, "headless", nameof(TestMigration));
+
+ Assert.That(storage.GetFullPath("."), Is.EqualTo(defaultStorageLocation));
+
+ osu.Migrate(customPath);
+
+ Assert.That(storage.GetFullPath("."), Is.EqualTo(customPath));
+
+ // ensure cache was not moved
+ Assert.That(host.Storage.ExistsDirectory("cache"));
+
+ // ensure nested cache was moved
+ Assert.That(!host.Storage.ExistsDirectory(Path.Combine("test-nested", "cache")));
+ Assert.That(storage.ExistsDirectory(Path.Combine("test-nested", "cache")));
+
+ foreach (var file in OsuStorage.IGNORE_FILES)
+ {
+ Assert.That(host.Storage.Exists(file), Is.True);
+ Assert.That(storage.Exists(file), Is.False);
+ }
+
+ foreach (var dir in OsuStorage.IGNORE_DIRECTORIES)
+ {
+ Assert.That(host.Storage.ExistsDirectory(dir), Is.True);
+ Assert.That(storage.ExistsDirectory(dir), Is.False);
+ }
+
+ Assert.That(new StreamReader(host.Storage.GetStream("storage.ini")).ReadToEnd().Contains($"FullPath = {customPath}"));
+ }
+ finally
+ {
+ host.Exit();
+ }
+ }
+ }
+
+ [Test]
+ public void TestMigrationBetweenTwoTargets()
+ {
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestMigrationBetweenTwoTargets)))
+ {
+ try
+ {
+ var osu = loadOsu(host);
+
+ string customPath2 = $"{customPath}-2";
+
+ const string database_filename = "client.db";
+
+ Assert.DoesNotThrow(() => osu.Migrate(customPath));
+ Assert.That(File.Exists(Path.Combine(customPath, database_filename)));
+
+ Assert.DoesNotThrow(() => osu.Migrate(customPath2));
+ Assert.That(File.Exists(Path.Combine(customPath2, database_filename)));
+
+ Assert.DoesNotThrow(() => osu.Migrate(customPath));
+ Assert.That(File.Exists(Path.Combine(customPath, database_filename)));
+ }
+ finally
+ {
+ host.Exit();
+ }
+ }
+ }
+
+ [Test]
+ public void TestMigrationToSameTargetFails()
+ {
+ using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestMigrationToSameTargetFails)))
+ {
+ try
+ {
+ var osu = loadOsu(host);
+
+ Assert.DoesNotThrow(() => osu.Migrate(customPath));
+ Assert.Throws(() => osu.Migrate(customPath));
+ }
+ finally
+ {
+ host.Exit();
+ }
+ }
+ }
+
private OsuGameBase loadOsu(GameHost host)
{
var osu = new OsuGameBase();
diff --git a/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs b/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs
new file mode 100644
index 0000000000..0cd0f13b5f
--- /dev/null
+++ b/osu.Game.Tests/Visual/Settings/TestSceneDirectorySelector.cs
@@ -0,0 +1,19 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using osu.Framework.Allocation;
+using osu.Framework.Graphics;
+using osu.Framework.Platform;
+using osu.Game.Graphics.UserInterfaceV2;
+
+namespace osu.Game.Tests.Visual.Settings
+{
+ public class TestSceneDirectorySelector : OsuTestScene
+ {
+ [BackgroundDependencyLoader]
+ private void load(GameHost host)
+ {
+ Add(new DirectorySelector { RelativeSizeAxes = Axes.Both });
+ }
+ }
+}
diff --git a/osu.Game/Database/DatabaseContextFactory.cs b/osu.Game/Database/DatabaseContextFactory.cs
index 1ed5fb3268..1cceb59b11 100644
--- a/osu.Game/Database/DatabaseContextFactory.cs
+++ b/osu.Game/Database/DatabaseContextFactory.cs
@@ -160,5 +160,13 @@ namespace osu.Game.Database
}
}
}
+
+ public void FlushConnections()
+ {
+ foreach (var context in threadContexts.Values)
+ context.Dispose();
+
+ recycleThreadContexts();
+ }
}
}
diff --git a/osu.Game/Extensions/WebRequestExtensions.cs b/osu.Game/Extensions/WebRequestExtensions.cs
new file mode 100644
index 0000000000..b940c7498b
--- /dev/null
+++ b/osu.Game/Extensions/WebRequestExtensions.cs
@@ -0,0 +1,23 @@
+// 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.IO.Network;
+using osu.Framework.Extensions.IEnumerableExtensions;
+using osu.Game.Online.API.Requests;
+
+namespace osu.Game.Extensions
+{
+ public static class WebRequestExtensions
+ {
+ ///
+ /// Add a pagination cursor to the web request in the format required by osu-web.
+ ///
+ public static void AddCursor(this WebRequest webRequest, Cursor cursor)
+ {
+ cursor?.Properties.ForEach(x =>
+ {
+ webRequest.AddParameter("cursor[" + x.Key + "]", x.Value.ToString());
+ });
+ }
+ }
+}
diff --git a/osu.Game/Graphics/UserInterfaceV2/DirectorySelector.cs b/osu.Game/Graphics/UserInterfaceV2/DirectorySelector.cs
new file mode 100644
index 0000000000..ee428c0047
--- /dev/null
+++ b/osu.Game/Graphics/UserInterfaceV2/DirectorySelector.cs
@@ -0,0 +1,273 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Graphics.Shapes;
+using osu.Framework.Graphics.Sprites;
+using osu.Framework.Input.Events;
+using osu.Framework.Platform;
+using osu.Game.Graphics.Containers;
+using osu.Game.Graphics.Sprites;
+using osuTK;
+using osuTK.Graphics;
+
+namespace osu.Game.Graphics.UserInterfaceV2
+{
+ public class DirectorySelector : CompositeDrawable
+ {
+ private FillFlowContainer directoryFlow;
+
+ [Resolved]
+ private GameHost host { get; set; }
+
+ [Cached]
+ private readonly Bindable currentDirectory = new Bindable();
+
+ public DirectorySelector(string initialPath = null)
+ {
+ currentDirectory.Value = new DirectoryInfo(initialPath ?? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile));
+ }
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ Padding = new MarginPadding(10);
+
+ InternalChildren = new Drawable[]
+ {
+ new FillFlowContainer
+ {
+ RelativeSizeAxes = Axes.Both,
+ Direction = FillDirection.Vertical,
+ Children = new Drawable[]
+ {
+ new CurrentDirectoryDisplay
+ {
+ RelativeSizeAxes = Axes.X,
+ Height = 50,
+ },
+ new OsuScrollContainer
+ {
+ RelativeSizeAxes = Axes.Both,
+ Child = directoryFlow = new FillFlowContainer
+ {
+ AutoSizeAxes = Axes.Y,
+ RelativeSizeAxes = Axes.X,
+ Direction = FillDirection.Vertical,
+ Spacing = new Vector2(2),
+ }
+ }
+ }
+ },
+ };
+
+ currentDirectory.BindValueChanged(updateDisplay, true);
+ }
+
+ private void updateDisplay(ValueChangedEvent directory)
+ {
+ directoryFlow.Clear();
+
+ try
+ {
+ if (directory.NewValue == null)
+ {
+ var drives = DriveInfo.GetDrives();
+
+ foreach (var drive in drives)
+ directoryFlow.Add(new DirectoryPiece(drive.RootDirectory));
+ }
+ else
+ {
+ directoryFlow.Add(new ParentDirectoryPiece(currentDirectory.Value.Parent));
+
+ foreach (var dir in currentDirectory.Value.GetDirectories().OrderBy(d => d.Name))
+ {
+ if ((dir.Attributes & FileAttributes.Hidden) == 0)
+ directoryFlow.Add(new DirectoryPiece(dir));
+ }
+ }
+ }
+ catch (Exception)
+ {
+ currentDirectory.Value = directory.OldValue;
+
+ this.FlashColour(Color4.Red, 300);
+ }
+ }
+
+ private class CurrentDirectoryDisplay : CompositeDrawable
+ {
+ [Resolved]
+ private Bindable currentDirectory { get; set; }
+
+ private FillFlowContainer flow;
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ InternalChildren = new Drawable[]
+ {
+ flow = new FillFlowContainer
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ RelativeSizeAxes = Axes.X,
+ Spacing = new Vector2(5),
+ Height = DirectoryPiece.HEIGHT,
+ Direction = FillDirection.Horizontal,
+ },
+ };
+
+ currentDirectory.BindValueChanged(updateDisplay, true);
+ }
+
+ private void updateDisplay(ValueChangedEvent dir)
+ {
+ flow.Clear();
+
+ List pathPieces = new List();
+
+ DirectoryInfo ptr = dir.NewValue;
+
+ while (ptr != null)
+ {
+ pathPieces.Insert(0, new CurrentDisplayPiece(ptr));
+ ptr = ptr.Parent;
+ }
+
+ flow.ChildrenEnumerable = new Drawable[]
+ {
+ new OsuSpriteText { Text = "Current Directory: ", Font = OsuFont.Default.With(size: DirectoryPiece.HEIGHT), },
+ new ComputerPiece(),
+ }.Concat(pathPieces);
+ }
+
+ private class ComputerPiece : CurrentDisplayPiece
+ {
+ protected override IconUsage? Icon => null;
+
+ public ComputerPiece()
+ : base(null, "Computer")
+ {
+ }
+ }
+
+ private class CurrentDisplayPiece : DirectoryPiece
+ {
+ public CurrentDisplayPiece(DirectoryInfo directory, string displayName = null)
+ : base(directory, displayName)
+ {
+ }
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ Flow.Add(new SpriteIcon
+ {
+ Anchor = Anchor.CentreLeft,
+ Origin = Anchor.CentreLeft,
+ Icon = FontAwesome.Solid.ChevronRight,
+ Size = new Vector2(FONT_SIZE / 2)
+ });
+ }
+
+ protected override IconUsage? Icon => Directory.Name.Contains(Path.DirectorySeparatorChar) ? base.Icon : null;
+ }
+ }
+
+ private class ParentDirectoryPiece : DirectoryPiece
+ {
+ protected override IconUsage? Icon => FontAwesome.Solid.Folder;
+
+ public ParentDirectoryPiece(DirectoryInfo directory)
+ : base(directory, "..")
+ {
+ }
+ }
+
+ private class DirectoryPiece : CompositeDrawable
+ {
+ public const float HEIGHT = 20;
+
+ protected const float FONT_SIZE = 16;
+
+ protected readonly DirectoryInfo Directory;
+
+ private readonly string displayName;
+
+ protected FillFlowContainer Flow;
+
+ [Resolved]
+ private Bindable currentDirectory { get; set; }
+
+ public DirectoryPiece(DirectoryInfo directory, string displayName = null)
+ {
+ Directory = directory;
+ this.displayName = displayName;
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(OsuColour colours)
+ {
+ AutoSizeAxes = Axes.Both;
+
+ Masking = true;
+ CornerRadius = 5;
+
+ InternalChildren = new Drawable[]
+ {
+ new Box
+ {
+ Colour = colours.GreySeafoamDarker,
+ RelativeSizeAxes = Axes.Both,
+ },
+ Flow = new FillFlowContainer
+ {
+ AutoSizeAxes = Axes.X,
+ Height = 20,
+ Margin = new MarginPadding { Vertical = 2, Horizontal = 5 },
+ Direction = FillDirection.Horizontal,
+ Spacing = new Vector2(5),
+ }
+ };
+
+ if (Icon.HasValue)
+ {
+ Flow.Add(new SpriteIcon
+ {
+ Anchor = Anchor.CentreLeft,
+ Origin = Anchor.CentreLeft,
+ Icon = Icon.Value,
+ Size = new Vector2(FONT_SIZE)
+ });
+ }
+
+ Flow.Add(new OsuSpriteText
+ {
+ Anchor = Anchor.CentreLeft,
+ Origin = Anchor.CentreLeft,
+ Text = displayName ?? Directory.Name,
+ Font = OsuFont.Default.With(size: FONT_SIZE)
+ });
+ }
+
+ protected override bool OnClick(ClickEvent e)
+ {
+ currentDirectory.Value = Directory;
+ return true;
+ }
+
+ protected virtual IconUsage? Icon => Directory.Name.Contains(Path.DirectorySeparatorChar)
+ ? FontAwesome.Solid.Database
+ : FontAwesome.Regular.Folder;
+ }
+ }
+}
diff --git a/osu.Game/IO/OsuStorage.cs b/osu.Game/IO/OsuStorage.cs
index ee42c491d1..71b01ce479 100644
--- a/osu.Game/IO/OsuStorage.cs
+++ b/osu.Game/IO/OsuStorage.cs
@@ -1,6 +1,10 @@
// 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.Linq;
+using System.Threading;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Configuration;
@@ -9,17 +13,119 @@ namespace osu.Game.IO
{
public class OsuStorage : WrappedStorage
{
+ private readonly GameHost host;
+ private readonly StorageConfigManager storageConfig;
+
+ internal static readonly string[] IGNORE_DIRECTORIES = { "cache" };
+
+ internal static readonly string[] IGNORE_FILES =
+ {
+ "framework.ini",
+ "storage.ini"
+ };
+
public OsuStorage(GameHost host)
: base(host.Storage, string.Empty)
{
- var storageConfig = new StorageConfigManager(host.Storage);
+ this.host = host;
+
+ storageConfig = new StorageConfigManager(host.Storage);
var customStoragePath = storageConfig.Get(StorageConfig.FullPath);
if (!string.IsNullOrEmpty(customStoragePath))
- {
ChangeTargetStorage(host.GetStorage(customStoragePath));
- Logger.Storage = UnderlyingStorage.GetStorageForDirectory("logs");
+ }
+
+ protected override void ChangeTargetStorage(Storage newStorage)
+ {
+ base.ChangeTargetStorage(newStorage);
+ Logger.Storage = UnderlyingStorage.GetStorageForDirectory("logs");
+ }
+
+ public void Migrate(string newLocation)
+ {
+ var source = new DirectoryInfo(GetFullPath("."));
+ var destination = new DirectoryInfo(newLocation);
+
+ // ensure the new location has no files present, else hard abort
+ if (destination.Exists)
+ {
+ if (destination.GetFiles().Length > 0 || destination.GetDirectories().Length > 0)
+ throw new InvalidOperationException("Migration destination already has files present");
+
+ deleteRecursive(destination);
+ }
+
+ copyRecursive(source, destination);
+
+ ChangeTargetStorage(host.GetStorage(newLocation));
+
+ storageConfig.Set(StorageConfig.FullPath, newLocation);
+ storageConfig.Save();
+
+ deleteRecursive(source);
+ }
+
+ private static void deleteRecursive(DirectoryInfo target, bool topLevelExcludes = true)
+ {
+ foreach (System.IO.FileInfo fi in target.GetFiles())
+ {
+ if (topLevelExcludes && IGNORE_FILES.Contains(fi.Name))
+ continue;
+
+ fi.Delete();
+ }
+
+ foreach (DirectoryInfo dir in target.GetDirectories())
+ {
+ if (topLevelExcludes && IGNORE_DIRECTORIES.Contains(dir.Name))
+ continue;
+
+ dir.Delete(true);
+ }
+ }
+
+ private static void copyRecursive(DirectoryInfo source, DirectoryInfo destination, bool topLevelExcludes = true)
+ {
+ // based off example code https://docs.microsoft.com/en-us/dotnet/api/system.io.directoryinfo
+ Directory.CreateDirectory(destination.FullName);
+
+ foreach (System.IO.FileInfo fi in source.GetFiles())
+ {
+ if (topLevelExcludes && IGNORE_FILES.Contains(fi.Name))
+ continue;
+
+ attemptCopy(fi, Path.Combine(destination.FullName, fi.Name));
+ }
+
+ foreach (DirectoryInfo dir in source.GetDirectories())
+ {
+ if (topLevelExcludes && IGNORE_DIRECTORIES.Contains(dir.Name))
+ continue;
+
+ copyRecursive(dir, destination.CreateSubdirectory(dir.Name), false);
+ }
+ }
+
+ private static void attemptCopy(System.IO.FileInfo fileInfo, string destination)
+ {
+ int tries = 5;
+
+ while (true)
+ {
+ try
+ {
+ fileInfo.CopyTo(destination, true);
+ return;
+ }
+ catch (Exception)
+ {
+ if (tries-- == 0)
+ throw;
+ }
+
+ Thread.Sleep(50);
}
}
}
diff --git a/osu.Game/IO/Serialization/Converters/TypedListConverter.cs b/osu.Game/IO/Serialization/Converters/TypedListConverter.cs
index 64f1ebeb1a..f98fa05821 100644
--- a/osu.Game/IO/Serialization/Converters/TypedListConverter.cs
+++ b/osu.Game/IO/Serialization/Converters/TypedListConverter.cs
@@ -9,12 +9,12 @@ using Newtonsoft.Json.Linq;
namespace osu.Game.IO.Serialization.Converters
{
///
- /// A type of that serializes a alongside
+ /// A type of that serializes an alongside
/// a lookup table for the types contained. The lookup table is used in deserialization to
/// reconstruct the objects with their original types.
///
- /// The type of objects contained in the this attribute is attached to.
- public class TypedListConverter : JsonConverter
+ /// The type of objects contained in the this attribute is attached to.
+ public class TypedListConverter : JsonConverter>
{
private readonly bool requiresTypeVersion;
@@ -36,9 +36,7 @@ namespace osu.Game.IO.Serialization.Converters
this.requiresTypeVersion = requiresTypeVersion;
}
- public override bool CanConvert(Type objectType) => objectType == typeof(List);
-
- public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
+ public override IReadOnlyList ReadJson(JsonReader reader, Type objectType, IReadOnlyList existingValue, bool hasExistingValue, JsonSerializer serializer)
{
var list = new List();
@@ -59,14 +57,12 @@ namespace osu.Game.IO.Serialization.Converters
return list;
}
- public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
+ public override void WriteJson(JsonWriter writer, IReadOnlyList value, JsonSerializer serializer)
{
- var list = (IEnumerable)value;
-
var lookupTable = new List();
var objects = new List();
- foreach (var item in list)
+ foreach (var item in value)
{
var type = item.GetType();
var assemblyName = type.Assembly.GetName();
diff --git a/osu.Game/IO/Serialization/Converters/Vector2Converter.cs b/osu.Game/IO/Serialization/Converters/Vector2Converter.cs
index bf5edeef94..46447b607b 100644
--- a/osu.Game/IO/Serialization/Converters/Vector2Converter.cs
+++ b/osu.Game/IO/Serialization/Converters/Vector2Converter.cs
@@ -11,26 +11,22 @@ namespace osu.Game.IO.Serialization.Converters
///
/// A type of that serializes only the X and Y coordinates of a .
///
- public class Vector2Converter : JsonConverter
+ public class Vector2Converter : JsonConverter
{
- public override bool CanConvert(Type objectType) => objectType == typeof(Vector2);
-
- public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
+ public override Vector2 ReadJson(JsonReader reader, Type objectType, Vector2 existingValue, bool hasExistingValue, JsonSerializer serializer)
{
var obj = JObject.Load(reader);
return new Vector2((float)obj["x"], (float)obj["y"]);
}
- public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
+ public override void WriteJson(JsonWriter writer, Vector2 value, JsonSerializer serializer)
{
- var vector = (Vector2)value;
-
writer.WriteStartObject();
writer.WritePropertyName("x");
- writer.WriteValue(vector.X);
+ writer.WriteValue(value.X);
writer.WritePropertyName("y");
- writer.WriteValue(vector.Y);
+ writer.WriteValue(value.Y);
writer.WriteEndObject();
}
diff --git a/osu.Game/IO/WrappedStorage.cs b/osu.Game/IO/WrappedStorage.cs
index cc59e2cc28..646faba9eb 100644
--- a/osu.Game/IO/WrappedStorage.cs
+++ b/osu.Game/IO/WrappedStorage.cs
@@ -27,7 +27,7 @@ namespace osu.Game.IO
protected virtual string MutatePath(string path) => !string.IsNullOrEmpty(subPath) ? Path.Combine(subPath, path) : path;
- protected void ChangeTargetStorage(Storage newStorage)
+ protected virtual void ChangeTargetStorage(Storage newStorage)
{
UnderlyingStorage = newStorage;
}
diff --git a/osu.Game/Online/API/Requests/Cursor.cs b/osu.Game/Online/API/Requests/Cursor.cs
new file mode 100644
index 0000000000..f21445ca32
--- /dev/null
+++ b/osu.Game/Online/API/Requests/Cursor.cs
@@ -0,0 +1,20 @@
+// 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 JetBrains.Annotations;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace osu.Game.Online.API.Requests
+{
+ ///
+ /// A collection of parameters which should be passed to the search endpoint to fetch the next page.
+ ///
+ public class Cursor
+ {
+ [UsedImplicitly]
+ [JsonExtensionData]
+ public IDictionary Properties;
+ }
+}
diff --git a/osu.Game/Online/API/Requests/ResponseWithCursor.cs b/osu.Game/Online/API/Requests/ResponseWithCursor.cs
index e38e73dd01..d52e999722 100644
--- a/osu.Game/Online/API/Requests/ResponseWithCursor.cs
+++ b/osu.Game/Online/API/Requests/ResponseWithCursor.cs
@@ -7,10 +7,7 @@ namespace osu.Game.Online.API.Requests
{
public abstract class ResponseWithCursor
{
- ///
- /// A collection of parameters which should be passed to the search endpoint to fetch the next page.
- ///
[JsonProperty("cursor")]
- public dynamic CursorJson;
+ public Cursor Cursor;
}
}
diff --git a/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs b/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs
index 047496b473..0c3272c7de 100644
--- a/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs
+++ b/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.IO.Network;
+using osu.Game.Extensions;
using osu.Game.Overlays;
using osu.Game.Overlays.BeatmapListing;
using osu.Game.Rulesets;
@@ -10,29 +11,31 @@ namespace osu.Game.Online.API.Requests
{
public class SearchBeatmapSetsRequest : APIRequest
{
- public SearchCategory SearchCategory { get; set; }
+ public SearchCategory SearchCategory { get; }
- public SortCriteria SortCriteria { get; set; }
+ public SortCriteria SortCriteria { get; }
- public SortDirection SortDirection { get; set; }
+ public SortDirection SortDirection { get; }
- public SearchGenre Genre { get; set; }
+ public SearchGenre Genre { get; }
- public SearchLanguage Language { get; set; }
+ public SearchLanguage Language { get; }
private readonly string query;
private readonly RulesetInfo ruleset;
+ private readonly Cursor cursor;
private string directionString => SortDirection == SortDirection.Descending ? @"desc" : @"asc";
- public SearchBeatmapSetsRequest(string query, RulesetInfo ruleset)
+ public SearchBeatmapSetsRequest(string query, RulesetInfo ruleset, Cursor cursor = null, SearchCategory searchCategory = SearchCategory.Any, SortCriteria sortCriteria = SortCriteria.Ranked, SortDirection sortDirection = SortDirection.Descending)
{
this.query = string.IsNullOrEmpty(query) ? string.Empty : System.Uri.EscapeDataString(query);
this.ruleset = ruleset;
+ this.cursor = cursor;
- SearchCategory = SearchCategory.Any;
- SortCriteria = SortCriteria.Ranked;
- SortDirection = SortDirection.Descending;
+ SearchCategory = searchCategory;
+ SortCriteria = sortCriteria;
+ SortDirection = sortDirection;
Genre = SearchGenre.Any;
Language = SearchLanguage.Any;
}
@@ -55,6 +58,8 @@ namespace osu.Game.Online.API.Requests
req.AddParameter("sort", $"{SortCriteria.ToString().ToLowerInvariant()}_{directionString}");
+ req.AddCursor(cursor);
+
return req;
}
diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs
index 3caffb6db5..7ecd7851d7 100644
--- a/osu.Game/OsuGame.cs
+++ b/osu.Game/OsuGame.cs
@@ -91,7 +91,7 @@ namespace osu.Game
protected BackButton BackButton;
- protected SettingsPanel Settings;
+ protected SettingsOverlay Settings;
private VolumeOverlay volume;
private OsuLogo osuLogo;
@@ -767,13 +767,20 @@ namespace osu.Game
private Task asyncLoadStream;
- private T loadComponentSingleFile(T d, Action add, bool cache = false)
+ ///
+ /// Queues loading the provided component in sequential fashion.
+ /// This operation is limited to a single thread to avoid saturating all cores.
+ ///
+ /// The component to load.
+ /// An action to invoke on load completion (generally to add the component to the hierarchy).
+ /// Whether to cache the component as type into the game dependencies before any scheduling.
+ private T loadComponentSingleFile(T component, Action loadCompleteAction, bool cache = false)
where T : Drawable
{
if (cache)
- dependencies.Cache(d);
+ dependencies.CacheAs(component);
- if (d is OverlayContainer overlay)
+ if (component is OverlayContainer overlay)
overlays.Add(overlay);
// schedule is here to ensure that all component loads are done after LoadComplete is run (and thus all dependencies are cached).
@@ -791,12 +798,12 @@ namespace osu.Game
try
{
- Logger.Log($"Loading {d}...", level: LogLevel.Debug);
+ Logger.Log($"Loading {component}...", level: LogLevel.Debug);
// Since this is running in a separate thread, it is possible for OsuGame to be disposed after LoadComponentAsync has been called
// throwing an exception. To avoid this, the call is scheduled on the update thread, which does not run if IsDisposed = true
Task task = null;
- var del = new ScheduledDelegate(() => task = LoadComponentAsync(d, add));
+ var del = new ScheduledDelegate(() => task = LoadComponentAsync(component, loadCompleteAction));
Scheduler.Add(del);
// The delegate won't complete if OsuGame has been disposed in the meantime
@@ -811,7 +818,7 @@ namespace osu.Game
await task;
- Logger.Log($"Loaded {d}!", level: LogLevel.Debug);
+ Logger.Log($"Loaded {component}!", level: LogLevel.Debug);
}
catch (OperationCanceledException)
{
@@ -819,7 +826,7 @@ namespace osu.Game
});
});
- return d;
+ return component;
}
protected override bool OnScroll(ScrollEvent e)
diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs
index cf39c03f9d..11a3834c71 100644
--- a/osu.Game/OsuGameBase.cs
+++ b/osu.Game/OsuGameBase.cs
@@ -328,6 +328,8 @@ namespace osu.Game
{
base.Dispose(isDisposing);
RulesetStore?.Dispose();
+
+ contextFactory.FlushConnections();
}
private class OsuUserInputManager : UserInputManager
@@ -355,5 +357,11 @@ namespace osu.Game
public override bool ChangeFocusOnClick => false;
}
}
+
+ public void Migrate(string path)
+ {
+ contextFactory.FlushConnections();
+ (Storage as OsuStorage)?.Migrate(path);
+ }
}
}
diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs
index 4dd60c7113..41c99d5d03 100644
--- a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs
+++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs
@@ -22,25 +22,46 @@ namespace osu.Game.Overlays.BeatmapListing
{
public class BeatmapListingFilterControl : CompositeDrawable
{
+ ///
+ /// Fired when a search finishes. Contains only new items in the case of pagination.
+ ///
public Action> SearchFinished;
+
+ ///
+ /// Fired when search criteria change.
+ ///
public Action SearchStarted;
+ ///
+ /// True when pagination has reached the end of available results.
+ ///
+ private bool noMoreResults;
+
+ ///
+ /// The current page fetched of results (zero index).
+ ///
+ public int CurrentPage { get; private set; }
+
+ private readonly BeatmapListingSearchControl searchControl;
+ private readonly BeatmapListingSortTabControl sortControl;
+ private readonly Box sortControlBackground;
+
+ private ScheduledDelegate queryChangedDebounce;
+
+ private SearchBeatmapSetsRequest getSetsRequest;
+ private SearchBeatmapSetsResponse lastResponse;
+
[Resolved]
private IAPIProvider api { get; set; }
[Resolved]
private RulesetStore rulesets { get; set; }
- private readonly BeatmapListingSearchControl searchControl;
- private readonly BeatmapListingSortTabControl sortControl;
- private readonly Box sortControlBackground;
-
- private SearchBeatmapSetsRequest getSetsRequest;
-
public BeatmapListingFilterControl()
{
RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y;
+
InternalChild = new FillFlowContainer
{
RelativeSizeAxes = Axes.X,
@@ -114,51 +135,84 @@ namespace osu.Game.Overlays.BeatmapListing
sortDirection.BindValueChanged(_ => queueUpdateSearch());
}
- private ScheduledDelegate queryChangedDebounce;
+ public void TakeFocus() => searchControl.TakeFocus();
+
+ ///
+ /// Fetch the next page of results. May result in a no-op if a fetch is already in progress, or if there are no results left.
+ ///
+ public void FetchNextPage()
+ {
+ // there may be no results left.
+ if (noMoreResults)
+ return;
+
+ // there may already be an active request.
+ if (getSetsRequest != null)
+ return;
+
+ if (lastResponse != null)
+ CurrentPage++;
+
+ performRequest();
+ }
private void queueUpdateSearch(bool queryTextChanged = false)
{
SearchStarted?.Invoke();
- getSetsRequest?.Cancel();
+ resetSearch();
- queryChangedDebounce?.Cancel();
- queryChangedDebounce = Scheduler.AddDelayed(updateSearch, queryTextChanged ? 500 : 100);
+ queryChangedDebounce = Scheduler.AddDelayed(() =>
+ {
+ resetSearch();
+ FetchNextPage();
+ }, queryTextChanged ? 500 : 100);
}
- private void updateSearch()
+ private void performRequest()
{
- getSetsRequest = new SearchBeatmapSetsRequest(searchControl.Query.Value, searchControl.Ruleset.Value)
- {
- SearchCategory = searchControl.Category.Value,
- SortCriteria = sortControl.Current.Value,
- SortDirection = sortControl.SortDirection.Value,
- Genre = searchControl.Genre.Value,
- Language = searchControl.Language.Value
- };
+ getSetsRequest = new SearchBeatmapSetsRequest(
+ searchControl.Query.Value,
+ searchControl.Ruleset.Value,
+ lastResponse?.Cursor,
+ searchControl.Category.Value,
+ sortControl.Current.Value,
+ sortControl.SortDirection.Value);
- getSetsRequest.Success += response => Schedule(() => onSearchFinished(response));
+ getSetsRequest.Success += response =>
+ {
+ var sets = response.BeatmapSets.Select(responseJson => responseJson.ToBeatmapSet(rulesets)).ToList();
+
+ if (sets.Count == 0)
+ noMoreResults = true;
+
+ lastResponse = response;
+ getSetsRequest = null;
+
+ SearchFinished?.Invoke(sets);
+ };
api.Queue(getSetsRequest);
}
- private void onSearchFinished(SearchBeatmapSetsResponse response)
+ private void resetSearch()
{
- var beatmaps = response.BeatmapSets.Select(r => r.ToBeatmapSet(rulesets)).ToList();
+ noMoreResults = false;
+ CurrentPage = 0;
- searchControl.BeatmapSet = response.Total == 0 ? null : beatmaps.First();
+ lastResponse = null;
- SearchFinished?.Invoke(beatmaps);
+ getSetsRequest?.Cancel();
+ getSetsRequest = null;
+
+ queryChangedDebounce?.Cancel();
}
protected override void Dispose(bool isDisposing)
{
- getSetsRequest?.Cancel();
- queryChangedDebounce?.Cancel();
+ resetSearch();
base.Dispose(isDisposing);
}
-
- public void TakeFocus() => searchControl.TakeFocus();
}
}
diff --git a/osu.Game/Overlays/BeatmapListingOverlay.cs b/osu.Game/Overlays/BeatmapListingOverlay.cs
index f680f7c67b..225a8a0578 100644
--- a/osu.Game/Overlays/BeatmapListingOverlay.cs
+++ b/osu.Game/Overlays/BeatmapListingOverlay.cs
@@ -4,7 +4,9 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
+using System.Threading.Tasks;
using osu.Framework.Allocation;
+using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
@@ -30,6 +32,10 @@ namespace osu.Game.Overlays
private Drawable currentContent;
private LoadingLayer loadingLayer;
private Container panelTarget;
+ private FillFlowContainer foundContent;
+ private NotFoundDrawable notFoundContent;
+
+ private OverlayScrollContainer resultScrollContainer;
public BeatmapListingOverlay()
: base(OverlayColourScheme.Blue)
@@ -48,7 +54,7 @@ namespace osu.Game.Overlays
RelativeSizeAxes = Axes.Both,
Colour = ColourProvider.Background6
},
- new OverlayScrollContainer
+ resultScrollContainer = new OverlayScrollContainer
{
RelativeSizeAxes = Axes.Both,
ScrollbarVisible = false,
@@ -80,9 +86,14 @@ namespace osu.Game.Overlays
{
AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X,
- Padding = new MarginPadding { Horizontal = 20 }
- },
- loadingLayer = new LoadingLayer(panelTarget)
+ Padding = new MarginPadding { Horizontal = 20 },
+ Children = new Drawable[]
+ {
+ foundContent = new FillFlowContainer(),
+ notFoundContent = new NotFoundDrawable(),
+ loadingLayer = new LoadingLayer(panelTarget)
+ }
+ }
}
},
}
@@ -110,34 +121,53 @@ namespace osu.Game.Overlays
loadingLayer.Show();
}
+ private Task panelLoadDelegate;
+
private void onSearchFinished(List beatmaps)
{
- if (!beatmaps.Any())
+ var newPanels = beatmaps.Select(b => new GridBeatmapPanel(b)
{
- LoadComponentAsync(new NotFoundDrawable(), addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token);
- return;
- }
+ Anchor = Anchor.TopCentre,
+ Origin = Anchor.TopCentre,
+ });
- var newPanels = new FillFlowContainer
+ if (filterControl.CurrentPage == 0)
{
- RelativeSizeAxes = Axes.X,
- AutoSizeAxes = Axes.Y,
- Spacing = new Vector2(10),
- Alpha = 0,
- Margin = new MarginPadding { Vertical = 15 },
- ChildrenEnumerable = beatmaps.Select(b => new GridBeatmapPanel(b)
+ //No matches case
+ if (!newPanels.Any())
{
- Anchor = Anchor.TopCentre,
- Origin = Anchor.TopCentre,
- })
- };
+ LoadComponentAsync(notFoundContent, addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token);
+ return;
+ }
- LoadComponentAsync(newPanels, addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token);
+ // spawn new children with the contained so we only clear old content at the last moment.
+ var content = new FillFlowContainer
+ {
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ Spacing = new Vector2(10),
+ Alpha = 0,
+ Margin = new MarginPadding { Vertical = 15 },
+ ChildrenEnumerable = newPanels
+ };
+
+ panelLoadDelegate = LoadComponentAsync(foundContent = content, addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token);
+ }
+ else
+ {
+ panelLoadDelegate = LoadComponentsAsync(newPanels, loaded =>
+ {
+ lastFetchDisplayedTime = Time.Current;
+ foundContent.AddRange(loaded);
+ loaded.ForEach(p => p.FadeIn(200, Easing.OutQuint));
+ });
+ }
}
private void addContentToPlaceholder(Drawable content)
{
loadingLayer.Hide();
+ lastFetchDisplayedTime = Time.Current;
var lastContent = currentContent;
@@ -149,11 +179,14 @@ namespace osu.Game.Overlays
// If the auto-size computation is delayed until fade out completes, the background remain high for too long making the resulting transition to the smaller height look weird.
// At the same time, if the last content's height is bypassed immediately, there is a period where the new content is at Alpha = 0 when the auto-sized height will be 0.
// To resolve both of these issues, the bypass is delayed until a point when the content transitions (fade-in and fade-out) overlap and it looks good to do so.
- lastContent.Delay(25).Schedule(() => lastContent.BypassAutoSizeAxes = Axes.Y);
+ lastContent.Delay(25).Schedule(() => lastContent.BypassAutoSizeAxes = Axes.Y).Then().Schedule(() => panelTarget.Remove(lastContent));
}
- panelTarget.Add(currentContent = content);
- currentContent.FadeIn(200, Easing.OutQuint);
+ if (!content.IsAlive)
+ panelTarget.Add(content);
+ content.FadeIn(200, Easing.OutQuint);
+
+ currentContent = content;
}
protected override void Dispose(bool isDisposing)
@@ -203,5 +236,23 @@ namespace osu.Game.Overlays
});
}
}
+
+ private const double time_between_fetches = 500;
+
+ private double lastFetchDisplayedTime;
+
+ protected override void Update()
+ {
+ base.Update();
+
+ const int pagination_scroll_distance = 500;
+
+ bool shouldShowMore = panelLoadDelegate?.IsCompleted != false
+ && Time.Current - lastFetchDisplayedTime > time_between_fetches
+ && (resultScrollContainer.ScrollableExtent > 0 && resultScrollContainer.IsScrolledToEnd(pagination_scroll_distance));
+
+ if (shouldShowMore)
+ filterControl.FetchNextPage();
+ }
}
}
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index c6dba8da13..ee6206e166 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -25,7 +25,7 @@
-
+
diff --git a/osu.iOS.props b/osu.iOS.props
index f78fd2e4ff..cbf8600c62 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -71,7 +71,7 @@
-
+