diff --git a/osu.Android.props b/osu.Android.props index 6dab6edc5e..0b43fd73f5 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -52,6 +52,6 @@ - + diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs index e8ecd2ca1b..160da75aa9 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitObjects.cs @@ -1,9 +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 NUnit.Framework; using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Objects.Drawables; using osuTK; @@ -17,34 +18,49 @@ namespace osu.Game.Rulesets.Catch.Tests { base.LoadComplete(); - foreach (FruitVisualRepresentation rep in Enum.GetValues(typeof(FruitVisualRepresentation))) - AddStep($"show {rep}", () => SetContents(() => createDrawableFruit(rep))); + AddStep("show pear", () => SetContents(() => createDrawableFruit(0))); + AddStep("show grape", () => SetContents(() => createDrawableFruit(1))); + AddStep("show pineapple / apple", () => SetContents(() => createDrawableFruit(2))); + AddStep("show raspberry / orange", () => SetContents(() => createDrawableFruit(3))); + + AddStep("show banana", () => SetContents(createDrawableBanana)); AddStep("show droplet", () => SetContents(() => createDrawableDroplet())); AddStep("show tiny droplet", () => SetContents(createDrawableTinyDroplet)); - foreach (FruitVisualRepresentation rep in Enum.GetValues(typeof(FruitVisualRepresentation))) - AddStep($"show hyperdash {rep}", () => SetContents(() => createDrawableFruit(rep, true))); + AddStep("show hyperdash pear", () => SetContents(() => createDrawableFruit(0, true))); + AddStep("show hyperdash grape", () => SetContents(() => createDrawableFruit(1, true))); + AddStep("show hyperdash pineapple / apple", () => SetContents(() => createDrawableFruit(2, true))); + AddStep("show hyperdash raspberry / orange", () => SetContents(() => createDrawableFruit(3, true))); AddStep("show hyperdash droplet", () => SetContents(() => createDrawableDroplet(true))); } - private Drawable createDrawableFruit(FruitVisualRepresentation rep, bool hyperdash = false) => - setProperties(new DrawableFruit(new TestCatchFruit(rep)), hyperdash); + private Drawable createDrawableFruit(int indexInBeatmap, bool hyperdash = false) => + SetProperties(new DrawableFruit(new Fruit + { + IndexInBeatmap = indexInBeatmap, + HyperDashBindable = { Value = hyperdash } + })); - private Drawable createDrawableDroplet(bool hyperdash = false) => setProperties(new DrawableDroplet(new Droplet()), hyperdash); + private Drawable createDrawableBanana() => + SetProperties(new DrawableBanana(new Banana())); - private Drawable createDrawableTinyDroplet() => setProperties(new DrawableTinyDroplet(new TinyDroplet())); + private Drawable createDrawableDroplet(bool hyperdash = false) => + SetProperties(new DrawableDroplet(new Droplet + { + HyperDashBindable = { Value = hyperdash } + })); - private DrawableCatchHitObject setProperties(DrawableCatchHitObject d, bool hyperdash = false) + private Drawable createDrawableTinyDroplet() => SetProperties(new DrawableTinyDroplet(new TinyDroplet())); + + protected virtual DrawableCatchHitObject SetProperties(DrawableCatchHitObject d) { var hitObject = d.HitObject; + hitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = 0 }); hitObject.StartTime = 1000000000000; hitObject.Scale = 1.5f; - if (hyperdash) - ((PalpableCatchHitObject)hitObject).HyperDashTarget = new Banana(); - d.Anchor = Anchor.Centre; d.RelativePositionAxes = Axes.None; d.Position = Vector2.Zero; @@ -55,15 +71,5 @@ namespace osu.Game.Rulesets.Catch.Tests }; return d; } - - public class TestCatchFruit : Fruit - { - public TestCatchFruit(FruitVisualRepresentation rep) - { - VisualRepresentation = rep; - } - - public override FruitVisualRepresentation VisualRepresentation { get; } - } } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneFruitVisualChange.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitVisualChange.cs new file mode 100644 index 0000000000..4448e828e7 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneFruitVisualChange.cs @@ -0,0 +1,32 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.Objects.Drawables; + +namespace osu.Game.Rulesets.Catch.Tests +{ + public class TestSceneFruitVisualChange : TestSceneFruitObjects + { + private readonly Bindable indexInBeatmap = new Bindable(); + private readonly Bindable hyperDash = new Bindable(); + + protected override void LoadComplete() + { + AddStep("fruit changes visual and hyper", () => SetContents(() => SetProperties(new DrawableFruit(new Fruit + { + IndexInBeatmapBindable = { BindTarget = indexInBeatmap }, + HyperDashBindable = { BindTarget = hyperDash }, + })))); + + AddStep("droplet changes hyper", () => SetContents(() => SetProperties(new DrawableDroplet(new Droplet + { + HyperDashBindable = { BindTarget = hyperDash }, + })))); + + Scheduler.AddDelayed(() => indexInBeatmap.Value++, 250, true); + Scheduler.AddDelayed(() => hyperDash.Value = !hyperDash.Value, 1000, true); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Banana.cs b/osu.Game.Rulesets.Catch/Objects/Banana.cs index 4ecfb7b16d..ccb1fff15b 100644 --- a/osu.Game.Rulesets.Catch/Objects/Banana.cs +++ b/osu.Game.Rulesets.Catch/Objects/Banana.cs @@ -2,21 +2,22 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Rulesets.Catch.Judgements; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects.Types; +using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.Objects { - public class Banana : Fruit + public class Banana : Fruit, IHasComboInformation { /// /// Index of banana in current shower. /// public int BananaIndex; - public override FruitVisualRepresentation VisualRepresentation => FruitVisualRepresentation.Banana; - public override Judgement CreateJudgement() => new CatchBananaJudgement(); private static readonly List samples = new List { new BananaHitSampleInfo() }; @@ -26,6 +27,29 @@ namespace osu.Game.Rulesets.Catch.Objects Samples = samples; } + private Color4? colour; + + Color4 IHasComboInformation.GetComboColour(IReadOnlyList comboColours) + { + // override any external colour changes with banananana + return colour ??= getBananaColour(); + } + + private Color4 getBananaColour() + { + switch (RNG.Next(0, 3)) + { + default: + return new Color4(255, 240, 0, 255); + + case 1: + return new Color4(255, 192, 0, 255); + + case 2: + return new Color4(214, 221, 28, 255); + } + } + private class BananaHitSampleInfo : HitSampleInfo { private static string[] lookupNames { get; } = { "metronomelow", "catch-banana" }; diff --git a/osu.Game.Rulesets.Catch/Objects/BananaShower.cs b/osu.Game.Rulesets.Catch/Objects/BananaShower.cs index 89c51459a6..b45f95a8e6 100644 --- a/osu.Game.Rulesets.Catch/Objects/BananaShower.cs +++ b/osu.Game.Rulesets.Catch/Objects/BananaShower.cs @@ -9,8 +9,6 @@ namespace osu.Game.Rulesets.Catch.Objects { public class BananaShower : CatchHitObject, IHasDuration { - public override FruitVisualRepresentation VisualRepresentation => FruitVisualRepresentation.Banana; - public override bool LastInCombo => true; public override Judgement CreateJudgement() => new IgnoreJudgement(); diff --git a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs index ccd2422381..a74055bff9 100644 --- a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs @@ -16,27 +16,47 @@ namespace osu.Game.Rulesets.Catch.Objects { public const float OBJECT_RADIUS = 64; - private float x; + // This value is after XOffset applied. + public readonly Bindable XBindable = new Bindable(); + + // This value is before XOffset applied. + private float originalX; /// /// The horizontal position of the fruit between 0 and . /// public float X { - get => x + XOffset; - set => x = value; + // TODO: I don't like this asymmetry. + get => XBindable.Value; + // originalX is set by `XBindable.BindValueChanged` + set => XBindable.Value = value + xOffset; } + private float xOffset; + /// /// A random offset applied to , set by the . /// - internal float XOffset { get; set; } + internal float XOffset + { + get => xOffset; + set + { + xOffset = value; + XBindable.Value = originalX + xOffset; + } + } public double TimePreempt = 1000; - public int IndexInBeatmap { get; set; } + public readonly Bindable IndexInBeatmapBindable = new Bindable(); - public virtual FruitVisualRepresentation VisualRepresentation => (FruitVisualRepresentation)(IndexInBeatmap % 4); + public int IndexInBeatmap + { + get => IndexInBeatmapBindable.Value; + set => IndexInBeatmapBindable.Value = value; + } public virtual bool NewCombo { get; set; } @@ -69,7 +89,13 @@ namespace osu.Game.Rulesets.Catch.Objects set => LastInComboBindable.Value = value; } - public float Scale { get; set; } = 1; + public readonly Bindable ScaleBindable = new Bindable(1); + + public float Scale + { + get => ScaleBindable.Value; + set => ScaleBindable.Value = value; + } protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) { @@ -81,14 +107,10 @@ namespace osu.Game.Rulesets.Catch.Objects } protected override HitWindows CreateHitWindows() => HitWindows.Empty; - } - public enum FruitVisualRepresentation - { - Pear, - Grape, - Pineapple, - Raspberry, - Banana // banananananannaanana + protected CatchHitObject() + { + XBindable.BindValueChanged(x => originalX = x.NewValue - xOffset); + } } } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBanana.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBanana.cs index a865984d45..efb0958a3a 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBanana.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableBanana.cs @@ -1,28 +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 osu.Framework.Graphics; using osu.Framework.Utils; -using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.Objects.Drawables { public class DrawableBanana : DrawableFruit { + protected override FruitVisualRepresentation GetVisualRepresentation(int indexInBeatmap) => FruitVisualRepresentation.Banana; + public DrawableBanana(Banana h) : base(h) { } - private Color4? colour; - - protected override Color4 GetComboColour(IReadOnlyList comboColours) - { - // override any external colour changes with banananana - return colour ??= getBananaColour(); - } - protected override void UpdateInitialTransforms() { base.UpdateInitialTransforms(); @@ -46,20 +38,5 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables if (Samples != null) Samples.Frequency.Value = 0.77f + ((Banana)HitObject).BananaIndex * 0.006f; } - - private Color4 getBananaColour() - { - switch (RNG.Next(0, 3)) - { - default: - return new Color4(255, 240, 0, 255); - - case 1: - return new Color4(255, 192, 0, 255); - - case 2: - return new Color4(214, 221, 28, 255); - } - } } } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs index f9f534f9ab..1faa6a5b0f 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; +using JetBrains.Annotations; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Objects.Drawables; @@ -10,19 +12,34 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables { public abstract class DrawableCatchHitObject : DrawableHitObject { + public readonly Bindable XBindable = new Bindable(); + protected override double InitialLifetimeOffset => HitObject.TimePreempt; public float DisplayRadius => DrawSize.X / 2 * Scale.X * HitObject.Scale; protected override float SamplePlaybackPosition => HitObject.X / CatchPlayfield.WIDTH; - protected DrawableCatchHitObject(CatchHitObject hitObject) + protected DrawableCatchHitObject([CanBeNull] CatchHitObject hitObject) : base(hitObject) { - X = hitObject.X; Anchor = Anchor.BottomLeft; } + protected override void OnApply() + { + base.OnApply(); + + XBindable.BindTo(HitObject.XBindable); + } + + protected override void OnFree() + { + base.OnFree(); + + XBindable.UnbindFrom(HitObject.XBindable); + } + public Func CheckPosition; public bool IsOnPlate; diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableDroplet.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableDroplet.cs index 74cd240aa3..9e76265394 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableDroplet.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableDroplet.cs @@ -21,7 +21,17 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables [BackgroundDependencyLoader] private void load() { - ScaleContainer.Child = new SkinnableDrawable(new CatchSkinComponent(CatchSkinComponents.Droplet), _ => new DropletPiece()); + HyperDash.BindValueChanged(_ => updatePiece(), true); + } + + private void updatePiece() + { + ScaleContainer.Child = new SkinnableDrawable( + new CatchSkinComponent(CatchSkinComponents.Droplet), + _ => new DropletPiece + { + HyperDash = { BindTarget = HyperDash } + }); } protected override void UpdateInitialTransforms() diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.cs index 96e24bf76c..4338d80345 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableFruit.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Utils; using osu.Game.Rulesets.Catch.Objects.Drawables.Pieces; using osu.Game.Skinning; @@ -11,6 +12,10 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables { public class DrawableFruit : DrawablePalpableCatchHitObject { + public readonly Bindable VisualRepresentation = new Bindable(); + + protected virtual FruitVisualRepresentation GetVisualRepresentation(int indexInBeatmap) => (FruitVisualRepresentation)(indexInBeatmap % 4); + public DrawableFruit(CatchHitObject h) : base(h) { @@ -19,10 +24,26 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables [BackgroundDependencyLoader] private void load() { - ScaleContainer.Child = new SkinnableDrawable( - new CatchSkinComponent(getComponent(HitObject.VisualRepresentation)), _ => new FruitPiece()); - ScaleContainer.Rotation = (float)(RNG.NextDouble() - 0.5f) * 40; + + IndexInBeatmap.BindValueChanged(change => + { + VisualRepresentation.Value = GetVisualRepresentation(change.NewValue); + }, true); + + VisualRepresentation.BindValueChanged(_ => updatePiece()); + HyperDash.BindValueChanged(_ => updatePiece(), true); + } + + private void updatePiece() + { + ScaleContainer.Child = new SkinnableDrawable( + new CatchSkinComponent(getComponent(VisualRepresentation.Value)), + _ => new FruitPiece + { + VisualRepresentation = { BindTarget = VisualRepresentation }, + HyperDash = { BindTarget = HyperDash }, + }); } private CatchSkinComponents getComponent(FruitVisualRepresentation hitObjectVisualRepresentation) @@ -49,4 +70,13 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables } } } + + public enum FruitVisualRepresentation + { + Pear, + Grape, + Pineapple, + Raspberry, + Banana // banananananannaanana + } } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawablePalpableCatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawablePalpableCatchHitObject.cs index 935aad914e..a3908f94b6 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawablePalpableCatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawablePalpableCatchHitObject.cs @@ -1,12 +1,12 @@ // 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 osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osuTK; -using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.Objects.Drawables { @@ -14,6 +14,17 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables { public new PalpableCatchHitObject HitObject => (PalpableCatchHitObject)base.HitObject; + public readonly Bindable HyperDash = new Bindable(); + + public readonly Bindable ScaleBindable = new Bindable(1); + + public readonly Bindable IndexInBeatmap = new Bindable(); + + /// + /// The multiplicative factor applied to scale relative to scale. + /// + protected virtual float ScaleFactor => 1; + /// /// Whether this hit object should stay on the catcher plate when the object is caught by the catcher. /// @@ -21,7 +32,7 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables protected readonly Container ScaleContainer; - protected DrawablePalpableCatchHitObject(CatchHitObject h) + protected DrawablePalpableCatchHitObject([CanBeNull] CatchHitObject h) : base(h) { Origin = Anchor.Centre; @@ -38,10 +49,35 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables [BackgroundDependencyLoader] private void load() { - ScaleContainer.Scale = new Vector2(HitObject.Scale); + XBindable.BindValueChanged(x => + { + if (!IsOnPlate) X = x.NewValue; + }, true); + + ScaleBindable.BindValueChanged(scale => + { + ScaleContainer.Scale = new Vector2(scale.NewValue * ScaleFactor); + }, true); + + IndexInBeatmap.BindValueChanged(_ => UpdateComboColour()); } - protected override Color4 GetComboColour(IReadOnlyList comboColours) => - comboColours[(HitObject.IndexInBeatmap + 1) % comboColours.Count]; + protected override void OnApply() + { + base.OnApply(); + + HyperDash.BindTo(HitObject.HyperDashBindable); + ScaleBindable.BindTo(HitObject.ScaleBindable); + IndexInBeatmap.BindTo(HitObject.IndexInBeatmapBindable); + } + + protected override void OnFree() + { + HyperDash.UnbindFrom(HitObject.HyperDashBindable); + ScaleBindable.UnbindFrom(HitObject.ScaleBindable); + IndexInBeatmap.UnbindFrom(HitObject.IndexInBeatmapBindable); + + base.OnFree(); + } } } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableTinyDroplet.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableTinyDroplet.cs index ae775684d8..8c4d821b4a 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableTinyDroplet.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableTinyDroplet.cs @@ -1,21 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Allocation; - namespace osu.Game.Rulesets.Catch.Objects.Drawables { public class DrawableTinyDroplet : DrawableDroplet { + protected override float ScaleFactor => base.ScaleFactor / 2; + public DrawableTinyDroplet(TinyDroplet h) : base(h) { } - - [BackgroundDependencyLoader] - private void load() - { - ScaleContainer.Scale /= 2; - } } } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/DropletPiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/DropletPiece.cs index bcef30fda8..c90407ae15 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/DropletPiece.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/DropletPiece.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects.Drawables; @@ -11,6 +12,8 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables.Pieces { public class DropletPiece : CompositeDrawable { + public readonly Bindable HyperDash = new Bindable(); + public DropletPiece() { Size = new Vector2(CatchHitObject.OBJECT_RADIUS / 2); @@ -19,15 +22,13 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables.Pieces [BackgroundDependencyLoader] private void load(DrawableHitObject drawableObject) { - var drawableCatchObject = (DrawablePalpableCatchHitObject)drawableObject; - InternalChild = new Pulp { RelativeSizeAxes = Axes.Both, AccentColour = { BindTarget = drawableObject.AccentColour } }; - if (drawableCatchObject.HitObject.HyperDash) + if (HyperDash.Value) { AddInternal(new HyperDropletBorderPiece()); } diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/FruitPiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/FruitPiece.cs index 208c9f8316..31487ee407 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/FruitPiece.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/Pieces/FruitPiece.cs @@ -2,7 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System; +using JetBrains.Annotations; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects.Drawables; @@ -16,36 +18,39 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables.Pieces /// public const float RADIUS_ADJUST = 1.1f; - private BorderPiece border; - private PalpableCatchHitObject hitObject; + public readonly Bindable VisualRepresentation = new Bindable(); + public readonly Bindable HyperDash = new Bindable(); + + [CanBeNull] + private DrawableCatchHitObject drawableHitObject; + + [CanBeNull] + private BorderPiece borderPiece; public FruitPiece() { RelativeSizeAxes = Axes.Both; } - [BackgroundDependencyLoader] - private void load(DrawableHitObject drawableObject) + [BackgroundDependencyLoader(permitNulls: true)] + private void load([CanBeNull] DrawableHitObject drawable) { - var drawableCatchObject = (DrawablePalpableCatchHitObject)drawableObject; - hitObject = drawableCatchObject.HitObject; + drawableHitObject = (DrawableCatchHitObject)drawable; - AddRangeInternal(new[] - { - getFruitFor(hitObject.VisualRepresentation), - border = new BorderPiece(), - }); + AddInternal(getFruitFor(VisualRepresentation.Value)); - if (hitObject.HyperDash) - { + // if it is not part of a DHO, the border is always invisible. + if (drawableHitObject != null) + AddInternal(borderPiece = new BorderPiece()); + + if (HyperDash.Value) AddInternal(new HyperBorderPiece()); - } } protected override void Update() { - base.Update(); - border.Alpha = (float)Math.Clamp((hitObject.StartTime - Time.Current) / 500, 0, 1); + if (borderPiece != null && drawableHitObject?.HitObject != null) + borderPiece.Alpha = (float)Math.Clamp((drawableHitObject.HitObject.StartTime - Time.Current) / 500, 0, 1); } private Drawable getFruitFor(FruitVisualRepresentation representation) diff --git a/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs index 361b338b2c..0cd3af01df 100644 --- a/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs @@ -1,13 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using osu.Framework.Bindables; +using osu.Game.Rulesets.Objects.Types; +using osuTK.Graphics; + namespace osu.Game.Rulesets.Catch.Objects { /// /// Represents a single object that can be caught by the catcher. /// This includes normal fruits, droplets, and bananas but excludes objects that act only as a container of nested hit objects. /// - public abstract class PalpableCatchHitObject : CatchHitObject + public abstract class PalpableCatchHitObject : CatchHitObject, IHasComboInformation { /// /// Difference between the distance to the next object @@ -16,14 +21,28 @@ namespace osu.Game.Rulesets.Catch.Objects /// public float DistanceToHyperDash { get; set; } + public readonly Bindable HyperDashBindable = new Bindable(); + /// /// Whether this fruit can initiate a hyperdash. /// - public bool HyperDash => HyperDashTarget != null; + public bool HyperDash => HyperDashBindable.Value; + + private CatchHitObject hyperDashTarget; /// /// The target fruit if we are to initiate a hyperdash. /// - public CatchHitObject HyperDashTarget; + public CatchHitObject HyperDashTarget + { + get => hyperDashTarget; + set + { + hyperDashTarget = value; + HyperDashBindable.Value = value != null; + } + } + + Color4 IHasComboInformation.GetComboColour(IReadOnlyList comboColours) => comboColours[(IndexInBeatmap + 1) % comboColours.Count]; } } diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs index abbdeacd9a..9df32d8d36 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs @@ -55,7 +55,13 @@ namespace osu.Game.Rulesets.Catch.UI HitObjectContainer, CatcherArea, }; + } + protected override void LoadComplete() + { + base.LoadComplete(); + + // these subscriptions need to be done post constructor to ensure externally bound components have a chance to populate required fields (ScoreProcessor / ComboAtJudgement in this case). NewResult += onNewResult; RevertResult += onRevertResult; } diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs index 9aabcc6699..d2a9b69b60 100644 --- a/osu.Game.Rulesets.Mania/UI/Column.cs +++ b/osu.Game.Rulesets.Mania/UI/Column.cs @@ -97,7 +97,7 @@ namespace osu.Game.Rulesets.Mania.UI DrawableManiaHitObject maniaObject = (DrawableManiaHitObject)hitObject; maniaObject.CheckHittable = hitPolicy.IsHittable; - HitObjectContainer.Add(hitObject); + base.Add(hitObject); } public override bool Remove(DrawableHitObject h) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs index a26db06ede..4b7f048c1b 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableOsuHitObject.cs @@ -53,9 +53,9 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables }); } - protected override void OnApply(HitObject hitObject) + protected override void OnApply() { - base.OnApply(hitObject); + base.OnApply(); IndexInCurrentComboBindable.BindTo(HitObject.IndexInCurrentComboBindable); PositionBindable.BindTo(HitObject.PositionBindable); @@ -70,9 +70,9 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables LifetimeEnd = HitObject.GetEndTime() + HitObject.HitWindows.WindowFor(HitResult.Miss) + 1000; } - protected override void OnFree(HitObject hitObject) + protected override void OnFree() { - base.OnFree(hitObject); + base.OnFree(); IndexInCurrentComboBindable.UnbindFrom(HitObject.IndexInCurrentComboBindable); PositionBindable.UnbindFrom(HitObject.PositionBindable); diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index 6340367593..dd27ac990e 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -86,18 +86,18 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Tracking.BindValueChanged(updateSlidingSample); } - protected override void OnApply(HitObject hitObject) + protected override void OnApply() { - base.OnApply(hitObject); + base.OnApply(); // Ensure that the version will change after the upcoming BindTo(). pathVersion.Value = int.MaxValue; PathVersion.BindTo(HitObject.Path.Version); } - protected override void OnFree(HitObject hitObject) + protected override void OnFree() { - base.OnFree(hitObject); + base.OnFree(); PathVersion.UnbindFrom(HitObject.Path.Version); } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs index fd0f35d20d..3a92938d75 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderHead.cs @@ -4,7 +4,6 @@ using System; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; @@ -36,9 +35,9 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables pathVersion.BindValueChanged(_ => updatePosition()); } - protected override void OnFree(HitObject hitObject) + protected override void OnFree() { - base.OnFree(hitObject); + base.OnFree(); pathVersion.UnbindFrom(drawableSlider.PathVersion); } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/MainCirclePiece.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/MainCirclePiece.cs index bf2236c945..102166f8dd 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/MainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/MainCirclePiece.cs @@ -38,7 +38,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces }; } - private readonly IBindable state = new Bindable(); private readonly IBindable accentColour = new Bindable(); private readonly IBindable indexInCurrentCombo = new Bindable(); @@ -50,7 +49,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { var drawableOsuObject = (DrawableOsuHitObject)drawableObject; - state.BindTo(drawableObject.State); accentColour.BindTo(drawableObject.AccentColour); indexInCurrentCombo.BindTo(drawableOsuObject.IndexInCurrentComboBindable); } @@ -59,7 +57,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { base.LoadComplete(); - state.BindValueChanged(updateState, true); accentColour.BindValueChanged(colour => { explode.Colour = colour.NewValue; @@ -68,15 +65,18 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces }, true); indexInCurrentCombo.BindValueChanged(index => number.Text = (index.NewValue + 1).ToString(), true); + + drawableObject.ApplyCustomUpdateState += updateState; + updateState(drawableObject, drawableObject.State.Value); } - private void updateState(ValueChangedEvent state) + private void updateState(DrawableHitObject drawableObject, ArmedState state) { using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime, true)) { glow.FadeOut(400); - switch (state.NewValue) + switch (state) { case ArmedState.Hit: const double flash_in = 40; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBall.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBall.cs index c5bf790377..ca5ca7ac59 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBall.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/SliderBall.cs @@ -248,7 +248,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces } private void trackingChanged(ValueChangedEvent tracking) => - box.FadeTo(tracking.NewValue ? 0.6f : 0.05f, 200, Easing.OutQuint); + box.FadeTo(tracking.NewValue ? 0.3f : 0.05f, 200, Easing.OutQuint); } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/LegacyMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/LegacyMainCirclePiece.cs index 1551d1c149..21af9a479e 100644 --- a/osu.Game.Rulesets.Osu/Skinning/LegacyMainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/LegacyMainCirclePiece.cs @@ -38,7 +38,6 @@ namespace osu.Game.Rulesets.Osu.Skinning private SkinnableSpriteText hitCircleText; - private readonly IBindable state = new Bindable(); private readonly Bindable accentColour = new Bindable(); private readonly IBindable indexInCurrentCombo = new Bindable(); @@ -113,7 +112,6 @@ namespace osu.Game.Rulesets.Osu.Skinning if (overlayAboveNumber) AddInternal(hitCircleOverlay.CreateProxy()); - state.BindTo(drawableObject.State); accentColour.BindTo(drawableObject.AccentColour); indexInCurrentCombo.BindTo(drawableOsuObject.IndexInCurrentComboBindable); @@ -137,19 +135,21 @@ namespace osu.Game.Rulesets.Osu.Skinning { base.LoadComplete(); - state.BindValueChanged(updateState, true); accentColour.BindValueChanged(colour => hitCircleSprite.Colour = LegacyColourCompatibility.DisallowZeroAlpha(colour.NewValue), true); if (hasNumber) indexInCurrentCombo.BindValueChanged(index => hitCircleText.Text = (index.NewValue + 1).ToString(), true); + + drawableObject.ApplyCustomUpdateState += updateState; + updateState(drawableObject, drawableObject.State.Value); } - private void updateState(ValueChangedEvent state) + private void updateState(DrawableHitObject drawableObject, ArmedState state) { const double legacy_fade_duration = 240; using (BeginAbsoluteSequence(drawableObject.HitStateUpdateTime, true)) { - switch (state.NewValue) + switch (state) { case ArmedState.Hit: circleSprites.FadeOut(legacy_fade_duration, Easing.Out); diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs index 120cf264c3..370760f03e 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -37,7 +38,7 @@ namespace osu.Game.Rulesets.Taiko.UI private SkinnableDrawable mascot; private ProxyContainer topLevelHitContainer; - private ProxyContainer barlineContainer; + private ScrollingHitObjectContainer barlineContainer; private Container rightArea; private Container leftArea; @@ -83,10 +84,7 @@ namespace osu.Game.Rulesets.Taiko.UI RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - barlineContainer = new ProxyContainer - { - RelativeSizeAxes = Axes.Both, - }, + barlineContainer = new ScrollingHitObjectContainer(), new Container { Name = "Hit objects", @@ -159,18 +157,37 @@ namespace osu.Game.Rulesets.Taiko.UI public override void Add(DrawableHitObject h) { - h.OnNewResult += OnNewResult; - base.Add(h); - switch (h) { case DrawableBarLine barline: - barlineContainer.Add(barline.CreateProxy()); + barlineContainer.Add(barline); break; case DrawableTaikoHitObject taikoObject: + h.OnNewResult += OnNewResult; topLevelHitContainer.Add(taikoObject.CreateProxiedContent()); + base.Add(h); break; + + default: + throw new ArgumentException($"Unsupported {nameof(DrawableHitObject)} type"); + } + } + + public override bool Remove(DrawableHitObject h) + { + switch (h) + { + case DrawableBarLine barline: + return barlineContainer.Remove(barline); + + case DrawableTaikoHitObject _: + h.OnNewResult -= OnNewResult; + // todo: consider tidying of proxied content if required. + return base.Remove(h); + + default: + throw new ArgumentException($"Unsupported {nameof(DrawableHitObject)} type"); } } diff --git a/osu.Game.Tests/Editing/EditorChangeHandlerTest.cs b/osu.Game.Tests/Editing/EditorChangeHandlerTest.cs index b7a41ffd1c..481cb3230e 100644 --- a/osu.Game.Tests/Editing/EditorChangeHandlerTest.cs +++ b/osu.Game.Tests/Editing/EditorChangeHandlerTest.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; -using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.Edit; @@ -44,6 +43,36 @@ namespace osu.Game.Tests.Editing Assert.That(stateChangedFired, Is.EqualTo(2)); } + [Test] + public void TestApplyThenUndoThenApplySameChange() + { + var (handler, beatmap) = createChangeHandler(); + + Assert.That(handler.CanUndo.Value, Is.False); + Assert.That(handler.CanRedo.Value, Is.False); + + string originalHash = handler.CurrentStateHash; + + addArbitraryChange(beatmap); + handler.SaveState(); + + Assert.That(handler.CanUndo.Value, Is.True); + Assert.That(handler.CanRedo.Value, Is.False); + Assert.That(stateChangedFired, Is.EqualTo(1)); + + string hash = handler.CurrentStateHash; + + // undo a change without saving + handler.RestoreState(-1); + + Assert.That(originalHash, Is.EqualTo(handler.CurrentStateHash)); + Assert.That(stateChangedFired, Is.EqualTo(2)); + + addArbitraryChange(beatmap); + handler.SaveState(); + Assert.That(hash, Is.EqualTo(handler.CurrentStateHash)); + } + [Test] public void TestSaveSameStateDoesNotSave() { @@ -139,7 +168,7 @@ namespace osu.Game.Tests.Editing private void addArbitraryChange(EditorBeatmap beatmap) { - beatmap.Add(new HitCircle { StartTime = RNG.Next(0, 100000) }); + beatmap.Add(new HitCircle { StartTime = 2760 }); } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs index 1a1babb4a8..9931ee4a45 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableScrollingRuleset.cs @@ -21,13 +21,13 @@ using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Objects.Legacy; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osuTK; using osuTK.Graphics; +using JetBrains.Annotations; namespace osu.Game.Tests.Visual.Gameplay { @@ -46,6 +46,50 @@ namespace osu.Game.Tests.Visual.Gameplay [SetUp] public void Setup() => Schedule(() => testClock.CurrentTime = 0); + [TestCase("pooled")] + [TestCase("non-pooled")] + public void TestHitObjectLifetime(string pooled) + { + var beatmap = createBeatmap(_ => pooled == "pooled" ? new TestPooledHitObject() : new TestHitObject()); + beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range }); + createTest(beatmap); + + assertPosition(0, 0f); + assertDead(3); + + setTime(3 * time_range); + assertPosition(3, 0f); + assertDead(0); + + setTime(0 * time_range); + assertPosition(0, 0f); + assertDead(3); + } + + [TestCase("pooled")] + [TestCase("non-pooled")] + public void TestNestedHitObject(string pooled) + { + var beatmap = createBeatmap(i => + { + var h = pooled == "pooled" ? new TestPooledParentHitObject() : new TestParentHitObject(); + h.Duration = 300; + h.ChildTimeOffset = i % 3 * 100; + return h; + }); + beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range }); + createTest(beatmap); + + assertPosition(0, 0f); + assertHeight(0); + assertChildPosition(0); + + setTime(5 * time_range); + assertPosition(5, 0f); + assertHeight(5); + assertChildPosition(5); + } + [Test] public void TestRelativeBeatLengthScaleSingleTimingPoint() { @@ -147,8 +191,37 @@ namespace osu.Game.Tests.Visual.Gameplay assertPosition(1, 1); } + /// + /// Get a corresponding to the 'th . + /// When the hit object is not alive, `null` is returned. + /// + [CanBeNull] + private DrawableTestHitObject getDrawableHitObject(int index) + { + var hitObject = drawableRuleset.Beatmap.HitObjects.ElementAt(index); + return (DrawableTestHitObject)drawableRuleset.Playfield.HitObjectContainer.AliveObjects.FirstOrDefault(obj => obj.HitObject == hitObject); + } + + private float yScale => drawableRuleset.Playfield.HitObjectContainer.DrawHeight; + + private void assertDead(int index) => AddAssert($"hitobject {index} is dead", () => getDrawableHitObject(index) == null); + + private void assertHeight(int index) => AddAssert($"hitobject {index} height", () => + { + var d = getDrawableHitObject(index); + return d != null && Precision.AlmostEquals(d.DrawHeight, yScale * (float)(d.HitObject.Duration / time_range), 0.1f); + }); + + private void assertChildPosition(int index) => AddAssert($"hitobject {index} child position", () => + { + var d = getDrawableHitObject(index); + return d is DrawableTestParentHitObject && Precision.AlmostEquals( + d.NestedHitObjects.First().DrawPosition.Y, + yScale * (float)((TestParentHitObject)d.HitObject).ChildTimeOffset / time_range, 0.1f); + }); + private void assertPosition(int index, float relativeY) => AddAssert($"hitobject {index} at {relativeY}", - () => Precision.AlmostEquals(drawableRuleset.Playfield.AllHitObjects.ElementAt(index).DrawPosition.Y, drawableRuleset.Playfield.HitObjectContainer.DrawHeight * relativeY)); + () => Precision.AlmostEquals(getDrawableHitObject(index)?.DrawPosition.Y ?? -1, yScale * relativeY)); private void setTime(double time) { @@ -160,12 +233,16 @@ namespace osu.Game.Tests.Visual.Gameplay /// The hitobjects are spaced milliseconds apart. /// /// The . - private IBeatmap createBeatmap() + private IBeatmap createBeatmap(Func createAction = null) { - var beatmap = new Beatmap { BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo } }; + var beatmap = new Beatmap { BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo } }; for (int i = 0; i < 10; i++) - beatmap.HitObjects.Add(new HitObject { StartTime = i * time_range }); + { + var h = createAction?.Invoke(i) ?? new TestHitObject(); + h.StartTime = i * time_range; + beatmap.HitObjects.Add(h); + } return beatmap; } @@ -225,7 +302,21 @@ namespace osu.Game.Tests.Visual.Gameplay TimeRange.Value = time_range; } - public override DrawableHitObject CreateDrawableRepresentation(TestHitObject h) => new DrawableTestHitObject(h); + public override DrawableHitObject CreateDrawableRepresentation(TestHitObject h) + { + switch (h) + { + case TestPooledHitObject _: + case TestPooledParentHitObject _: + return null; + + case TestParentHitObject p: + return new DrawableTestParentHitObject(p); + + default: + return new DrawableTestHitObject(h); + } + } protected override PassThroughInputManager CreateInputManager() => new PassThroughInputManager(); @@ -265,6 +356,9 @@ namespace osu.Game.Tests.Visual.Gameplay } } }); + + RegisterPool(1); + RegisterPool(1); } } @@ -277,30 +371,46 @@ namespace osu.Game.Tests.Visual.Gameplay public override bool CanConvert() => true; - protected override IEnumerable ConvertHitObject(HitObject original, IBeatmap beatmap, CancellationToken cancellationToken) - { - yield return new TestHitObject - { - StartTime = original.StartTime, - Duration = (original as IHasDuration)?.Duration ?? 100 - }; - } + protected override IEnumerable ConvertHitObject(HitObject original, IBeatmap beatmap, CancellationToken cancellationToken) => + throw new NotImplementedException(); } #endregion #region HitObject - private class TestHitObject : ConvertHitObject, IHasDuration + private class TestHitObject : HitObject, IHasDuration { public double EndTime => StartTime + Duration; - public double Duration { get; set; } + public double Duration { get; set; } = 100; + } + + private class TestPooledHitObject : TestHitObject + { + } + + private class TestParentHitObject : TestHitObject + { + public double ChildTimeOffset; + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + AddNested(new TestHitObject { StartTime = StartTime + ChildTimeOffset }); + } + } + + private class TestPooledParentHitObject : TestParentHitObject + { + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + AddNested(new TestPooledHitObject { StartTime = StartTime + ChildTimeOffset }); + } } private class DrawableTestHitObject : DrawableHitObject { - public DrawableTestHitObject(TestHitObject hitObject) + public DrawableTestHitObject([CanBeNull] TestHitObject hitObject) : base(hitObject) { Anchor = Anchor.TopCentre; @@ -324,6 +434,52 @@ namespace osu.Game.Tests.Visual.Gameplay } }); } + + protected override void Update() => LifetimeEnd = HitObject.EndTime; + } + + private class DrawableTestPooledHitObject : DrawableTestHitObject + { + public DrawableTestPooledHitObject() + : base(null) + { + InternalChildren[0].Colour = Color4.LightSkyBlue; + InternalChildren[1].Colour = Color4.Blue; + } + } + + private class DrawableTestParentHitObject : DrawableTestHitObject + { + private readonly Container container; + + public DrawableTestParentHitObject([CanBeNull] TestHitObject hitObject) + : base(hitObject) + { + InternalChildren[0].Colour = Color4.LightYellow; + InternalChildren[1].Colour = Color4.Yellow; + + AddInternal(container = new Container + { + RelativeSizeAxes = Axes.Both, + }); + } + + protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) => + new DrawableTestHitObject((TestHitObject)hitObject); + + protected override void AddNestedHitObject(DrawableHitObject hitObject) => container.Add(hitObject); + + protected override void ClearNestedHitObjects() => container.Clear(false); + } + + private class DrawableTestPooledParentHitObject : DrawableTestParentHitObject + { + public DrawableTestPooledParentHitObject() + : base(null) + { + InternalChildren[0].Colour = Color4.LightSeaGreen; + InternalChildren[1].Colour = Color4.Green; + } } #endregion diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs index 3e777119c4..cd7d692b0a 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs @@ -261,9 +261,9 @@ namespace osu.Game.Tests.Visual.Gameplay }); } - protected override void OnApply(HitObject hitObject) + protected override void OnApply() { - base.OnApply(hitObject); + base.OnApply(); Position = new Vector2(RNG.Next(-200, 200), RNG.Next(-200, 200)); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStarCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStarCounter.cs index 709e71d195..717485bcc1 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStarCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStarCounter.cs @@ -3,7 +3,6 @@ using NUnit.Framework; using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; using osu.Framework.Utils; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -14,44 +13,41 @@ namespace osu.Game.Tests.Visual.Gameplay [TestFixture] public class TestSceneStarCounter : OsuTestScene { + private readonly StarCounter starCounter; + private readonly OsuSpriteText starsLabel; + public TestSceneStarCounter() { - StarCounter stars = new StarCounter + starCounter = new StarCounter { Origin = Anchor.Centre, Anchor = Anchor.Centre, - Current = 5, }; - Add(stars); + Add(starCounter); - SpriteText starsLabel = new OsuSpriteText + starsLabel = new OsuSpriteText { Origin = Anchor.Centre, Anchor = Anchor.Centre, Scale = new Vector2(2), Y = 50, - Text = stars.Current.ToString("0.00"), }; Add(starsLabel); - AddRepeatStep(@"random value", delegate - { - stars.Current = RNG.NextSingle() * (stars.StarCount + 1); - starsLabel.Text = stars.Current.ToString("0.00"); - }, 10); + setStars(5); - AddStep(@"Stop animation", delegate - { - stars.StopAnimation(); - }); + AddRepeatStep("random value", () => setStars(RNG.NextSingle() * (starCounter.StarCount + 1)), 10); + AddSliderStep("exact value", 0f, 10f, 5f, setStars); + AddStep("stop animation", () => starCounter.StopAnimation()); + AddStep("reset", () => setStars(0)); + } - AddStep(@"Reset", delegate - { - stars.Current = 0; - starsLabel.Text = stars.Current.ToString("0.00"); - }); + private void setStars(float stars) + { + starCounter.Current = stars; + starsLabel.Text = starCounter.Current.ToString("0.00"); } } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs index 4699784327..44c9361ff8 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs @@ -917,7 +917,7 @@ namespace osu.Game.Tests.Visual.SongSelect { get { - foreach (var item in ScrollableContent) + foreach (var item in Scroll.Children) { yield return item; diff --git a/osu.Game/Graphics/Containers/OsuScrollContainer.cs b/osu.Game/Graphics/Containers/OsuScrollContainer.cs index b9122d254d..aaad72f65c 100644 --- a/osu.Game/Graphics/Containers/OsuScrollContainer.cs +++ b/osu.Game/Graphics/Containers/OsuScrollContainer.cs @@ -12,7 +12,19 @@ using osuTK.Input; namespace osu.Game.Graphics.Containers { - public class OsuScrollContainer : ScrollContainer + public class OsuScrollContainer : OsuScrollContainer + { + public OsuScrollContainer() + { + } + + public OsuScrollContainer(Direction direction) + : base(direction) + { + } + } + + public class OsuScrollContainer : ScrollContainer where T : Drawable { public const float SCROLL_BAR_HEIGHT = 10; public const float SCROLL_BAR_PADDING = 3; diff --git a/osu.Game/Graphics/UserInterface/StarCounter.cs b/osu.Game/Graphics/UserInterface/StarCounter.cs index b13d6485ac..894a21fcf3 100644 --- a/osu.Game/Graphics/UserInterface/StarCounter.cs +++ b/osu.Game/Graphics/UserInterface/StarCounter.cs @@ -147,7 +147,7 @@ namespace osu.Game.Graphics.UserInterface public override void DisplayAt(float scale) { - scale = Math.Clamp(scale, min_star_scale, 1); + scale = (float)Interpolation.Lerp(min_star_scale, 1, Math.Clamp(scale, 0, 1)); this.FadeTo(scale, fading_duration); Icon.ScaleTo(scale, scaling_duration, scaling_easing); diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index e5d3a89a88..74eb2b0126 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -69,6 +69,7 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Control, InputKey.Plus }, GlobalAction.IncreaseScrollSpeed), new KeyBinding(new[] { InputKey.Control, InputKey.Minus }, GlobalAction.DecreaseScrollSpeed), new KeyBinding(InputKey.MouseMiddle, GlobalAction.PauseGameplay), + new KeyBinding(InputKey.Space, GlobalAction.TogglePauseReplay), new KeyBinding(InputKey.Control, GlobalAction.HoldForHUD), }; @@ -163,10 +164,10 @@ namespace osu.Game.Input.Bindings [Description("Toggle now playing overlay")] ToggleNowPlaying, - [Description("Previous Selection")] + [Description("Previous selection")] SelectPrevious, - [Description("Next Selection")] + [Description("Next selection")] SelectNext, [Description("Home")] @@ -175,26 +176,29 @@ namespace osu.Game.Input.Bindings [Description("Toggle notifications")] ToggleNotifications, - [Description("Pause")] + [Description("Pause gameplay")] PauseGameplay, // Editor - [Description("Setup Mode")] + [Description("Setup mode")] EditorSetupMode, - [Description("Compose Mode")] + [Description("Compose mode")] EditorComposeMode, - [Description("Design Mode")] + [Description("Design mode")] EditorDesignMode, - [Description("Timing Mode")] + [Description("Timing mode")] EditorTimingMode, [Description("Hold for HUD")] HoldForHUD, - [Description("Random Skin")] + [Description("Random skin")] RandomSkin, + + [Description("Pause / resume replay")] + TogglePauseReplay, } } diff --git a/osu.Game/Overlays/Settings/Sections/AudioSection.cs b/osu.Game/Overlays/Settings/Sections/AudioSection.cs index 69538358f1..7072d8e63d 100644 --- a/osu.Game/Overlays/Settings/Sections/AudioSection.cs +++ b/osu.Game/Overlays/Settings/Sections/AudioSection.cs @@ -27,7 +27,6 @@ namespace osu.Game.Overlays.Settings.Sections new AudioDevicesSettings(), new VolumeSettings(), new OffsetSettings(), - new MainMenuSettings() }; } } diff --git a/osu.Game/Overlays/Settings/Sections/GameplaySection.cs b/osu.Game/Overlays/Settings/Sections/GameplaySection.cs index e5cebd28e2..acb94a6a01 100644 --- a/osu.Game/Overlays/Settings/Sections/GameplaySection.cs +++ b/osu.Game/Overlays/Settings/Sections/GameplaySection.cs @@ -26,7 +26,6 @@ namespace osu.Game.Overlays.Settings.Sections Children = new Drawable[] { new GeneralSettings(), - new SongSelectSettings(), new ModsSettings(), }; } diff --git a/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs b/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs index c1b4b0bbcb..4ade48031f 100644 --- a/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs +++ b/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs @@ -23,7 +23,6 @@ namespace osu.Game.Overlays.Settings.Sections new RendererSettings(), new LayoutSettings(), new DetailSettings(), - new UserInterfaceSettings(), }; } } diff --git a/osu.Game/Overlays/Settings/Sections/SizeSlider.cs b/osu.Game/Overlays/Settings/Sections/SizeSlider.cs new file mode 100644 index 0000000000..101d8f43f7 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/SizeSlider.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Overlays.Settings.Sections +{ + /// + /// A slider intended to show a "size" multiplier number, where 1x is 1.0. + /// + internal class SizeSlider : OsuSliderBar + { + public override string TooltipText => Current.Value.ToString(@"0.##x"); + } +} diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index 3e7068f1ff..5898482e4a 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -54,12 +54,6 @@ namespace osu.Game.Overlays.Settings.Sections skinDropdown = new SkinSettingsDropdown(), new ExportSkinButton(), new SettingsSlider - { - LabelText = "Menu cursor size", - Current = config.GetBindable(OsuSetting.MenuCursorSize), - KeyboardStep = 0.01f - }, - new SettingsSlider { LabelText = "Gameplay cursor size", Current = config.GetBindable(OsuSetting.GameplayCursorSize), @@ -136,11 +130,6 @@ namespace osu.Game.Overlays.Settings.Sections Schedule(() => skinDropdown.Items = skinDropdown.Items.Where(i => i.ID != item.ID).ToArray()); } - private class SizeSlider : OsuSliderBar - { - public override string TooltipText => Current.Value.ToString(@"0.##x"); - } - private class SkinSettingsDropdown : SettingsDropdown { protected override OsuDropdown CreateDropdown() => new SkinDropdownControl(); diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/UserInterfaceSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs similarity index 75% rename from osu.Game/Overlays/Settings/Sections/Graphics/UserInterfaceSettings.cs rename to osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs index 38c30ddd64..19adfc5dd9 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/UserInterfaceSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs @@ -6,11 +6,11 @@ using osu.Framework.Graphics; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; -namespace osu.Game.Overlays.Settings.Sections.Graphics +namespace osu.Game.Overlays.Settings.Sections.UserInterface { - public class UserInterfaceSettings : SettingsSubsection + public class GeneralSettings : SettingsSubsection { - protected override string Header => "User Interface"; + protected override string Header => "General"; [BackgroundDependencyLoader] private void load(OsuConfigManager config) @@ -22,6 +22,12 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics LabelText = "Rotate cursor when dragging", Current = config.GetBindable(OsuSetting.CursorRotation) }, + new SettingsSlider + { + LabelText = "Menu cursor size", + Current = config.GetBindable(OsuSetting.MenuCursorSize), + KeyboardStep = 0.01f + }, new SettingsCheckbox { LabelText = "Parallax", diff --git a/osu.Game/Overlays/Settings/Sections/Audio/MainMenuSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs similarity index 97% rename from osu.Game/Overlays/Settings/Sections/Audio/MainMenuSettings.cs rename to osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs index 7682967d10..598b666642 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/MainMenuSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs @@ -7,7 +7,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Configuration; -namespace osu.Game.Overlays.Settings.Sections.Audio +namespace osu.Game.Overlays.Settings.Sections.UserInterface { public class MainMenuSettings : SettingsSubsection { diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/SongSelectSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs similarity index 97% rename from osu.Game/Overlays/Settings/Sections/Gameplay/SongSelectSettings.cs rename to osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs index b26876556e..c73a783d37 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/SongSelectSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs @@ -8,7 +8,7 @@ using osu.Framework.Graphics; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; -namespace osu.Game.Overlays.Settings.Sections.Gameplay +namespace osu.Game.Overlays.Settings.Sections.UserInterface { public class SongSelectSettings : SettingsSubsection { diff --git a/osu.Game/Overlays/Settings/Sections/UserInterfaceSection.cs b/osu.Game/Overlays/Settings/Sections/UserInterfaceSection.cs new file mode 100644 index 0000000000..718fea5f2b --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/UserInterfaceSection.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Overlays.Settings.Sections.UserInterface; + +namespace osu.Game.Overlays.Settings.Sections +{ + public class UserInterfaceSection : SettingsSection + { + public override string Header => "User Interface"; + + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Solid.LayerGroup + }; + + public UserInterfaceSection() + { + Children = new Drawable[] + { + new GeneralSettings(), + new MainMenuSettings(), + new SongSelectSettings() + }; + } + } +} diff --git a/osu.Game/Overlays/SettingsOverlay.cs b/osu.Game/Overlays/SettingsOverlay.cs index e1bcdbbaf0..31d188b545 100644 --- a/osu.Game/Overlays/SettingsOverlay.cs +++ b/osu.Game/Overlays/SettingsOverlay.cs @@ -23,10 +23,11 @@ namespace osu.Game.Overlays { new GeneralSection(), new GraphicsSection(), - new GameplaySection(), new AudioSection(), - new SkinSection(), new InputSection(createSubPanel(new KeyBindingPanel())), + new UserInterfaceSection(), + new GameplaySection(), + new SkinSection(), new OnlineSection(), new MaintenanceSection(), new DebugSection(), diff --git a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs index 889e748a4a..da9bb8a09d 100644 --- a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs +++ b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs @@ -32,9 +32,6 @@ namespace osu.Game.Rulesets.Judgements private readonly Container aboveHitObjectsContent; - [Resolved] - private ISkinSource skinSource { get; set; } - /// /// Duration of initial fade in. /// @@ -78,29 +75,6 @@ namespace osu.Game.Rulesets.Judgements public Drawable GetProxyAboveHitObjectsContent() => aboveHitObjectsContent.CreateProxy(); - protected override void LoadComplete() - { - base.LoadComplete(); - skinSource.SourceChanged += onSkinChanged; - } - - private void onSkinChanged() - { - // on a skin change, the child component will update but not get correctly triggered to play its animation. - // we need to trigger a reinitialisation to make things right. - currentDrawableType = null; - - PrepareForUse(); - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (skinSource != null) - skinSource.SourceChanged -= onSkinChanged; - } - /// /// Apply top-level animations to the current judgement when successfully hit. /// If displaying components which require lifetime extensions, manually adjusting is required. @@ -142,13 +116,14 @@ namespace osu.Game.Rulesets.Judgements Debug.Assert(Result != null); - prepareDrawables(); - runAnimation(); } private void runAnimation() { + // is a no-op if the drawables are already in a correct state. + prepareDrawables(); + // undo any transforms applies in ApplyMissAnimations/ApplyHitAnimations to get a sane initial state. ApplyTransformsAt(double.MinValue, true); ClearTransforms(true); @@ -203,7 +178,6 @@ namespace osu.Game.Rulesets.Judgements if (JudgementBody != null) RemoveInternal(JudgementBody); - aboveHitObjectsContent.Clear(); AddInternal(JudgementBody = new SkinnableDrawable(new GameplaySkinComponent(type), _ => CreateDefaultJudgement(type), confineMode: ConfineMode.NoScaling) { @@ -211,14 +185,29 @@ namespace osu.Game.Rulesets.Judgements Origin = Anchor.Centre, }); - if (JudgementBody.Drawable is IAnimatableJudgement animatable) + JudgementBody.OnSkinChanged += () => { - var proxiedContent = animatable.GetAboveHitObjectsProxiedContent(); - if (proxiedContent != null) - aboveHitObjectsContent.Add(proxiedContent); - } + // on a skin change, the child component will update but not get correctly triggered to play its animation (or proxy the newly created content). + // we need to trigger a reinitialisation to make things right. + proxyContent(); + runAnimation(); + }; + + proxyContent(); currentDrawableType = type; + + void proxyContent() + { + aboveHitObjectsContent.Clear(); + + if (JudgementBody.Drawable is IAnimatableJudgement animatable) + { + var proxiedContent = animatable.GetAboveHitObjectsProxiedContent(); + if (proxiedContent != null) + aboveHitObjectsContent.Add(proxiedContent); + } + } } protected virtual Drawable CreateDefaultJudgement(HitResult result) => new DefaultJudgementPiece(result); diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index 537da24e01..a27a811351 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -128,6 +128,12 @@ namespace osu.Game.Rulesets.Objects.Drawables private readonly Bindable state = new Bindable(); + /// + /// The state of this . + /// + /// + /// For pooled hitobjects, is recommended to be used instead for better editor/rewinding support. + /// public IBindable State => state; /// @@ -184,7 +190,7 @@ namespace osu.Game.Rulesets.Objects.Drawables { base.LoadComplete(); - comboIndexBindable.BindValueChanged(_ => updateComboColour(), true); + comboIndexBindable.BindValueChanged(_ => UpdateComboColour(), true); updateState(ArmedState.Idle, true); } @@ -254,12 +260,19 @@ namespace osu.Game.Rulesets.Objects.Drawables HitObject.DefaultsApplied += onDefaultsApplied; - OnApply(hitObject); + OnApply(); HitObjectApplied?.Invoke(this); - // If not loaded, the state update happens in LoadComplete(). Otherwise, the update is scheduled to allow for lifetime updates. + // If not loaded, the state update happens in LoadComplete(). if (IsLoaded) - Schedule(() => updateState(ArmedState.Idle, true)); + { + if (Result.IsHit) + updateState(ArmedState.Hit, true); + else if (Result.HasResult) + updateState(ArmedState.Miss, true); + else + updateState(ArmedState.Idle, true); + } hasHitObjectApplied = true; } @@ -299,7 +312,7 @@ namespace osu.Game.Rulesets.Objects.Drawables HitObject.DefaultsApplied -= onDefaultsApplied; - OnFree(HitObject); + OnFree(); HitObject = null; Result = null; @@ -324,16 +337,14 @@ namespace osu.Game.Rulesets.Objects.Drawables /// /// Invoked for this to take on any values from a newly-applied . /// - /// The being applied. - protected virtual void OnApply(HitObject hitObject) + protected virtual void OnApply() { } /// /// Invoked for this to revert any values previously taken on from the currently-applied . /// - /// The currently-applied . - protected virtual void OnFree(HitObject hitObject) + protected virtual void OnFree() { } @@ -519,7 +530,7 @@ namespace osu.Game.Rulesets.Objects.Drawables { base.SkinChanged(skin, allowFallback); - updateComboColour(); + UpdateComboColour(); ApplySkin(skin, allowFallback); @@ -527,13 +538,12 @@ namespace osu.Game.Rulesets.Objects.Drawables updateState(State.Value, true); } - private void updateComboColour() + protected void UpdateComboColour() { - if (!(HitObject is IHasComboInformation)) return; + if (!(HitObject is IHasComboInformation combo)) return; - var comboColours = CurrentSkin.GetConfig>(GlobalSkinColours.ComboColours)?.Value; - - AccentColour.Value = GetComboColour(comboColours); + var comboColours = CurrentSkin.GetConfig>(GlobalSkinColours.ComboColours)?.Value ?? Array.Empty(); + AccentColour.Value = combo.GetComboColour(comboColours); } /// @@ -544,6 +554,7 @@ namespace osu.Game.Rulesets.Objects.Drawables /// This will only be called if the implements . /// /// A list of combo colours provided by the beatmap or skin. Can be null if not available. + [Obsolete("Unused. Implement IHasComboInformation and IHasComboInformation.GetComboColour() on the HitObject model instead.")] // Can be removed 20210527 protected virtual Color4 GetComboColour(IReadOnlyList comboColours) { if (!(HitObject is IHasComboInformation combo)) diff --git a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs index 211c077d4f..4f66802079 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs @@ -1,7 +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.Collections.Generic; +using JetBrains.Annotations; using osu.Framework.Bindables; +using osuTK.Graphics; namespace osu.Game.Rulesets.Objects.Types { @@ -35,5 +38,13 @@ namespace osu.Game.Rulesets.Objects.Types /// Whether this is the last object in the current combo. /// bool LastInCombo { get; set; } + + /// + /// Retrieves the colour of the combo described by this object from a set of possible combo colours. + /// Defaults to using to decide the colour. + /// + /// A list of possible combo colours provided by the beatmap or skin. + /// The colour of the combo described by this object. + Color4 GetComboColour([NotNull] IReadOnlyList comboColours) => comboColours.Count > 0 ? comboColours[ComboIndex % comboColours.Count] : Color4.White; } } diff --git a/osu.Game/Rulesets/UI/HitObjectContainer.cs b/osu.Game/Rulesets/UI/HitObjectContainer.cs index 5fbda305c8..ac5d281ddc 100644 --- a/osu.Game/Rulesets/UI/HitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/HitObjectContainer.cs @@ -114,6 +114,7 @@ namespace osu.Game.Rulesets.UI bindStartTime(drawable); AddInternal(drawableMap[entry] = drawable, false); + OnAdd(drawable); HitObjectUsageBegan?.Invoke(entry.HitObject); } @@ -129,6 +130,7 @@ namespace osu.Game.Rulesets.UI drawableMap.Remove(entry); + OnRemove(drawable); unbindStartTime(drawable); RemoveInternal(drawable); @@ -147,10 +149,12 @@ namespace osu.Game.Rulesets.UI hitObject.OnRevertResult += onRevertResult; AddInternal(hitObject); + OnAdd(hitObject); } public virtual bool Remove(DrawableHitObject hitObject) { + OnRemove(hitObject); if (!RemoveInternal(hitObject)) return false; @@ -178,6 +182,26 @@ namespace osu.Game.Rulesets.UI #endregion + /// + /// Invoked when a is added to this container. + /// + /// + /// This method is not invoked for nested s. + /// + protected virtual void OnAdd(DrawableHitObject drawableHitObject) + { + } + + /// + /// Invoked when a is removed from this container. + /// + /// + /// This method is not invoked for nested s. + /// + protected virtual void OnRemove(DrawableHitObject drawableHitObject) + { + } + public virtual void Clear(bool disposeChildren = true) { lifetimeManager.ClearEntries(); diff --git a/osu.Game/Rulesets/UI/Playfield.cs b/osu.Game/Rulesets/UI/Playfield.cs index 2f589f4ce9..a2ac234471 100644 --- a/osu.Game/Rulesets/UI/Playfield.cs +++ b/osu.Game/Rulesets/UI/Playfield.cs @@ -135,10 +135,8 @@ namespace osu.Game.Rulesets.UI /// The DrawableHitObject to add. public virtual void Add(DrawableHitObject h) { - if (h.IsInitialized) - throw new InvalidOperationException($"{nameof(Add)} doesn't support {nameof(DrawableHitObject)} reuse. Use pooling instead."); - - onNewDrawableHitObject(h); + if (!h.IsInitialized) + onNewDrawableHitObject(h); HitObjectContainer.Add(h); OnHitObjectAdded(h.HitObject); diff --git a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs index bf64175468..3a5e3c098f 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs @@ -2,13 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Caching; using osu.Framework.Graphics; using osu.Framework.Layout; -using osu.Framework.Threading; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; using osuTK; @@ -19,7 +16,16 @@ namespace osu.Game.Rulesets.UI.Scrolling { private readonly IBindable timeRange = new BindableDouble(); private readonly IBindable direction = new Bindable(); - private readonly Dictionary hitObjectInitialStateCache = new Dictionary(); + + /// + /// Hit objects which require lifetime computation in the next update call. + /// + private readonly HashSet toComputeLifetime = new HashSet(); + + /// + /// A set containing all which have an up-to-date layout. + /// + private readonly HashSet layoutComputed = new HashSet(); [Resolved] private IScrollingInfo scrollingInfo { get; set; } @@ -27,10 +33,6 @@ namespace osu.Game.Rulesets.UI.Scrolling // Responds to changes in the layout. When the layout changes, all hit object states must be recomputed. private readonly LayoutValue layoutCache = new LayoutValue(Invalidation.RequiredParentSizeToFit | Invalidation.DrawInfo); - // A combined cache across all hit object states to reduce per-update iterations. - // When invalidated, one or more (but not necessarily all) hitobject states must be re-validated. - private readonly Cached combinedObjCache = new Cached(); - public ScrollingHitObjectContainer() { RelativeSizeAxes = Axes.Both; @@ -48,37 +50,12 @@ namespace osu.Game.Rulesets.UI.Scrolling timeRange.ValueChanged += _ => layoutCache.Invalidate(); } - public override void Add(DrawableHitObject hitObject) - { - combinedObjCache.Invalidate(); - hitObject.DefaultsApplied += onDefaultsApplied; - base.Add(hitObject); - } - - public override bool Remove(DrawableHitObject hitObject) - { - var result = base.Remove(hitObject); - - if (result) - { - combinedObjCache.Invalidate(); - hitObjectInitialStateCache.Remove(hitObject); - - hitObject.DefaultsApplied -= onDefaultsApplied; - } - - return result; - } - public override void Clear(bool disposeChildren = true) { - foreach (var h in Objects) - h.DefaultsApplied -= onDefaultsApplied; - base.Clear(disposeChildren); - combinedObjCache.Invalidate(); - hitObjectInitialStateCache.Clear(); + toComputeLifetime.Clear(); + layoutComputed.Clear(); } /// @@ -173,15 +150,40 @@ namespace osu.Game.Rulesets.UI.Scrolling } } - private void onDefaultsApplied(DrawableHitObject drawableObject) + protected override void OnAdd(DrawableHitObject drawableHitObject) => onAddRecursive(drawableHitObject); + + protected override void OnRemove(DrawableHitObject drawableHitObject) => onRemoveRecursive(drawableHitObject); + + private void onAddRecursive(DrawableHitObject hitObject) { - // The cache may not exist if the hitobject state hasn't been computed yet (e.g. if the hitobject was added + defaults applied in the same frame). - // In such a case, combinedObjCache will take care of updating the hitobject. - if (hitObjectInitialStateCache.TryGetValue(drawableObject, out var state)) - { - combinedObjCache.Invalidate(); - state.Cache.Invalidate(); - } + invalidateHitObject(hitObject); + + hitObject.DefaultsApplied += invalidateHitObject; + + foreach (var nested in hitObject.NestedHitObjects) + onAddRecursive(nested); + } + + private void onRemoveRecursive(DrawableHitObject hitObject) + { + toComputeLifetime.Remove(hitObject); + layoutComputed.Remove(hitObject); + + hitObject.DefaultsApplied -= invalidateHitObject; + + foreach (var nested in hitObject.NestedHitObjects) + onRemoveRecursive(nested); + } + + /// + /// Make this lifetime and layout computed in next update. + /// + private void invalidateHitObject(DrawableHitObject hitObject) + { + // Lifetime computation is delayed until next update because + // when the hit object is not pooled this container is not loaded here and `scrollLength` cannot be computed. + toComputeLifetime.Add(hitObject); + layoutComputed.Remove(hitObject); } private float scrollLength; @@ -192,17 +194,18 @@ namespace osu.Game.Rulesets.UI.Scrolling if (!layoutCache.IsValid) { - foreach (var state in hitObjectInitialStateCache.Values) - state.Cache.Invalidate(); - combinedObjCache.Invalidate(); + toComputeLifetime.Clear(); + + foreach (var hitObject in Objects) + { + if (hitObject.HitObject != null) + toComputeLifetime.Add(hitObject); + } + + layoutComputed.Clear(); scrollingInfo.Algorithm.Reset(); - layoutCache.Validate(); - } - - if (!combinedObjCache.IsValid) - { switch (direction.Value) { case ScrollingDirection.Up: @@ -215,32 +218,24 @@ namespace osu.Game.Rulesets.UI.Scrolling break; } - foreach (var obj in Objects) - { - if (!hitObjectInitialStateCache.TryGetValue(obj, out var state)) - state = hitObjectInitialStateCache[obj] = new InitialState(new Cached()); - - if (state.Cache.IsValid) - continue; - - state.ScheduledComputation?.Cancel(); - state.ScheduledComputation = computeInitialStateRecursive(obj); - - computeLifetimeStartRecursive(obj); - - state.Cache.Validate(); - } - - combinedObjCache.Validate(); + layoutCache.Validate(); } - } - private void computeLifetimeStartRecursive(DrawableHitObject hitObject) - { - hitObject.LifetimeStart = computeOriginAdjustedLifetimeStart(hitObject); + foreach (var hitObject in toComputeLifetime) + hitObject.LifetimeStart = computeOriginAdjustedLifetimeStart(hitObject); - foreach (var obj in hitObject.NestedHitObjects) - computeLifetimeStartRecursive(obj); + toComputeLifetime.Clear(); + + // only AliveObjects need to be considered for layout (reduces overhead in the case of scroll speed changes). + foreach (var obj in AliveObjects) + { + if (layoutComputed.Contains(obj)) + continue; + + updateLayoutRecursive(obj); + + layoutComputed.Add(obj); + } } private double computeOriginAdjustedLifetimeStart(DrawableHitObject hitObject) @@ -271,7 +266,7 @@ namespace osu.Game.Rulesets.UI.Scrolling return scrollingInfo.Algorithm.GetDisplayStartTime(hitObject.HitObject.StartTime, originAdjustment, timeRange.Value, scrollLength); } - private ScheduledDelegate computeInitialStateRecursive(DrawableHitObject hitObject) => hitObject.Schedule(() => + private void updateLayoutRecursive(DrawableHitObject hitObject) { if (hitObject.HitObject is IHasDuration e) { @@ -291,12 +286,12 @@ namespace osu.Game.Rulesets.UI.Scrolling foreach (var obj in hitObject.NestedHitObjects) { - computeInitialStateRecursive(obj); + updateLayoutRecursive(obj); // Nested hitobjects don't need to scroll, but they do need accurate positions updatePosition(obj, hitObject.HitObject.StartTime); } - }); + } protected override void UpdateAfterChildrenLife() { @@ -328,19 +323,5 @@ namespace osu.Game.Rulesets.UI.Scrolling break; } } - - private class InitialState - { - [NotNull] - public readonly Cached Cache; - - [CanBeNull] - public ScheduledDelegate ScheduledComputation; - - public InitialState(Cached cache) - { - Cache = cache; - } - } } } diff --git a/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs index 9dac3f4de1..2b75f93f9e 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingPlayfield.cs @@ -15,6 +15,8 @@ namespace osu.Game.Rulesets.UI.Scrolling { protected readonly IBindable Direction = new Bindable(); + public new ScrollingHitObjectContainer HitObjectContainer => (ScrollingHitObjectContainer)base.HitObjectContainer; + [Resolved] protected IScrollingInfo ScrollingInfo { get; private set; } @@ -27,14 +29,12 @@ namespace osu.Game.Rulesets.UI.Scrolling /// /// Given a position in screen space, return the time within this column. /// - public virtual double TimeAtScreenSpacePosition(Vector2 screenSpacePosition) => - ((ScrollingHitObjectContainer)HitObjectContainer).TimeAtScreenSpacePosition(screenSpacePosition); + public virtual double TimeAtScreenSpacePosition(Vector2 screenSpacePosition) => HitObjectContainer.TimeAtScreenSpacePosition(screenSpacePosition); /// /// Given a time, return the screen space position within this column. /// - public virtual Vector2 ScreenSpacePositionAtTime(double time) - => ((ScrollingHitObjectContainer)HitObjectContainer).ScreenSpacePositionAtTime(time); + public virtual Vector2 ScreenSpacePositionAtTime(double time) => HitObjectContainer.ScreenSpacePositionAtTime(time); protected sealed override HitObjectContainer CreateHitObjectContainer() => new ScrollingHitObjectContainer(); } diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index 5c5f203667..0b45bd5597 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -496,10 +496,7 @@ namespace osu.Game.Screens.Edit.Compose.Components double offset = result.Time.Value - movementBlueprints.First().HitObject.StartTime; foreach (HitObject obj in Beatmap.SelectedHitObjects) - { obj.StartTime += offset; - Beatmap.Update(obj); - } } return true; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs index 975433d407..657c5834b2 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -19,8 +18,8 @@ using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Skinning; using osuTK; using osuTK.Graphics; @@ -28,32 +27,26 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { public class TimelineHitObjectBlueprint : SelectionBlueprint { - private readonly Circle circle; + private const float thickness = 5; + private const float shadow_radius = 5; + private const float circle_size = 24; + + public Action OnDragHandled; [UsedImplicitly] private readonly Bindable startTime; - public Action OnDragHandled; + private Bindable indexInCurrentComboBindable; + private Bindable comboIndexBindable; + private readonly Circle circle; private readonly DragBar dragBar; - private readonly List shadowComponents = new List(); - - private DrawableHitObject drawableHitObject; - - private Bindable comboColour; - private readonly Container mainComponents; - private readonly OsuSpriteText comboIndexText; - private Bindable comboIndex; - - private const float thickness = 5; - - private const float shadow_radius = 5; - - private const float circle_size = 24; + [Resolved] + private ISkinSource skin { get; set; } public TimelineHitObjectBlueprint(HitObject hitObject) : base(hitObject) @@ -152,46 +145,42 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline updateShadows(); } - [BackgroundDependencyLoader(true)] - private void load(HitObjectComposer composer) - { - if (composer != null) - { - // best effort to get the drawable representation for grabbing colour and what not. - drawableHitObject = composer.HitObjects.FirstOrDefault(d => d.HitObject == HitObject); - } - } - protected override void LoadComplete() { base.LoadComplete(); if (HitObject is IHasComboInformation comboInfo) { - comboIndex = comboInfo.IndexInCurrentComboBindable.GetBoundCopy(); - comboIndex.BindValueChanged(combo => - { - comboIndexText.Text = (combo.NewValue + 1).ToString(); - }, true); + indexInCurrentComboBindable = comboInfo.IndexInCurrentComboBindable.GetBoundCopy(); + indexInCurrentComboBindable.BindValueChanged(_ => updateComboIndex(), true); + + comboIndexBindable = comboInfo.ComboIndexBindable.GetBoundCopy(); + comboIndexBindable.BindValueChanged(_ => updateComboColour(), true); + + skin.SourceChanged += updateComboColour; } + } - if (drawableHitObject != null) - { - comboColour = drawableHitObject.AccentColour.GetBoundCopy(); - comboColour.BindValueChanged(colour => - { - if (HitObject is IHasDuration) - mainComponents.Colour = ColourInfo.GradientHorizontal(drawableHitObject.AccentColour.Value, Color4.White); - else - mainComponents.Colour = drawableHitObject.AccentColour.Value; + private void updateComboIndex() => comboIndexText.Text = (indexInCurrentComboBindable.Value + 1).ToString(); - var col = mainComponents.Colour.TopLeft.Linear; - float brightness = col.R + col.G + col.B; + private void updateComboColour() + { + if (!(HitObject is IHasComboInformation combo)) + return; - // decide the combo index colour based on brightness? - comboIndexText.Colour = brightness > 0.5f ? Color4.Black : Color4.White; - }, true); - } + var comboColours = skin.GetConfig>(GlobalSkinColours.ComboColours)?.Value ?? Array.Empty(); + var comboColour = combo.GetComboColour(comboColours); + + if (HitObject is IHasDuration) + mainComponents.Colour = ColourInfo.GradientHorizontal(comboColour, Color4.White); + else + mainComponents.Colour = comboColour; + + var col = mainComponents.Colour.TopLeft.Linear; + float brightness = col.R + col.G + col.B; + + // decide the combo index colour based on brightness? + comboIndexText.Colour = brightness > 0.5f ? Color4.Black : Color4.White; } protected override void Update() diff --git a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs index 46d5eb40b4..c297a03dbf 100644 --- a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs +++ b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -43,6 +44,21 @@ namespace osu.Game.Screens.Edit.Compose if (ruleset == null || composer == null) return new ScreenWhiteBox.UnderConstructionMessage(ruleset == null ? "This beatmap" : $"{ruleset.Description}'s composer"); + return wrapSkinnableContent(composer); + } + + protected override Drawable CreateTimelineContent() + { + if (ruleset == null || composer == null) + return base.CreateTimelineContent(); + + return wrapSkinnableContent(new TimelineBlueprintContainer(composer)); + } + + private Drawable wrapSkinnableContent(Drawable content) + { + Debug.Assert(ruleset != null); + var beatmapSkinProvider = new BeatmapSkinProvidingContainer(Beatmap.Value.Skin); // the beatmapSkinProvider is used as the fallback source here to allow the ruleset-specific skin implementation @@ -51,9 +67,7 @@ namespace osu.Game.Screens.Edit.Compose // load the skinning hierarchy first. // this is intentionally done in two stages to ensure things are in a loaded state before exposing the ruleset to skin sources. - return beatmapSkinProvider.WithChild(rulesetSkinProvider.WithChild(composer)); + return beatmapSkinProvider.WithChild(rulesetSkinProvider.WithChild(content)); } - - protected override Drawable CreateTimelineContent() => composer == null ? base.CreateTimelineContent() : new TimelineBlueprintContainer(composer); } } diff --git a/osu.Game/Screens/Edit/EditorChangeHandler.cs b/osu.Game/Screens/Edit/EditorChangeHandler.cs index 62187aed24..2dcb416a03 100644 --- a/osu.Game/Screens/Edit/EditorChangeHandler.cs +++ b/osu.Game/Screens/Edit/EditorChangeHandler.cs @@ -76,7 +76,7 @@ namespace osu.Game.Screens.Edit var newState = stream.ToArray(); // if the previous state is binary equal we don't need to push a new one, unless this is the initial state. - if (savedStates.Count > 0 && newState.SequenceEqual(savedStates.Last())) return; + if (savedStates.Count > 0 && newState.SequenceEqual(savedStates[currentState])) return; if (currentState < savedStates.Count - 1) savedStates.RemoveRange(currentState + 1, savedStates.Count - currentState - 1); diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index d0a83e3c22..7979b635aa 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -339,7 +339,11 @@ namespace osu.Game.Screens.Play AlwaysVisible = { BindTarget = DrawableRuleset.HasReplayLoaded }, IsCounting = false }, - RequestSeek = GameplayClockContainer.Seek, + RequestSeek = time => + { + GameplayClockContainer.Seek(time); + GameplayClockContainer.Start(); + }, Anchor = Anchor.Centre, Origin = Anchor.Centre }, diff --git a/osu.Game/Screens/Play/ReplayPlayer.cs b/osu.Game/Screens/Play/ReplayPlayer.cs index 3a4298f22d..294d116f51 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.cs @@ -1,12 +1,14 @@ // 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.Input.Bindings; +using osu.Game.Input.Bindings; using osu.Game.Scoring; using osu.Game.Screens.Ranking; namespace osu.Game.Screens.Play { - public class ReplayPlayer : Player + public class ReplayPlayer : Player, IKeyBindingHandler { protected readonly Score Score; @@ -35,5 +37,24 @@ namespace osu.Game.Screens.Play return Score.ScoreInfo; } + + public bool OnPressed(GlobalAction action) + { + switch (action) + { + case GlobalAction.TogglePauseReplay: + if (GameplayClockContainer.IsPaused.Value) + GameplayClockContainer.Start(); + else + GameplayClockContainer.Stop(); + return true; + } + + return false; + } + + public void OnReleased(GlobalAction action) + { + } } } diff --git a/osu.Game/Screens/Play/SkipOverlay.cs b/osu.Game/Screens/Play/SkipOverlay.cs index b123757ded..92b304de91 100644 --- a/osu.Game/Screens/Play/SkipOverlay.cs +++ b/osu.Game/Screens/Play/SkipOverlay.cs @@ -133,6 +133,9 @@ namespace osu.Game.Screens.Play switch (action) { case GlobalAction.SkipCutscene: + if (!button.Enabled.Value) + return false; + button.Click(); return true; } diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index 83631fd383..4ce87927a1 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -91,7 +91,7 @@ namespace osu.Game.Screens.Select /// public bool BeatmapSetsLoaded { get; private set; } - private readonly CarouselScrollContainer scroll; + protected readonly CarouselScrollContainer Scroll; private IEnumerable beatmapSets => root.Children.OfType(); @@ -112,9 +112,9 @@ namespace osu.Game.Screens.Select if (selectedBeatmapSet != null && !beatmapSets.Contains(selectedBeatmapSet.BeatmapSet)) selectedBeatmapSet = null; - ScrollableContent.Clear(false); + Scroll.Clear(false); itemsCache.Invalidate(); - scrollPositionCache.Invalidate(); + ScrollToSelected(); // apply any pending filter operation that may have been delayed (see applyActiveCriteria's scheduling behaviour when BeatmapSetsLoaded is false). FlushPendingFilterOperations(); @@ -130,9 +130,7 @@ namespace osu.Game.Screens.Select private readonly List visibleItems = new List(); private readonly Cached itemsCache = new Cached(); - private readonly Cached scrollPositionCache = new Cached(); - - protected readonly Container ScrollableContent; + private PendingScrollOperation pendingScrollOperation = PendingScrollOperation.None; public Bindable RightClickScrollingEnabled = new Bindable(); @@ -155,17 +153,12 @@ namespace osu.Game.Screens.Select InternalChild = new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, - Child = scroll = new CarouselScrollContainer + Children = new Drawable[] { - Masking = false, - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] + setPool, + Scroll = new CarouselScrollContainer { - setPool, - ScrollableContent = new Container - { - RelativeSizeAxes = Axes.X, - } + RelativeSizeAxes = Axes.Both, } } }; @@ -180,7 +173,7 @@ namespace osu.Game.Screens.Select config.BindWith(OsuSetting.RandomSelectAlgorithm, RandomAlgorithm); config.BindWith(OsuSetting.SongSelectRightMouseScroll, RightClickScrollingEnabled); - RightClickScrollingEnabled.ValueChanged += enabled => scroll.RightMouseScrollbar = enabled.NewValue; + RightClickScrollingEnabled.ValueChanged += enabled => Scroll.RightMouseScrollbar = enabled.NewValue; RightClickScrollingEnabled.TriggerChange(); itemUpdated = beatmaps.ItemUpdated.GetBoundCopy(); @@ -421,12 +414,12 @@ namespace osu.Game.Screens.Select /// /// The position of the lower visible bound with respect to the current scroll position. /// - private float visibleBottomBound => scroll.Current + DrawHeight + BleedBottom; + private float visibleBottomBound => Scroll.Current + DrawHeight + BleedBottom; /// /// The position of the upper visible bound with respect to the current scroll position. /// - private float visibleUpperBound => scroll.Current - BleedTop; + private float visibleUpperBound => Scroll.Current - BleedTop; public void FlushPendingFilterOperations() { @@ -468,8 +461,8 @@ namespace osu.Game.Screens.Select root.Filter(activeCriteria); itemsCache.Invalidate(); - if (alwaysResetScrollPosition || !scroll.UserScrolling) - ScrollToSelected(); + if (alwaysResetScrollPosition || !Scroll.UserScrolling) + ScrollToSelected(true); } } @@ -478,7 +471,12 @@ namespace osu.Game.Screens.Select /// /// Scroll to the current . /// - public void ScrollToSelected() => scrollPositionCache.Invalidate(); + /// + /// Whether the scroll position should immediately be shifted to the target, delegating animation to visible panels. + /// This should be true for operations like filtering - where panels are changing visibility state - to avoid large jumps in animation. + /// + public void ScrollToSelected(bool immediate = false) => + pendingScrollOperation = immediate ? PendingScrollOperation.Immediate : PendingScrollOperation.Standard; #region Key / button selection logic @@ -488,12 +486,12 @@ namespace osu.Game.Screens.Select { case Key.Left: if (!e.Repeat) - beginRepeatSelection(() => SelectNext(-1, true), e.Key); + beginRepeatSelection(() => SelectNext(-1), e.Key); return true; case Key.Right: if (!e.Repeat) - beginRepeatSelection(() => SelectNext(1, true), e.Key); + beginRepeatSelection(() => SelectNext(), e.Key); return true; } @@ -580,6 +578,11 @@ namespace osu.Game.Screens.Select if (revalidateItems) updateYPositions(); + // if there is a pending scroll action we apply it without animation and transfer the difference in position to the panels. + // this is intentionally applied before updating the visible range below, to avoid animating new items (sourced from pool) from locations off-screen, as it looks bad. + if (pendingScrollOperation != PendingScrollOperation.None) + updateScrollPosition(); + // This data is consumed to find the currently displayable range. // This is the range we want to keep drawables for, and should exceed the visible range slightly to avoid drawable churn. var newDisplayRange = getDisplayRange(); @@ -594,7 +597,7 @@ namespace osu.Game.Screens.Select { var toDisplay = visibleItems.GetRange(displayedRange.first, displayedRange.last - displayedRange.first + 1); - foreach (var panel in ScrollableContent.Children) + foreach (var panel in Scroll.Children) { if (toDisplay.Remove(panel.Item)) { @@ -620,24 +623,14 @@ namespace osu.Game.Screens.Select panel.Depth = item.CarouselYPosition; panel.Y = item.CarouselYPosition; - ScrollableContent.Add(panel); + Scroll.Add(panel); } } } - // Finally, if the filtered items have changed, animate drawables to their new locations. - // This is common if a selected/collapsed state has changed. - if (revalidateItems) - { - foreach (DrawableCarouselItem panel in ScrollableContent.Children) - { - panel.MoveToY(panel.Item.CarouselYPosition, 800, Easing.OutQuint); - } - } - // Update externally controlled state of currently visible items (e.g. x-offset and opacity). // This is a per-frame update on all drawable panels. - foreach (DrawableCarouselItem item in ScrollableContent.Children) + foreach (DrawableCarouselItem item in Scroll.Children) { updateItem(item); @@ -670,14 +663,6 @@ namespace osu.Game.Screens.Select return (firstIndex, lastIndex); } - protected override void UpdateAfterChildren() - { - base.UpdateAfterChildren(); - - if (!scrollPositionCache.IsValid) - updateScrollPosition(); - } - private void beatmapRemoved(ValueChangedEvent> weakItem) { if (weakItem.NewValue.TryGetTarget(out var item)) @@ -789,7 +774,8 @@ namespace osu.Game.Screens.Select } currentY += visibleHalfHeight; - ScrollableContent.Height = currentY; + + Scroll.ScrollContent.Height = currentY; if (BeatmapSetsLoaded && (selectedBeatmapSet == null || selectedBeatmap == null || selectedBeatmapSet.State.Value != CarouselItemState.Selected)) { @@ -809,12 +795,31 @@ namespace osu.Game.Screens.Select if (firstScroll) { // reduce movement when first displaying the carousel. - scroll.ScrollTo(scrollTarget.Value - 200, false); + Scroll.ScrollTo(scrollTarget.Value - 200, false); firstScroll = false; } - scroll.ScrollTo(scrollTarget.Value); - scrollPositionCache.Validate(); + switch (pendingScrollOperation) + { + case PendingScrollOperation.Standard: + Scroll.ScrollTo(scrollTarget.Value); + break; + + case PendingScrollOperation.Immediate: + // in order to simplify animation logic, rather than using the animated version of ScrollTo, + // we take the difference in scroll height and apply to all visible panels. + // this avoids edge cases like when the visible panels is reduced suddenly, causing ScrollContainer + // to enter clamp-special-case mode where it animates completely differently to normal. + float scrollChange = scrollTarget.Value - Scroll.Current; + + Scroll.ScrollTo(scrollTarget.Value, false); + + foreach (var i in Scroll.Children) + i.Y += scrollChange; + break; + } + + pendingScrollOperation = PendingScrollOperation.None; } } @@ -844,7 +849,7 @@ namespace osu.Game.Screens.Select /// For nested items, the parent of the item to be updated. private void updateItem(DrawableCarouselItem item, DrawableCarouselItem parent = null) { - Vector2 posInScroll = ScrollableContent.ToLocalSpace(item.Header.ScreenSpaceDrawQuad.Centre); + Vector2 posInScroll = Scroll.ScrollContent.ToLocalSpace(item.Header.ScreenSpaceDrawQuad.Centre); float itemDrawY = posInScroll.Y - visibleUpperBound; float dist = Math.Abs(1f - itemDrawY / visibleHalfHeight); @@ -858,6 +863,13 @@ namespace osu.Game.Screens.Select item.SetMultiplicativeAlpha(Math.Clamp(1.75f - 1.5f * dist, 0, 1)); } + private enum PendingScrollOperation + { + None, + Standard, + Immediate, + } + /// /// A carousel item strictly used for binary search purposes. /// @@ -889,7 +901,7 @@ namespace osu.Game.Screens.Select } } - private class CarouselScrollContainer : OsuScrollContainer + protected class CarouselScrollContainer : OsuScrollContainer { private bool rightMouseScrollBlocked; @@ -898,6 +910,12 @@ namespace osu.Game.Screens.Select /// public bool UserScrolling { get; private set; } + public CarouselScrollContainer() + { + // size is determined by the carousel itself, due to not all content necessarily being loaded. + ScrollContent.AutoSizeAxes = Axes.None; + } + // ReSharper disable once OptionalParameterHierarchyMismatch 2020.3 EAP4 bug. (https://youtrack.jetbrains.com/issue/RSRP-481535?p=RIDER-51910) protected override void OnUserScroll(float value, bool animated = true, double? distanceDecay = default) { diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs index 93f95e76cc..e25c6932cf 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Collections; using osu.Game.Graphics.UserInterface; @@ -60,6 +61,25 @@ namespace osu.Game.Screens.Select.Carousel viewDetails = beatmapOverlay.FetchAndShowBeatmapSet; } + protected override void Update() + { + base.Update(); + + // position updates should not occur if the item is filtered away. + // this avoids panels flying across the screen only to be eventually off-screen or faded out. + if (!Item.Visible) + return; + + float targetY = Item.CarouselYPosition; + + if (Precision.AlmostEquals(targetY, Y)) + Y = targetY; + else + // algorithm for this is taken from ScrollContainer. + // while it doesn't necessarily need to match 1:1, as we are emulating scroll in some cases this feels most correct. + Y = (float)Interpolation.Lerp(targetY, Y, Math.Exp(-0.01 * Time.Elapsed)); + } + protected override void UpdateItem() { base.UpdateItem(); diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 54f3fcede6..e201383d51 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -26,7 +26,7 @@ - + diff --git a/osu.iOS.props b/osu.iOS.props index 692dac909a..e5f7581404 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -70,7 +70,7 @@ - + @@ -88,7 +88,7 @@ - +