Merge branch 'master' into fix-failure-logic

This commit is contained in:
Dan Balasescu
2020-05-14 20:16:37 +09:00
committed by GitHub
41 changed files with 1681 additions and 221 deletions

View File

@ -51,7 +51,7 @@
<Reference Include="Java.Interop" /> <Reference Include="Java.Interop" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ppy.osu.Game.Resources" Version="2020.427.0" /> <PackageReference Include="ppy.osu.Game.Resources" Version="2020.512.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2020.511.0" /> <PackageReference Include="ppy.osu.Framework.Android" Version="2020.511.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -0,0 +1,36 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<byte[]>(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, Color4>(CatchSkinColour.HyperDash)?.Value);
Assert.AreEqual(new Color4(232, 74, 35, 255), skin.GetConfig<CatchSkinColour, Color4>(CatchSkinColour.HyperDashAfterImage)?.Value);
Assert.AreEqual(new Color4(0, 255, 255, 255), skin.GetConfig<CatchSkinColour, Color4>(CatchSkinColour.HyperDashFruit)?.Value);
}
private class TestLegacySkin : LegacySkin
{
public TestLegacySkin(SkinInfo skin, IResourceStore<byte[]> storage)
// Bypass LegacySkinResourceStore to avoid returning null for retrieving files due to bad skin info (SkinInfo.Files = null).
: base(skin, storage, null, "skin.ini")
{
}
}
}
}

View File

@ -0,0 +1,4 @@
[CatchTheBeat]
HyperDash: 232,185,35
HyperDashFruit: 0,255,255
HyperDashAfterImage: 232,74,35

View File

@ -8,6 +8,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
namespace osu.Game.Rulesets.Catch.Tests namespace osu.Game.Rulesets.Catch.Tests
{ {
@ -23,7 +24,7 @@ namespace osu.Game.Rulesets.Catch.Tests
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load()
{ {
SetContents(() => new Catcher SetContents(() => new Catcher(new Container())
{ {
RelativePositionAxes = Axes.None, RelativePositionAxes = Axes.None,
Anchor = Anchor.Centre, Anchor = Anchor.Centre,

View File

@ -26,6 +26,48 @@ namespace osu.Game.Rulesets.Catch.Tests
[Resolved] [Resolved]
private SkinManager skins { get; set; } 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] [Test]
public void TestDefaultFruitColour() public void TestDefaultFruitColour()
{ {
@ -68,6 +110,38 @@ namespace osu.Game.Rulesets.Catch.Tests
checkHyperDashFruitColour(skin, skin.HyperDashColour); 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<CatcherTrailDisplay>().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) private void checkHyperDashFruitColour(ISkin skin, Color4 expectedColour)
{ {
DrawableFruit drawableFruit = null; DrawableFruit drawableFruit = null;

View File

@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.Catch.Skinning
{ {
private readonly ISkin source; private readonly ISkin source;
public CatchLegacySkinTransformer(ISkinSource source) public CatchLegacySkinTransformer(ISkin source)
{ {
this.source = source; this.source = source;
} }

View File

@ -3,26 +3,37 @@
using System; using System;
using System.Linq; using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Animations;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Bindings; using osu.Framework.Input.Bindings;
using osu.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.Objects.Drawables;
using osu.Game.Rulesets.Catch.Skinning;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Skinning;
using osuTK; using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
namespace osu.Game.Rulesets.Catch.UI namespace osu.Game.Rulesets.Catch.UI
{ {
public class Catcher : Container, IKeyBindingHandler<CatchAction> public class Catcher : SkinReloadableDrawable, IKeyBindingHandler<CatchAction>
{ {
/// <summary>
/// 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.
/// </summary>
public static readonly Color4 DEFAULT_HYPER_DASH_COLOUR = Color4.Red; public static readonly Color4 DEFAULT_HYPER_DASH_COLOUR = Color4.Red;
/// <summary>
/// The duration between transitioning to hyper-dash state.
/// </summary>
public const double HYPER_DASH_TRANSITION_DURATION = 180;
/// <summary> /// <summary>
/// Whether we are hyper-dashing or not. /// Whether we are hyper-dashing or not.
/// </summary> /// </summary>
@ -35,7 +46,10 @@ namespace osu.Game.Rulesets.Catch.UI
public Container ExplodingFruitTarget; public Container ExplodingFruitTarget;
public Container AdditiveTarget; [NotNull]
private readonly Container trailsTarget;
private CatcherTrailDisplay trails;
public CatcherAnimationState CurrentState { get; private set; } public CatcherAnimationState CurrentState { get; private set; }
@ -44,33 +58,23 @@ namespace osu.Game.Rulesets.Catch.UI
/// </summary> /// </summary>
private const float allowed_catch_range = 0.8f; private const float allowed_catch_range = 0.8f;
protected bool Dashing /// <summary>
/// The drawable catcher for <see cref="CurrentState"/>.
/// </summary>
internal Drawable CurrentDrawableCatcher => currentCatcher.Drawable;
private bool dashing;
public bool Dashing
{ {
get => dashing; get => dashing;
set protected set
{ {
if (value == dashing) return; if (value == dashing) return;
dashing = value; dashing = value;
Trail |= dashing; updateTrailVisibility();
}
}
/// <summary>
/// Activate or deactivate the trail. Will be automatically deactivated when conditions to keep the trail displayed are no longer met.
/// </summary>
protected bool Trail
{
get => trail;
set
{
if (value == trail || AdditiveTarget == null) return;
trail = value;
if (Trail)
beginTrail();
} }
} }
@ -87,18 +91,19 @@ namespace osu.Game.Rulesets.Catch.UI
private CatcherSprite currentCatcher; private CatcherSprite currentCatcher;
private Color4 hyperDashColour = DEFAULT_HYPER_DASH_COLOUR;
private Color4 hyperDashEndGlowColour = DEFAULT_HYPER_DASH_COLOUR;
private int currentDirection; private int currentDirection;
private bool dashing;
private bool trail;
private double hyperDashModifier = 1; private double hyperDashModifier = 1;
private int hyperDashDirection; private int hyperDashDirection;
private float hyperDashTargetPosition; private float hyperDashTargetPosition;
public Catcher(BeatmapDifficulty difficulty = null) public Catcher([NotNull] Container trailsTarget, BeatmapDifficulty difficulty = null)
{ {
this.trailsTarget = trailsTarget;
RelativePositionAxes = Axes.X; RelativePositionAxes = Axes.X;
X = 0.5f; X = 0.5f;
@ -114,7 +119,7 @@ namespace osu.Game.Rulesets.Catch.UI
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load()
{ {
Children = new Drawable[] InternalChildren = new Drawable[]
{ {
caughtFruit = new Container<DrawableHitObject> caughtFruit = new Container<DrawableHitObject>
{ {
@ -138,6 +143,8 @@ namespace osu.Game.Rulesets.Catch.UI
} }
}; };
trailsTarget.Add(trails = new CatcherTrailDisplay(this));
updateCatcher(); updateCatcher();
} }
@ -185,7 +192,7 @@ namespace osu.Game.Rulesets.Catch.UI
caughtFruit.Add(fruit); caughtFruit.Add(fruit);
Add(new HitExplosion(fruit) AddInternal(new HitExplosion(fruit)
{ {
X = fruit.X, X = fruit.X,
Scale = new Vector2(fruit.HitObject.Scale) Scale = new Vector2(fruit.HitObject.Scale)
@ -240,8 +247,6 @@ namespace osu.Game.Rulesets.Catch.UI
/// <param name="targetPosition">When this catcher crosses this position, this catcher ends hyper-dashing.</param> /// <param name="targetPosition">When this catcher crosses this position, this catcher ends hyper-dashing.</param>
public void SetHyperDashState(double modifier = 1, float targetPosition = -1) public void SetHyperDashState(double modifier = 1, float targetPosition = -1)
{ {
const float hyper_dash_transition_length = 180;
var wasHyperDashing = HyperDashing; var wasHyperDashing = HyperDashing;
if (modifier <= 1 || X == targetPosition) if (modifier <= 1 || X == targetPosition)
@ -250,11 +255,7 @@ namespace osu.Game.Rulesets.Catch.UI
hyperDashDirection = 0; hyperDashDirection = 0;
if (wasHyperDashing) if (wasHyperDashing)
{ runHyperDashStateTransition(false);
this.FadeColour(Color4.White, hyper_dash_transition_length, Easing.OutQuint);
this.FadeTo(1, hyper_dash_transition_length, Easing.OutQuint);
Trail &= Dashing;
}
} }
else else
{ {
@ -264,20 +265,32 @@ namespace osu.Game.Rulesets.Catch.UI
if (!wasHyperDashing) if (!wasHyperDashing)
{ {
this.FadeColour(Color4.OrangeRed, hyper_dash_transition_length, Easing.OutQuint); trails.DisplayEndGlow();
this.FadeTo(0.2f, hyper_dash_transition_length, Easing.OutQuint); runHyperDashStateTransition(true);
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);
} }
} }
} }
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) public bool OnPressed(CatchAction action)
{ {
switch (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, Color4>(CatchSkinColour.HyperDash)?.Value ??
DEFAULT_HYPER_DASH_COLOUR;
hyperDashEndGlowColour =
skin.GetConfig<CatchSkinColour, Color4>(CatchSkinColour.HyperDashAfterImage)?.Value ??
hyperDashColour;
runHyperDashStateTransition(HyperDashing);
}
protected override void Update() protected override void Update()
{ {
base.Update(); base.Update();
@ -411,22 +439,6 @@ namespace osu.Game.Rulesets.Catch.UI
(currentCatcher.Drawable as IFramedAnimation)?.GotoFrame(0); (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) private void updateState(CatcherAnimationState state)
{ {
if (CurrentState == state) if (CurrentState == state)
@ -436,25 +448,6 @@ namespace osu.Game.Rulesets.Catch.UI
updateCatcher(); 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<DrawableHitObject> action) private void removeFromPlateWithTransform(DrawableHitObject fruit, Action<DrawableHitObject> action)
{ {
if (ExplodingFruitTarget != null) if (ExplodingFruitTarget != null)

View File

@ -33,10 +33,7 @@ namespace osu.Game.Rulesets.Catch.UI
{ {
RelativeSizeAxes = Axes.X; RelativeSizeAxes = Axes.X;
Height = CATCHER_SIZE; Height = CATCHER_SIZE;
Child = MovableCatcher = new Catcher(difficulty) Child = MovableCatcher = new Catcher(this, difficulty);
{
AdditiveTarget = this,
};
} }
public static float GetCatcherSize(BeatmapDifficulty difficulty) public static float GetCatcherSize(BeatmapDifficulty difficulty)

View File

@ -0,0 +1,135 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
{
/// <summary>
/// Represents a component responsible for displaying
/// the appropriate catcher trails when requested to.
/// </summary>
public class CatcherTrailDisplay : CompositeDrawable
{
private readonly Catcher catcher;
private readonly Container<CatcherTrailSprite> dashTrails;
private readonly Container<CatcherTrailSprite> hyperDashTrails;
private readonly Container<CatcherTrailSprite> 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;
/// <summary>
/// Whether to start displaying trails following the catcher.
/// </summary>
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<CatcherTrailSprite> { RelativeSizeAxes = Axes.Both },
hyperDashTrails = new Container<CatcherTrailSprite> { RelativeSizeAxes = Axes.Both, Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR },
endGlowSprites = new Container<CatcherTrailSprite> { RelativeSizeAxes = Axes.Both, Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR },
};
}
/// <summary>
/// Displays a single end-glow catcher sprite.
/// </summary>
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<CatcherTrailSprite> 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;
}
}
}

View File

@ -7,6 +7,7 @@ using osu.Framework.Input.Events;
using osu.Game.Rulesets.Mania.Edit.Blueprints.Components; using osu.Game.Rulesets.Mania.Edit.Blueprints.Components;
using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects;
using osuTK; using osuTK;
using osuTK.Input;
namespace osu.Game.Rulesets.Mania.Edit.Blueprints namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{ {
@ -49,6 +50,9 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
protected override void OnMouseUp(MouseUpEvent e) protected override void OnMouseUp(MouseUpEvent e)
{ {
if (e.Button != MouseButton.Left)
return;
base.OnMouseUp(e); base.OnMouseUp(e);
EndPlacement(true); EndPlacement(true);
} }

View File

@ -11,6 +11,7 @@ using osu.Game.Rulesets.Mania.Objects.Drawables.Pieces;
using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.UI.Scrolling;
using osuTK; using osuTK;
using osuTK.Input;
namespace osu.Game.Rulesets.Mania.Edit.Blueprints namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{ {
@ -46,6 +47,9 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
protected override bool OnMouseDown(MouseDownEvent e) protected override bool OnMouseDown(MouseDownEvent e)
{ {
if (e.Button != MouseButton.Left)
return false;
if (Column == null) if (Column == null)
return base.OnMouseDown(e); return base.OnMouseDown(e);

View File

@ -5,6 +5,7 @@ using osu.Framework.Graphics;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Rulesets.Mania.Edit.Blueprints.Components; using osu.Game.Rulesets.Mania.Edit.Blueprints.Components;
using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects;
using osuTK.Input;
namespace osu.Game.Rulesets.Mania.Edit.Blueprints namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{ {
@ -30,6 +31,9 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
protected override bool OnMouseDown(MouseDownEvent e) protected override bool OnMouseDown(MouseDownEvent e)
{ {
if (e.Button != MouseButton.Left)
return false;
base.OnMouseDown(e); base.OnMouseDown(e);
// Place the note immediately. // Place the note immediately.

View File

@ -0,0 +1,223 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<Type> 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<DrawableTaikoMascot> mascots => this.ChildrenOfType<DrawableTaikoMascot>();
private IEnumerable<TaikoPlayfield> playfields => this.ChildrenOfType<TaikoPlayfield>();
[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<HitObject> { 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);
}
}

View File

@ -3,6 +3,7 @@
using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Framework.Timing;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Skinning; using osu.Game.Rulesets.Taiko.Skinning;
@ -12,11 +13,30 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning
{ {
public class TestSceneTaikoScroller : TaikoSkinnableTestScene public class TestSceneTaikoScroller : TaikoSkinnableTestScene
{ {
private readonly ManualClock clock = new ManualClock();
private bool reversed;
public TestSceneTaikoScroller() 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<LegacyTaikoScroller>().ForEach(s => s.LastResult.Value = AddToggleStep("Toggle passing", passing => this.ChildrenOfType<LegacyTaikoScroller>().ForEach(s => s.LastResult.Value =
new JudgementResult(null, new Judgement()) { Type = passing ? HitResult.Perfect : HitResult.Miss })); 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;
} }
} }
} }

View File

@ -17,6 +17,8 @@ namespace osu.Game.Rulesets.Taiko.Skinning
{ {
public class LegacyTaikoScroller : CompositeDrawable public class LegacyTaikoScroller : CompositeDrawable
{ {
public Bindable<JudgementResult> LastResult = new Bindable<JudgementResult>();
public LegacyTaikoScroller() public LegacyTaikoScroller()
{ {
RelativeSizeAxes = Axes.Both; RelativeSizeAxes = Axes.Both;
@ -50,37 +52,38 @@ namespace osu.Game.Rulesets.Taiko.Skinning
}, true); }, true);
} }
public Bindable<JudgementResult> LastResult = new Bindable<JudgementResult>();
protected override void Update() protected override void Update()
{ {
base.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) if (first.ScreenSpaceDrawQuad.TopLeft.X >= ScreenSpaceDrawQuad.TopLeft.X)
{ {
// add the x coordinates and perform re-layout on all sprites as spacing may change with gameplay scale. foreach (var internalChild in InternalChildren)
sprite.X = additiveX ??= sprite.X - (float)Time.Elapsed * 0.1f; internalChild.X -= first.DrawWidth;
}
additiveX += sprite.DrawWidth - 1; if (last.ScreenSpaceDrawQuad.TopRight.X <= ScreenSpaceDrawQuad.TopRight.X)
{
if (sprite.X + sprite.DrawWidth < 0) foreach (var internalChild in InternalChildren)
sprite.Expire(); internalChild.X += first.DrawWidth;
}
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
});
} }
} }

View File

@ -8,6 +8,7 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Textures; using osu.Framework.Graphics.Textures;
using osu.Game.Audio; using osu.Game.Audio;
using osu.Game.Rulesets.Taiko.UI;
using osu.Game.Skinning; using osu.Game.Skinning;
namespace osu.Game.Rulesets.Taiko.Skinning namespace osu.Game.Rulesets.Taiko.Skinning
@ -86,11 +87,17 @@ namespace osu.Game.Rulesets.Taiko.Skinning
return null; return null;
case TaikoSkinComponents.TaikoScroller: case TaikoSkinComponents.Scroller:
if (GetTexture("taiko-slider") != null) if (GetTexture("taiko-slider") != null)
return new LegacyTaikoScroller(); return new LegacyTaikoScroller();
return null; return null;
case TaikoSkinComponents.Mascot:
if (GetTexture("pippidonclear0") != null)
return new DrawableTaikoMascot();
return null;
} }
return source.GetDrawableComponent(component); return source.GetDrawableComponent(component);

View File

@ -18,6 +18,7 @@ namespace osu.Game.Rulesets.Taiko
TaikoExplosionMiss, TaikoExplosionMiss,
TaikoExplosionGood, TaikoExplosionGood,
TaikoExplosionGreat, TaikoExplosionGreat,
TaikoScroller Scroller,
Mascot,
} }
} }

View File

@ -0,0 +1,123 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<TaikoMascotAnimationState> State;
public readonly Bindable<JudgementResult> LastResult;
private readonly Dictionary<TaikoMascotAnimationState, TaikoMascotAnimation> animations;
private TaikoMascotAnimation currentAnimation;
private bool lastObjectHit = true;
private bool kiaiMode;
public DrawableTaikoMascot(TaikoMascotAnimationState startingState = TaikoMascotAnimationState.Idle)
{
Origin = Anchor = Anchor.BottomLeft;
State = new Bindable<TaikoMascotAnimationState>(startingState);
LastResult = new Bindable<JudgementResult>();
animations = new Dictionary<TaikoMascotAnimationState, TaikoMascotAnimation>();
}
[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<JudgementResult>)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<JudgementResult> 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<TaikoMascotAnimationState> 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;
}
}

View File

@ -42,7 +42,7 @@ namespace osu.Game.Rulesets.Taiko.UI
{ {
new BarLineGenerator<BarLine>(Beatmap).BarLines.ForEach(bar => Playfield.Add(bar.Major ? new DrawableBarLineMajor(bar) : new DrawableBarLine(bar))); new BarLineGenerator<BarLine>(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, RelativeSizeAxes = Axes.X,
Depth = float.MaxValue Depth = float.MaxValue

View File

@ -0,0 +1,133 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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}");
}
}

View File

@ -0,0 +1,13 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
}
}

View File

@ -15,6 +15,7 @@ using osu.Game.Rulesets.Taiko.Objects.Drawables;
using osu.Game.Rulesets.Taiko.Judgements; using osu.Game.Rulesets.Taiko.Judgements;
using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Skinning; using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Taiko.UI namespace osu.Game.Rulesets.Taiko.UI
{ {
@ -32,6 +33,7 @@ namespace osu.Game.Rulesets.Taiko.UI
private JudgementContainer<DrawableTaikoJudgement> judgementContainer; private JudgementContainer<DrawableTaikoJudgement> judgementContainer;
private ScrollingHitObjectContainer drumRollHitContainer; private ScrollingHitObjectContainer drumRollHitContainer;
internal Drawable HitTarget; internal Drawable HitTarget;
private SkinnableDrawable mascot;
private ProxyContainer topLevelHitContainer; private ProxyContainer topLevelHitContainer;
private ProxyContainer barlineContainer; 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 topLevelHitContainer = new ProxyContainer
{ {
Name = "Top level hit objects", Name = "Top level hit objects",
RelativeSizeAxes = Axes.Both, 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. // This is basically allowing for correct alignment as relative pieces move around them.
rightArea.Padding = new MarginPadding { Left = leftArea.DrawWidth }; rightArea.Padding = new MarginPadding { Left = leftArea.DrawWidth };
hitTargetOffsetContent.Padding = new MarginPadding { Left = HitTarget.DrawWidth / 2 }; hitTargetOffsetContent.Padding = new MarginPadding { Left = HitTarget.DrawWidth / 2 };
mascot.Scale = new Vector2(DrawHeight / DEFAULT_HEIGHT);
} }
public override void Add(DrawableHitObject h) public override void Add(DrawableHitObject h)

View File

@ -13,18 +13,16 @@ namespace osu.Game.Rulesets.Taiko.UI
private const float default_relative_height = TaikoPlayfield.DEFAULT_HEIGHT / 768; private const float default_relative_height = TaikoPlayfield.DEFAULT_HEIGHT / 768;
private const float default_aspect = 16f / 9f; private const float default_aspect = 16f / 9f;
public TaikoPlayfieldAdjustmentContainer()
{
Anchor = Anchor.CentreLeft;
Origin = Anchor.CentreLeft;
}
protected override void Update() protected override void Update()
{ {
base.Update(); base.Update();
float aspectAdjust = Math.Clamp(Parent.ChildSize.X / Parent.ChildSize.Y, 0.4f, 4) / default_aspect; float aspectAdjust = Math.Clamp(Parent.ChildSize.X / Parent.ChildSize.Y, 0.4f, 4) / default_aspect;
Size = new Vector2(1, default_relative_height * aspectAdjust); 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;
} }
} }
} }

View File

@ -8,14 +8,23 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Configuration;
using osu.Framework.Platform; using osu.Framework.Platform;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.IO;
namespace osu.Game.Tests.NonVisual namespace osu.Game.Tests.NonVisual
{ {
[TestFixture] [TestFixture]
public class CustomDataDirectoryTest public class CustomDataDirectoryTest
{ {
[SetUp]
public void SetUp()
{
if (Directory.Exists(customPath))
Directory.Delete(customPath, true);
}
[Test] [Test]
public void TestDefaultDirectory() 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<Storage>();
// ensure we perform a save
host.Dependencies.Get<FrameworkConfigManager>().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<InvalidOperationException>(() => osu.Migrate(customPath));
}
finally
{
host.Exit();
}
}
}
private OsuGameBase loadOsu(GameHost host) private OsuGameBase loadOsu(GameHost host)
{ {
var osu = new OsuGameBase(); var osu = new OsuGameBase();

View File

@ -0,0 +1,19 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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 });
}
}
}

View File

@ -160,5 +160,13 @@ namespace osu.Game.Database
} }
} }
} }
public void FlushConnections()
{
foreach (var context in threadContexts.Values)
context.Dispose();
recycleThreadContexts();
}
} }
} }

View File

@ -0,0 +1,23 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
{
/// <summary>
/// Add a pagination cursor to the web request in the format required by osu-web.
/// </summary>
public static void AddCursor(this WebRequest webRequest, Cursor cursor)
{
cursor?.Properties.ForEach(x =>
{
webRequest.AddParameter("cursor[" + x.Key + "]", x.Value.ToString());
});
}
}
}

View File

@ -0,0 +1,273 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<DirectoryInfo> currentDirectory = new Bindable<DirectoryInfo>();
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<DirectoryInfo> 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<DirectoryInfo> 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<DirectoryInfo> dir)
{
flow.Clear();
List<DirectoryPiece> pathPieces = new List<DirectoryPiece>();
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<DirectoryInfo> 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;
}
}
}

View File

@ -1,6 +1,10 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // 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.Logging;
using osu.Framework.Platform; using osu.Framework.Platform;
using osu.Game.Configuration; using osu.Game.Configuration;
@ -9,17 +13,119 @@ namespace osu.Game.IO
{ {
public class OsuStorage : WrappedStorage 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) public OsuStorage(GameHost host)
: base(host.Storage, string.Empty) : base(host.Storage, string.Empty)
{ {
var storageConfig = new StorageConfigManager(host.Storage); this.host = host;
storageConfig = new StorageConfigManager(host.Storage);
var customStoragePath = storageConfig.Get<string>(StorageConfig.FullPath); var customStoragePath = storageConfig.Get<string>(StorageConfig.FullPath);
if (!string.IsNullOrEmpty(customStoragePath)) if (!string.IsNullOrEmpty(customStoragePath))
{
ChangeTargetStorage(host.GetStorage(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);
} }
} }
} }

View File

@ -9,12 +9,12 @@ using Newtonsoft.Json.Linq;
namespace osu.Game.IO.Serialization.Converters namespace osu.Game.IO.Serialization.Converters
{ {
/// <summary> /// <summary>
/// A type of <see cref="JsonConverter"/> that serializes a <see cref="List{T}"/> alongside /// A type of <see cref="JsonConverter"/> that serializes an <see cref="IReadOnlyList{T}"/> alongside
/// a lookup table for the types contained. The lookup table is used in deserialization to /// a lookup table for the types contained. The lookup table is used in deserialization to
/// reconstruct the objects with their original types. /// reconstruct the objects with their original types.
/// </summary> /// </summary>
/// <typeparam name="T">The type of objects contained in the <see cref="List{T}"/> this attribute is attached to.</typeparam> /// <typeparam name="T">The type of objects contained in the <see cref="IReadOnlyList{T}"/> this attribute is attached to.</typeparam>
public class TypedListConverter<T> : JsonConverter public class TypedListConverter<T> : JsonConverter<IReadOnlyList<T>>
{ {
private readonly bool requiresTypeVersion; private readonly bool requiresTypeVersion;
@ -36,9 +36,7 @@ namespace osu.Game.IO.Serialization.Converters
this.requiresTypeVersion = requiresTypeVersion; this.requiresTypeVersion = requiresTypeVersion;
} }
public override bool CanConvert(Type objectType) => objectType == typeof(List<T>); public override IReadOnlyList<T> ReadJson(JsonReader reader, Type objectType, IReadOnlyList<T> existingValue, bool hasExistingValue, JsonSerializer serializer)
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{ {
var list = new List<T>(); var list = new List<T>();
@ -59,14 +57,12 @@ namespace osu.Game.IO.Serialization.Converters
return list; return list;
} }
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) public override void WriteJson(JsonWriter writer, IReadOnlyList<T> value, JsonSerializer serializer)
{ {
var list = (IEnumerable<T>)value;
var lookupTable = new List<string>(); var lookupTable = new List<string>();
var objects = new List<JObject>(); var objects = new List<JObject>();
foreach (var item in list) foreach (var item in value)
{ {
var type = item.GetType(); var type = item.GetType();
var assemblyName = type.Assembly.GetName(); var assemblyName = type.Assembly.GetName();

View File

@ -11,26 +11,22 @@ namespace osu.Game.IO.Serialization.Converters
/// <summary> /// <summary>
/// A type of <see cref="JsonConverter"/> that serializes only the X and Y coordinates of a <see cref="Vector2"/>. /// A type of <see cref="JsonConverter"/> that serializes only the X and Y coordinates of a <see cref="Vector2"/>.
/// </summary> /// </summary>
public class Vector2Converter : JsonConverter public class Vector2Converter : JsonConverter<Vector2>
{ {
public override bool CanConvert(Type objectType) => objectType == typeof(Vector2); public override Vector2 ReadJson(JsonReader reader, Type objectType, Vector2 existingValue, bool hasExistingValue, JsonSerializer serializer)
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{ {
var obj = JObject.Load(reader); var obj = JObject.Load(reader);
return new Vector2((float)obj["x"], (float)obj["y"]); 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.WriteStartObject();
writer.WritePropertyName("x"); writer.WritePropertyName("x");
writer.WriteValue(vector.X); writer.WriteValue(value.X);
writer.WritePropertyName("y"); writer.WritePropertyName("y");
writer.WriteValue(vector.Y); writer.WriteValue(value.Y);
writer.WriteEndObject(); writer.WriteEndObject();
} }

View File

@ -27,7 +27,7 @@ namespace osu.Game.IO
protected virtual string MutatePath(string path) => !string.IsNullOrEmpty(subPath) ? Path.Combine(subPath, path) : path; 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; UnderlyingStorage = newStorage;
} }

View File

@ -0,0 +1,20 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
{
/// <summary>
/// A collection of parameters which should be passed to the search endpoint to fetch the next page.
/// </summary>
public class Cursor
{
[UsedImplicitly]
[JsonExtensionData]
public IDictionary<string, JToken> Properties;
}
}

View File

@ -7,10 +7,7 @@ namespace osu.Game.Online.API.Requests
{ {
public abstract class ResponseWithCursor public abstract class ResponseWithCursor
{ {
/// <summary>
/// A collection of parameters which should be passed to the search endpoint to fetch the next page.
/// </summary>
[JsonProperty("cursor")] [JsonProperty("cursor")]
public dynamic CursorJson; public Cursor Cursor;
} }
} }

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using osu.Framework.IO.Network; using osu.Framework.IO.Network;
using osu.Game.Extensions;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Game.Overlays.BeatmapListing; using osu.Game.Overlays.BeatmapListing;
using osu.Game.Rulesets; using osu.Game.Rulesets;
@ -10,29 +11,31 @@ namespace osu.Game.Online.API.Requests
{ {
public class SearchBeatmapSetsRequest : APIRequest<SearchBeatmapSetsResponse> public class SearchBeatmapSetsRequest : APIRequest<SearchBeatmapSetsResponse>
{ {
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 string query;
private readonly RulesetInfo ruleset; private readonly RulesetInfo ruleset;
private readonly Cursor cursor;
private string directionString => SortDirection == SortDirection.Descending ? @"desc" : @"asc"; 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.query = string.IsNullOrEmpty(query) ? string.Empty : System.Uri.EscapeDataString(query);
this.ruleset = ruleset; this.ruleset = ruleset;
this.cursor = cursor;
SearchCategory = SearchCategory.Any; SearchCategory = searchCategory;
SortCriteria = SortCriteria.Ranked; SortCriteria = sortCriteria;
SortDirection = SortDirection.Descending; SortDirection = sortDirection;
Genre = SearchGenre.Any; Genre = SearchGenre.Any;
Language = SearchLanguage.Any; Language = SearchLanguage.Any;
} }
@ -55,6 +58,8 @@ namespace osu.Game.Online.API.Requests
req.AddParameter("sort", $"{SortCriteria.ToString().ToLowerInvariant()}_{directionString}"); req.AddParameter("sort", $"{SortCriteria.ToString().ToLowerInvariant()}_{directionString}");
req.AddCursor(cursor);
return req; return req;
} }

View File

@ -91,7 +91,7 @@ namespace osu.Game
protected BackButton BackButton; protected BackButton BackButton;
protected SettingsPanel Settings; protected SettingsOverlay Settings;
private VolumeOverlay volume; private VolumeOverlay volume;
private OsuLogo osuLogo; private OsuLogo osuLogo;
@ -767,13 +767,20 @@ namespace osu.Game
private Task asyncLoadStream; private Task asyncLoadStream;
private T loadComponentSingleFile<T>(T d, Action<T> add, bool cache = false) /// <summary>
/// Queues loading the provided component in sequential fashion.
/// This operation is limited to a single thread to avoid saturating all cores.
/// </summary>
/// <param name="component">The component to load.</param>
/// <param name="loadCompleteAction">An action to invoke on load completion (generally to add the component to the hierarchy).</param>
/// <param name="cache">Whether to cache the component as type <typeparamref name="T"/> into the game dependencies before any scheduling.</param>
private T loadComponentSingleFile<T>(T component, Action<T> loadCompleteAction, bool cache = false)
where T : Drawable where T : Drawable
{ {
if (cache) if (cache)
dependencies.Cache(d); dependencies.CacheAs(component);
if (d is OverlayContainer overlay) if (component is OverlayContainer overlay)
overlays.Add(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). // 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 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 // 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 // throwing an exception. To avoid this, the call is scheduled on the update thread, which does not run if IsDisposed = true
Task task = null; Task task = null;
var del = new ScheduledDelegate(() => task = LoadComponentAsync(d, add)); var del = new ScheduledDelegate(() => task = LoadComponentAsync(component, loadCompleteAction));
Scheduler.Add(del); Scheduler.Add(del);
// The delegate won't complete if OsuGame has been disposed in the meantime // The delegate won't complete if OsuGame has been disposed in the meantime
@ -811,7 +818,7 @@ namespace osu.Game
await task; await task;
Logger.Log($"Loaded {d}!", level: LogLevel.Debug); Logger.Log($"Loaded {component}!", level: LogLevel.Debug);
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@ -819,7 +826,7 @@ namespace osu.Game
}); });
}); });
return d; return component;
} }
protected override bool OnScroll(ScrollEvent e) protected override bool OnScroll(ScrollEvent e)

View File

@ -328,6 +328,8 @@ namespace osu.Game
{ {
base.Dispose(isDisposing); base.Dispose(isDisposing);
RulesetStore?.Dispose(); RulesetStore?.Dispose();
contextFactory.FlushConnections();
} }
private class OsuUserInputManager : UserInputManager private class OsuUserInputManager : UserInputManager
@ -355,5 +357,11 @@ namespace osu.Game
public override bool ChangeFocusOnClick => false; public override bool ChangeFocusOnClick => false;
} }
} }
public void Migrate(string path)
{
contextFactory.FlushConnections();
(Storage as OsuStorage)?.Migrate(path);
}
} }
} }

View File

@ -22,25 +22,46 @@ namespace osu.Game.Overlays.BeatmapListing
{ {
public class BeatmapListingFilterControl : CompositeDrawable public class BeatmapListingFilterControl : CompositeDrawable
{ {
/// <summary>
/// Fired when a search finishes. Contains only new items in the case of pagination.
/// </summary>
public Action<List<BeatmapSetInfo>> SearchFinished; public Action<List<BeatmapSetInfo>> SearchFinished;
/// <summary>
/// Fired when search criteria change.
/// </summary>
public Action SearchStarted; public Action SearchStarted;
/// <summary>
/// True when pagination has reached the end of available results.
/// </summary>
private bool noMoreResults;
/// <summary>
/// The current page fetched of results (zero index).
/// </summary>
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] [Resolved]
private IAPIProvider api { get; set; } private IAPIProvider api { get; set; }
[Resolved] [Resolved]
private RulesetStore rulesets { get; set; } private RulesetStore rulesets { get; set; }
private readonly BeatmapListingSearchControl searchControl;
private readonly BeatmapListingSortTabControl sortControl;
private readonly Box sortControlBackground;
private SearchBeatmapSetsRequest getSetsRequest;
public BeatmapListingFilterControl() public BeatmapListingFilterControl()
{ {
RelativeSizeAxes = Axes.X; RelativeSizeAxes = Axes.X;
AutoSizeAxes = Axes.Y; AutoSizeAxes = Axes.Y;
InternalChild = new FillFlowContainer InternalChild = new FillFlowContainer
{ {
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
@ -114,51 +135,84 @@ namespace osu.Game.Overlays.BeatmapListing
sortDirection.BindValueChanged(_ => queueUpdateSearch()); sortDirection.BindValueChanged(_ => queueUpdateSearch());
} }
private ScheduledDelegate queryChangedDebounce; public void TakeFocus() => searchControl.TakeFocus();
/// <summary>
/// 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.
/// </summary>
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) private void queueUpdateSearch(bool queryTextChanged = false)
{ {
SearchStarted?.Invoke(); SearchStarted?.Invoke();
getSetsRequest?.Cancel(); resetSearch();
queryChangedDebounce?.Cancel(); queryChangedDebounce = Scheduler.AddDelayed(() =>
queryChangedDebounce = Scheduler.AddDelayed(updateSearch, queryTextChanged ? 500 : 100); {
resetSearch();
FetchNextPage();
}, queryTextChanged ? 500 : 100);
} }
private void updateSearch() private void performRequest()
{ {
getSetsRequest = new SearchBeatmapSetsRequest(searchControl.Query.Value, searchControl.Ruleset.Value) getSetsRequest = new SearchBeatmapSetsRequest(
{ searchControl.Query.Value,
SearchCategory = searchControl.Category.Value, searchControl.Ruleset.Value,
SortCriteria = sortControl.Current.Value, lastResponse?.Cursor,
SortDirection = sortControl.SortDirection.Value, searchControl.Category.Value,
Genre = searchControl.Genre.Value, sortControl.Current.Value,
Language = searchControl.Language.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); 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) protected override void Dispose(bool isDisposing)
{ {
getSetsRequest?.Cancel(); resetSearch();
queryChangedDebounce?.Cancel();
base.Dispose(isDisposing); base.Dispose(isDisposing);
} }
public void TakeFocus() => searchControl.TakeFocus();
} }
} }

View File

@ -4,7 +4,9 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
@ -30,6 +32,10 @@ namespace osu.Game.Overlays
private Drawable currentContent; private Drawable currentContent;
private LoadingLayer loadingLayer; private LoadingLayer loadingLayer;
private Container panelTarget; private Container panelTarget;
private FillFlowContainer<BeatmapPanel> foundContent;
private NotFoundDrawable notFoundContent;
private OverlayScrollContainer resultScrollContainer;
public BeatmapListingOverlay() public BeatmapListingOverlay()
: base(OverlayColourScheme.Blue) : base(OverlayColourScheme.Blue)
@ -48,7 +54,7 @@ namespace osu.Game.Overlays
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Colour = ColourProvider.Background6 Colour = ColourProvider.Background6
}, },
new OverlayScrollContainer resultScrollContainer = new OverlayScrollContainer
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
ScrollbarVisible = false, ScrollbarVisible = false,
@ -80,9 +86,14 @@ namespace osu.Game.Overlays
{ {
AutoSizeAxes = Axes.Y, AutoSizeAxes = Axes.Y,
RelativeSizeAxes = Axes.X, RelativeSizeAxes = Axes.X,
Padding = new MarginPadding { Horizontal = 20 } Padding = new MarginPadding { Horizontal = 20 },
}, Children = new Drawable[]
loadingLayer = new LoadingLayer(panelTarget) {
foundContent = new FillFlowContainer<BeatmapPanel>(),
notFoundContent = new NotFoundDrawable(),
loadingLayer = new LoadingLayer(panelTarget)
}
}
} }
}, },
} }
@ -110,34 +121,53 @@ namespace osu.Game.Overlays
loadingLayer.Show(); loadingLayer.Show();
} }
private Task panelLoadDelegate;
private void onSearchFinished(List<BeatmapSetInfo> beatmaps) private void onSearchFinished(List<BeatmapSetInfo> beatmaps)
{ {
if (!beatmaps.Any()) var newPanels = beatmaps.Select<BeatmapSetInfo, BeatmapPanel>(b => new GridBeatmapPanel(b)
{ {
LoadComponentAsync(new NotFoundDrawable(), addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token); Anchor = Anchor.TopCentre,
return; Origin = Anchor.TopCentre,
} });
var newPanels = new FillFlowContainer<BeatmapPanel> if (filterControl.CurrentPage == 0)
{ {
RelativeSizeAxes = Axes.X, //No matches case
AutoSizeAxes = Axes.Y, if (!newPanels.Any())
Spacing = new Vector2(10),
Alpha = 0,
Margin = new MarginPadding { Vertical = 15 },
ChildrenEnumerable = beatmaps.Select<BeatmapSetInfo, BeatmapPanel>(b => new GridBeatmapPanel(b)
{ {
Anchor = Anchor.TopCentre, LoadComponentAsync(notFoundContent, addContentToPlaceholder, (cancellationToken = new CancellationTokenSource()).Token);
Origin = Anchor.TopCentre, 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<BeatmapPanel>
{
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) private void addContentToPlaceholder(Drawable content)
{ {
loadingLayer.Hide(); loadingLayer.Hide();
lastFetchDisplayedTime = Time.Current;
var lastContent = currentContent; 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. // 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. // 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. // 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); if (!content.IsAlive)
currentContent.FadeIn(200, Easing.OutQuint); panelTarget.Add(content);
content.FadeIn(200, Easing.OutQuint);
currentContent = content;
} }
protected override void Dispose(bool isDisposing) 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();
}
} }
} }

View File

@ -25,7 +25,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="ppy.osu.Framework" Version="2020.511.0" /> <PackageReference Include="ppy.osu.Framework" Version="2020.511.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2020.427.0" /> <PackageReference Include="ppy.osu.Game.Resources" Version="2020.512.0" />
<PackageReference Include="Sentry" Version="2.1.1" /> <PackageReference Include="Sentry" Version="2.1.1" />
<PackageReference Include="SharpCompress" Version="0.25.0" /> <PackageReference Include="SharpCompress" Version="0.25.0" />
<PackageReference Include="NUnit" Version="3.12.0" /> <PackageReference Include="NUnit" Version="3.12.0" />

View File

@ -71,7 +71,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup Label="Package References"> <ItemGroup Label="Package References">
<PackageReference Include="ppy.osu.Framework.iOS" Version="2020.511.0" /> <PackageReference Include="ppy.osu.Framework.iOS" Version="2020.511.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2020.427.0" /> <PackageReference Include="ppy.osu.Game.Resources" Version="2020.512.0" />
</ItemGroup> </ItemGroup>
<!-- Xamarin.iOS does not automatically handle transitive dependencies from NuGet packages. --> <!-- Xamarin.iOS does not automatically handle transitive dependencies from NuGet packages. -->
<ItemGroup Label="Transitive Dependencies"> <ItemGroup Label="Transitive Dependencies">