diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs index 6eeda2c731..194a12a9b7 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs @@ -1,26 +1,194 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Game.Rulesets.Catch.UI; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Configuration; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Tests.Visual; namespace osu.Game.Rulesets.Catch.Tests { [TestFixture] - public class TestSceneCatcher : CatchSkinnableTestScene + public class TestSceneCatcher : OsuTestScene { - [BackgroundDependencyLoader] - private void load() + [Resolved] + private OsuConfigManager config { get; set; } + + private Container droppedObjectContainer; + + private TestCatcher catcher; + + [SetUp] + public void SetUp() => Schedule(() => { - SetContents(() => new Catcher(new Container()) + var difficulty = new BeatmapDifficulty + { + CircleSize = 0, + }; + + var trailContainer = new Container(); + droppedObjectContainer = new Container(); + catcher = new TestCatcher(trailContainer, droppedObjectContainer, difficulty); + + Child = new Container { - RelativePositionAxes = Axes.None, Anchor = Anchor.Centre, - Origin = Anchor.Centre, + Children = new Drawable[] + { + trailContainer, + droppedObjectContainer, + catcher + } + }; + }); + + [Test] + public void TestCatcherCatchWidth() + { + var halfWidth = Catcher.CalculateCatchWidth(new BeatmapDifficulty { CircleSize = 0 }) / 2; + AddStep("catch fruit", () => + { + attemptCatch(new Fruit { X = -halfWidth + 1 }); + attemptCatch(new Fruit { X = halfWidth - 1 }); }); + checkPlate(2); + AddStep("miss fruit", () => + { + attemptCatch(new Fruit { X = -halfWidth - 1 }); + attemptCatch(new Fruit { X = halfWidth + 1 }); + }); + checkPlate(2); + } + + [Test] + public void TestFruitChangesCatcherState() + { + AddStep("miss fruit", () => attemptCatch(new Fruit { X = 100 })); + checkState(CatcherAnimationState.Fail); + AddStep("catch fruit", () => attemptCatch(new Fruit())); + checkState(CatcherAnimationState.Idle); + AddStep("catch kiai fruit", () => attemptCatch(new TestKiaiFruit())); + checkState(CatcherAnimationState.Kiai); + } + + [Test] + public void TestNormalFruitResetsHyperDashState() + { + AddStep("catch hyper fruit", () => attemptCatch(new Fruit + { + HyperDashTarget = new Fruit { X = 100 } + })); + checkHyperDash(true); + AddStep("catch normal fruit", () => attemptCatch(new Fruit())); + checkHyperDash(false); + } + + [Test] + public void TestTinyDropletMissPreservesCatcherState() + { + AddStep("catch hyper kiai fruit", () => attemptCatch(new TestKiaiFruit + { + HyperDashTarget = new Fruit { X = 100 } + })); + AddStep("catch tiny droplet", () => attemptCatch(new TinyDroplet())); + AddStep("miss tiny droplet", () => attemptCatch(new TinyDroplet { X = 100 })); + // catcher state and hyper dash state is preserved + checkState(CatcherAnimationState.Kiai); + checkHyperDash(true); + } + + [Test] + public void TestBananaMissPreservesCatcherState() + { + AddStep("catch hyper kiai fruit", () => attemptCatch(new TestKiaiFruit + { + HyperDashTarget = new Fruit { X = 100 } + })); + AddStep("miss banana", () => attemptCatch(new Banana { X = 100 })); + // catcher state is preserved but hyper dash state is reset + checkState(CatcherAnimationState.Kiai); + checkHyperDash(false); + } + + [Test] + public void TestCatcherStacking() + { + AddStep("catch fruit", () => attemptCatch(new Fruit())); + checkPlate(1); + AddStep("catch more fruits", () => attemptCatch(new Fruit(), 9)); + checkPlate(10); + AddAssert("caught objects are stacked", () => + catcher.CaughtObjects.All(obj => obj.Y <= 0) && + catcher.CaughtObjects.Any(obj => obj.Y == 0) && + catcher.CaughtObjects.Any(obj => obj.Y < -20)); + } + + [Test] + public void TestCatcherExplosionAndDropping() + { + AddStep("catch fruit", () => attemptCatch(new Fruit())); + AddStep("catch tiny droplet", () => attemptCatch(new TinyDroplet())); + AddAssert("tiny droplet is exploded", () => catcher.CaughtObjects.Count() == 1 && droppedObjectContainer.Count == 1); + AddUntilStep("wait explosion", () => !droppedObjectContainer.Any()); + AddStep("catch more fruits", () => attemptCatch(new Fruit(), 9)); + AddStep("explode", () => catcher.Explode()); + AddAssert("fruits are exploded", () => !catcher.CaughtObjects.Any() && droppedObjectContainer.Count == 10); + AddUntilStep("wait explosion", () => !droppedObjectContainer.Any()); + AddStep("catch fruits", () => attemptCatch(new Fruit(), 10)); + AddStep("drop", () => catcher.Drop()); + AddAssert("fruits are dropped", () => !catcher.CaughtObjects.Any() && droppedObjectContainer.Count == 10); + } + + [TestCase(true)] + [TestCase(false)] + public void TestHitLighting(bool enabled) + { + AddStep($"{(enabled ? "enable" : "disable")} hit lighting", () => config.Set(OsuSetting.HitLighting, enabled)); + AddStep("catch fruit", () => attemptCatch(new Fruit())); + AddAssert("check hit lighting", () => catcher.ChildrenOfType().Any() == enabled); + } + + private void checkPlate(int count) => AddAssert($"{count} objects on the plate", () => catcher.CaughtObjects.Count() == count); + + private void checkState(CatcherAnimationState state) => AddAssert($"catcher state is {state}", () => catcher.CurrentState == state); + + private void checkHyperDash(bool state) => AddAssert($"catcher is {(state ? "" : "not ")}hyper dashing", () => catcher.HyperDashing == state); + + private void attemptCatch(CatchHitObject hitObject, int count = 1) + { + hitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + for (var i = 0; i < count; i++) + catcher.AttemptCatch(hitObject); + } + + public class TestCatcher : Catcher + { + public IEnumerable CaughtObjects => this.ChildrenOfType(); + + public TestCatcher(Container trailsTarget, Container droppedObjectTarget, BeatmapDifficulty difficulty) + : base(trailsTarget, droppedObjectTarget, difficulty) + { + } + } + + public class TestKiaiFruit : Fruit + { + protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, BeatmapDifficulty difficulty) + { + controlPointInfo.Add(0, new EffectControlPoint { KiaiMode = true }); + base.ApplyDefaultsToSelf(controlPointInfo, difficulty); + } } } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs index c12f38723b..281ddc7eaa 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs @@ -6,6 +6,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -27,81 +28,68 @@ namespace osu.Game.Rulesets.Catch.Tests [Resolved] private OsuConfigManager config { get; set; } - private Catcher catcher => this.ChildrenOfType().First().MovableCatcher; + private Catcher catcher => this.ChildrenOfType().First(); + + private float circleSize; public TestSceneCatcherArea() { - AddSliderStep("CircleSize", 0, 8, 5, createCatcher); - AddToggleStep("Hyperdash", t => - CreatedDrawables.OfType().Select(i => i.Child) - .OfType().ForEach(c => c.ToggleHyperDash(t))); + AddSliderStep("circle size", 0, 8, 5, createCatcher); + AddToggleStep("hyper dash", t => this.ChildrenOfType().ForEach(area => area.ToggleHyperDash(t))); - AddRepeatStep("catch fruit", () => catchFruit(new TestFruit(false) - { - X = catcher.X - }), 20); - AddRepeatStep("catch fruit last in combo", () => catchFruit(new TestFruit(false) - { - X = catcher.X, - LastInCombo = true, - }), 20); - AddRepeatStep("catch kiai fruit", () => catchFruit(new TestFruit(true) - { - X = catcher.X - }), 20); - AddRepeatStep("miss fruit", () => catchFruit(new Fruit - { - X = catcher.X + 100, - LastInCombo = true, - }, true), 20); + AddStep("catch fruit", () => attemptCatch(new Fruit())); + AddStep("catch fruit last in combo", () => attemptCatch(new Fruit { LastInCombo = true })); + AddStep("catch kiai fruit", () => attemptCatch(new TestSceneCatcher.TestKiaiFruit())); + AddStep("miss last in combo", () => attemptCatch(new Fruit { X = 100, LastInCombo = true })); } - [TestCase(true)] - [TestCase(false)] - public void TestHitLighting(bool enable) + private void attemptCatch(Fruit fruit) { - AddStep("create catcher", () => createCatcher(5)); - - AddStep("toggle hit lighting", () => config.Set(OsuSetting.HitLighting, enable)); - AddStep("catch fruit", () => catchFruit(new TestFruit(false) + fruit.X += catcher.X; + fruit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { - X = catcher.X - })); - AddStep("catch fruit last in combo", () => catchFruit(new TestFruit(false) - { - X = catcher.X, - LastInCombo = true - })); - AddAssert("check hit explosion", () => catcher.ChildrenOfType().Any() == enable); - } + CircleSize = circleSize + }); - private void catchFruit(Fruit fruit, bool miss = false) - { - this.ChildrenOfType().ForEach(area => + foreach (var area in this.ChildrenOfType()) { DrawableFruit drawable = new DrawableFruit(fruit); area.Add(drawable); Schedule(() => { - area.AttemptCatch(fruit); - area.OnNewResult(drawable, new JudgementResult(fruit, new CatchJudgement()) { Type = miss ? HitResult.Miss : HitResult.Great }); + bool caught = area.AttemptCatch(fruit); + area.OnNewResult(drawable, new JudgementResult(fruit, new CatchJudgement()) + { + Type = caught ? HitResult.Great : HitResult.Miss + }); drawable.Expire(); }); - }); + } } private void createCatcher(float size) { - SetContents(() => new CatchInputManager(catchRuleset) + circleSize = size; + + SetContents(() => { - RelativeSizeAxes = Axes.Both, - Child = new TestCatcherArea(new BeatmapDifficulty { CircleSize = size }) + var droppedObjectContainer = new Container(); + + return new CatchInputManager(catchRuleset) { - Anchor = Anchor.Centre, - Origin = Anchor.TopCentre, - }, + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + droppedObjectContainer, + new TestCatcherArea(droppedObjectContainer, new BeatmapDifficulty { CircleSize = size }) + { + Anchor = Anchor.Centre, + Origin = Anchor.TopCentre, + } + } + }; }); } @@ -111,26 +99,13 @@ namespace osu.Game.Rulesets.Catch.Tests catchRuleset = rulesets.GetRuleset(2); } - public class TestFruit : Fruit - { - public TestFruit(bool kiai) - { - var kiaiCpi = new ControlPointInfo(); - kiaiCpi.Add(0, new EffectControlPoint { KiaiMode = kiai }); - - ApplyDefaultsToSelf(kiaiCpi, new BeatmapDifficulty()); - } - } - private class TestCatcherArea : CatcherArea { - public TestCatcherArea(BeatmapDifficulty beatmapDifficulty) - : base(beatmapDifficulty) + public TestCatcherArea(Container droppedObjectContainer, BeatmapDifficulty beatmapDifficulty) + : base(droppedObjectContainer, beatmapDifficulty) { } - public new Catcher MovableCatcher => base.MovableCatcher; - public void ToggleHyperDash(bool status) => MovableCatcher.SetHyperDashState(status ? 2 : 1); } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs index 1b8368794c..07cb73e5ff 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs @@ -117,7 +117,7 @@ namespace osu.Game.Rulesets.Catch.Tests AddStep("create hyper-dashing catcher", () => { - Child = setupSkinHierarchy(catcherArea = new CatcherArea + Child = setupSkinHierarchy(catcherArea = new CatcherArea(new Container()) { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs index 86c1c7d0cd..6aa8ff439e 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawableCatchHitObject.cs @@ -17,8 +17,6 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables protected override double InitialLifetimeOffset => HitObject.TimePreempt; - public float DisplayRadius => DrawSize.X / 2 * Scale.X * HitObject.Scale; - protected override float SamplePlaybackPosition => HitObject.X / CatchPlayfield.WIDTH; public int RandomSeed => HitObject?.RandomSeed ?? 0; diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawablePalpableCatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawablePalpableCatchHitObject.cs index a3908f94b6..0877b5e248 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/DrawablePalpableCatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/DrawablePalpableCatchHitObject.cs @@ -30,6 +30,8 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables /// public virtual bool StaysOnPlate => true; + public float DisplayRadius => CatchHitObject.OBJECT_RADIUS * HitObject.Scale * ScaleFactor; + protected readonly Container ScaleContainer; protected DrawablePalpableCatchHitObject([CanBeNull] CatchHitObject h) diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs index 820f08d439..df87359ed6 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs @@ -36,21 +36,20 @@ namespace osu.Game.Rulesets.Catch.UI public CatchPlayfield(BeatmapDifficulty difficulty, Func> createDrawableRepresentation) { - var explodingFruitContainer = new Container + var droppedObjectContainer = new Container { RelativeSizeAxes = Axes.Both, }; - CatcherArea = new CatcherArea(difficulty) + CatcherArea = new CatcherArea(droppedObjectContainer, difficulty) { - ExplodingFruitTarget = explodingFruitContainer, Anchor = Anchor.BottomLeft, Origin = Anchor.TopLeft, }; InternalChildren = new[] { - explodingFruitContainer, + droppedObjectContainer, CatcherArea.MovableCatcher.CreateProxiedContent(), HitObjectContainer, CatcherArea, diff --git a/osu.Game.Rulesets.Catch/UI/Catcher.cs b/osu.Game.Rulesets.Catch/UI/Catcher.cs index 11b6916a4c..2a3447c80a 100644 --- a/osu.Game.Rulesets.Catch/UI/Catcher.cs +++ b/osu.Game.Rulesets.Catch/UI/Catcher.cs @@ -17,7 +17,6 @@ using osu.Game.Configuration; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.Skinning; -using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Skinning; using osuTK; using osuTK.Graphics; @@ -47,19 +46,15 @@ namespace osu.Game.Rulesets.Catch.UI /// public const double BASE_SPEED = 1.0; - public Container ExplodingFruitTarget; - - private Container caughtFruitContainer { get; } = new Container - { - Anchor = Anchor.TopCentre, - Origin = Anchor.BottomCentre, - }; - [NotNull] private readonly Container trailsTarget; private CatcherTrailDisplay trails; + private readonly Container droppedObjectTarget; + + private readonly Container caughtFruitContainer; + public CatcherAnimationState CurrentState { get; private set; } /// @@ -92,9 +87,9 @@ namespace osu.Game.Rulesets.Catch.UI /// private readonly float catchWidth; - private CatcherSprite catcherIdle; - private CatcherSprite catcherKiai; - private CatcherSprite catcherFail; + private readonly CatcherSprite catcherIdle; + private readonly CatcherSprite catcherKiai; + private readonly CatcherSprite catcherFail; private CatcherSprite currentCatcher; @@ -108,12 +103,13 @@ namespace osu.Game.Rulesets.Catch.UI private float hyperDashTargetPosition; private Bindable hitLighting; - private DrawablePool hitExplosionPool; - private Container hitExplosionContainer; + private readonly DrawablePool hitExplosionPool; + private readonly Container hitExplosionContainer; - public Catcher([NotNull] Container trailsTarget, BeatmapDifficulty difficulty = null) + public Catcher([NotNull] Container trailsTarget, [NotNull] Container droppedObjectTarget, BeatmapDifficulty difficulty = null) { this.trailsTarget = trailsTarget; + this.droppedObjectTarget = droppedObjectTarget; Origin = Anchor.TopCentre; @@ -122,17 +118,15 @@ namespace osu.Game.Rulesets.Catch.UI Scale = calculateScale(difficulty); catchWidth = CalculateCatchWidth(Scale); - } - - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) - { - hitLighting = config.GetBindable(OsuSetting.HitLighting); InternalChildren = new Drawable[] { hitExplosionPool = new DrawablePool(10), - caughtFruitContainer, + caughtFruitContainer = new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.BottomCentre, + }, catcherIdle = new CatcherSprite(CatcherAnimationState.Idle) { Anchor = Anchor.TopCentre, @@ -154,7 +148,12 @@ namespace osu.Game.Rulesets.Catch.UI Origin = Anchor.BottomCentre, }, }; + } + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + hitLighting = config.GetBindable(OsuSetting.HitLighting); trails = new CatcherTrailDisplay(this); updateCatcher(); @@ -176,56 +175,19 @@ namespace osu.Game.Rulesets.Catch.UI /// /// Calculates the scale of the catcher based off the provided beatmap difficulty. /// - private static Vector2 calculateScale(BeatmapDifficulty difficulty) - => new Vector2(1.0f - 0.7f * (difficulty.CircleSize - 5) / 5); + private static Vector2 calculateScale(BeatmapDifficulty difficulty) => new Vector2(1.0f - 0.7f * (difficulty.CircleSize - 5) / 5); /// /// Calculates the width of the area used for attempting catches in gameplay. /// /// The scale of the catcher. - internal static float CalculateCatchWidth(Vector2 scale) - => CatcherArea.CATCHER_SIZE * Math.Abs(scale.X) * ALLOWED_CATCH_RANGE; + internal static float CalculateCatchWidth(Vector2 scale) => CatcherArea.CATCHER_SIZE * Math.Abs(scale.X) * ALLOWED_CATCH_RANGE; /// /// Calculates the width of the area used for attempting catches in gameplay. /// /// The beatmap difficulty. - internal static float CalculateCatchWidth(BeatmapDifficulty difficulty) - => CalculateCatchWidth(calculateScale(difficulty)); - - /// - /// Add a caught fruit to the catcher's stack. - /// - /// The fruit that was caught. - public void PlaceOnPlate(DrawableCatchHitObject fruit) - { - var ourRadius = fruit.DisplayRadius; - float theirRadius = 0; - - const float allowance = 10; - - while (caughtFruitContainer.Any(f => - f.LifetimeEnd == double.MaxValue && - Vector2Extensions.Distance(f.Position, fruit.Position) < (ourRadius + (theirRadius = f.DrawSize.X / 2 * f.Scale.X)) / (allowance / 2))) - { - var diff = (ourRadius + theirRadius) / allowance; - fruit.X += (RNG.NextSingle() - 0.5f) * diff * 2; - fruit.Y -= RNG.NextSingle() * diff; - } - - fruit.X = Math.Clamp(fruit.X, -CatcherArea.CATCHER_SIZE / 2, CatcherArea.CATCHER_SIZE / 2); - - caughtFruitContainer.Add(fruit); - - if (hitLighting.Value) - { - HitExplosion hitExplosion = hitExplosionPool.Get(); - hitExplosion.X = fruit.X; - hitExplosion.Scale = new Vector2(fruit.HitObject.Scale); - hitExplosion.ObjectColour = fruit.AccentColour.Value; - hitExplosionContainer.Add(hitExplosion); - } - } + internal static float CalculateCatchWidth(BeatmapDifficulty difficulty) => CalculateCatchWidth(calculateScale(difficulty)); /// /// Let the catcher attempt to catch a fruit. @@ -247,7 +209,10 @@ namespace osu.Game.Rulesets.Catch.UI catchObjectPosition >= catcherPosition - halfCatchWidth && catchObjectPosition <= catcherPosition + halfCatchWidth; - // only update hyperdash state if we are not catching a tiny droplet. + if (validCatch) + placeCaughtObject(fruit); + + // droplet doesn't affect the catcher state if (fruit is TinyDroplet) return validCatch; if (validCatch && fruit.HyperDash) @@ -301,24 +266,17 @@ namespace osu.Game.Rulesets.Catch.UI } } - private void runHyperDashStateTransition(bool hyperDashing) + public void UpdatePosition(float position) { - updateTrailVisibility(); + position = Math.Clamp(position, 0, CatchPlayfield.WIDTH); - if (hyperDashing) - { - this.FadeColour(hyperDashColour, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); - this.FadeTo(0.2f, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); - } - else - { - this.FadeColour(Color4.White, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); - this.FadeTo(1f, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); - } + if (position == X) + return; + + Scale = new Vector2(Math.Abs(Scale.X) * (position > X ? 1 : -1), Scale.Y); + X = position; } - private void updateTrailVisibility() => trails.DisplayTrail = Dashing || HyperDashing; - public bool OnPressed(CatchAction action) { switch (action) @@ -357,56 +315,34 @@ namespace osu.Game.Rulesets.Catch.UI } } - public void UpdatePosition(float position) - { - position = Math.Clamp(position, 0, CatchPlayfield.WIDTH); - - if (position == X) - return; - - Scale = new Vector2(Math.Abs(Scale.X) * (position > X ? 1 : -1), Scale.Y); - X = position; - } - /// /// Drop any fruit off the plate. /// - public void Drop() - { - foreach (var f in caughtFruitContainer.ToArray()) - Drop(f); - } + public void Drop() => clearPlate(DroppedObjectAnimation.Drop); /// - /// Explode any fruit off the plate. + /// Explode all fruit off the plate. /// - public void Explode() - { - foreach (var f in caughtFruitContainer.ToArray()) - Explode(f); - } + public void Explode() => clearPlate(DroppedObjectAnimation.Explode); - public void Drop(DrawableHitObject fruit) + private void runHyperDashStateTransition(bool hyperDashing) { - removeFromPlateWithTransform(fruit, f => + updateTrailVisibility(); + + if (hyperDashing) { - f.MoveToY(f.Y + 75, 750, Easing.InSine); - f.FadeOut(750); - }); - } - - public void Explode(DrawableHitObject fruit) - { - var originalX = fruit.X * Scale.X; - - removeFromPlateWithTransform(fruit, f => + this.FadeColour(hyperDashColour, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); + this.FadeTo(0.2f, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); + } + else { - f.MoveToY(f.Y - 50, 250, Easing.OutSine).Then().MoveToY(f.Y + 50, 500, Easing.InSine); - f.MoveToX(f.X + originalX * 6, 1000); - f.FadeOut(750); - }); + this.FadeColour(Color4.White, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); + this.FadeTo(1f, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); + } } + private void updateTrailVisibility() => trails.DisplayTrail = Dashing || HyperDashing; + protected override void SkinChanged(ISkinSource skin, bool allowFallback) { base.SkinChanged(skin, allowFallback); @@ -479,33 +415,143 @@ namespace osu.Game.Rulesets.Catch.UI updateCatcher(); } - private void removeFromPlateWithTransform(DrawableHitObject fruit, Action action) + private void placeCaughtObject(PalpableCatchHitObject source) { - if (ExplodingFruitTarget != null) + var caughtObject = createCaughtObject(source); + + if (caughtObject == null) return; + + caughtObject.RelativePositionAxes = Axes.None; + caughtObject.X = source.X - X; + caughtObject.IsOnPlate = true; + + caughtObject.Anchor = Anchor.TopCentre; + caughtObject.Origin = Anchor.Centre; + caughtObject.Scale *= 0.5f; + caughtObject.LifetimeStart = source.StartTime; + caughtObject.LifetimeEnd = double.MaxValue; + + adjustPositionInStack(caughtObject); + + caughtFruitContainer.Add(caughtObject); + + addLighting(caughtObject); + + if (!caughtObject.StaysOnPlate) + removeFromPlate(caughtObject, DroppedObjectAnimation.Explode); + } + + private void adjustPositionInStack(DrawablePalpableCatchHitObject caughtObject) + { + const float radius_div_2 = CatchHitObject.OBJECT_RADIUS / 2; + const float allowance = 10; + + float caughtObjectRadius = caughtObject.DisplayRadius; + + while (caughtFruitContainer.Any(f => Vector2Extensions.Distance(f.Position, caughtObject.Position) < (caughtObjectRadius + radius_div_2) / (allowance / 2))) { - fruit.Anchor = Anchor.TopLeft; - fruit.Position = caughtFruitContainer.ToSpaceOfOtherDrawable(fruit.DrawPosition, ExplodingFruitTarget); + float diff = (caughtObjectRadius + radius_div_2) / allowance; - if (!caughtFruitContainer.Remove(fruit)) - // we may have already been removed by a previous operation (due to the weird OnLoadComplete scheduling). - // this avoids a crash on potentially attempting to Add a fruit to ExplodingFruitTarget twice. - return; - - ExplodingFruitTarget.Add(fruit); + caughtObject.X += (RNG.NextSingle() - 0.5f) * diff * 2; + caughtObject.Y -= RNG.NextSingle() * diff; } - var actionTime = Clock.CurrentTime; + caughtObject.X = Math.Clamp(caughtObject.X, -CatcherArea.CATCHER_SIZE / 2, CatcherArea.CATCHER_SIZE / 2); + } - fruit.ApplyCustomUpdateState += onFruitOnApplyCustomUpdateState; - onFruitOnApplyCustomUpdateState(fruit, fruit.State.Value); + private void addLighting(DrawablePalpableCatchHitObject caughtObject) + { + if (!hitLighting.Value) return; - void onFruitOnApplyCustomUpdateState(DrawableHitObject o, ArmedState state) + HitExplosion hitExplosion = hitExplosionPool.Get(); + hitExplosion.X = caughtObject.X; + hitExplosion.Scale = new Vector2(caughtObject.HitObject.Scale); + hitExplosion.ObjectColour = caughtObject.AccentColour.Value; + hitExplosionContainer.Add(hitExplosion); + } + + private DrawablePalpableCatchHitObject createCaughtObject(PalpableCatchHitObject source) + { + switch (source) { - using (fruit.BeginAbsoluteSequence(actionTime)) - action(fruit); + case Banana banana: + return new DrawableBanana(banana); - fruit.Expire(); + case Fruit fruit: + return new DrawableFruit(fruit); + + case TinyDroplet tiny: + return new DrawableTinyDroplet(tiny); + + case Droplet droplet: + return new DrawableDroplet(droplet); + + default: + return null; } } + + private void clearPlate(DroppedObjectAnimation animation) + { + var caughtObjects = caughtFruitContainer.Children.ToArray(); + caughtFruitContainer.Clear(false); + + droppedObjectTarget.AddRange(caughtObjects); + + foreach (var caughtObject in caughtObjects) + drop(caughtObject, animation); + } + + private void removeFromPlate(DrawablePalpableCatchHitObject caughtObject, DroppedObjectAnimation animation) + { + if (!caughtFruitContainer.Remove(caughtObject)) + throw new InvalidOperationException("Can only drop a caught object on the plate"); + + droppedObjectTarget.Add(caughtObject); + + drop(caughtObject, animation); + } + + private void drop(DrawablePalpableCatchHitObject d, DroppedObjectAnimation animation) + { + var originalX = d.X * Scale.X; + var startTime = Clock.CurrentTime; + + d.Anchor = Anchor.TopLeft; + d.Position = caughtFruitContainer.ToSpaceOfOtherDrawable(d.DrawPosition, droppedObjectTarget); + + // we cannot just apply the transforms because DHO clears transforms when state is updated + d.ApplyCustomUpdateState += (o, state) => animate(o, animation, originalX, startTime); + if (d.IsLoaded) + animate(d, animation, originalX, startTime); + } + + private void animate(Drawable d, DroppedObjectAnimation animation, float originalX, double startTime) + { + using (d.BeginAbsoluteSequence(startTime)) + { + switch (animation) + { + case DroppedObjectAnimation.Drop: + d.MoveToY(d.Y + 75, 750, Easing.InSine); + d.FadeOut(750); + break; + + case DroppedObjectAnimation.Explode: + d.MoveToY(d.Y - 50, 250, Easing.OutSine).Then().MoveToY(d.Y + 50, 500, Easing.InSine); + d.MoveToX(d.X + originalX * 6, 1000); + d.FadeOut(750); + break; + } + + d.Expire(); + } + } + + private enum DroppedObjectAnimation + { + Drop, + Explode + } } } diff --git a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs index 26077aeba4..539776354c 100644 --- a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs +++ b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; @@ -23,14 +22,7 @@ namespace osu.Game.Rulesets.Catch.UI public readonly Catcher MovableCatcher; private readonly CatchComboDisplay comboDisplay; - public Container ExplodingFruitTarget - { - set => MovableCatcher.ExplodingFruitTarget = value; - } - - private DrawableCatchHitObject lastPlateableFruit; - - public CatcherArea(BeatmapDifficulty difficulty = null) + public CatcherArea(Container droppedObjectContainer, BeatmapDifficulty difficulty = null) { Size = new Vector2(CatchPlayfield.WIDTH, CATCHER_SIZE); Children = new Drawable[] @@ -44,7 +36,7 @@ namespace osu.Game.Rulesets.Catch.UI Margin = new MarginPadding { Bottom = 350f }, X = CatchPlayfield.CENTER_X }, - MovableCatcher = new Catcher(this, difficulty) { X = CatchPlayfield.CENTER_X }, + MovableCatcher = new Catcher(this, droppedObjectContainer, difficulty) { X = CatchPlayfield.CENTER_X }, }; } @@ -53,47 +45,10 @@ namespace osu.Game.Rulesets.Catch.UI if (!result.Type.IsScorable()) return; - void runAfterLoaded(Action action) - { - if (lastPlateableFruit == null) - return; - - // this is required to make this run after the last caught fruit runs updateState() at least once. - // TODO: find a better alternative - if (lastPlateableFruit.IsLoaded) - action(); - else - lastPlateableFruit.OnLoadComplete += _ => action(); - } - - if (result.IsHit && hitObject is DrawablePalpableCatchHitObject fruit) - { - // create a new (cloned) fruit to stay on the plate. the original is faded out immediately. - var caughtFruit = createCaughtFruit(fruit); - - if (caughtFruit == null) return; - - caughtFruit.RelativePositionAxes = Axes.None; - caughtFruit.Position = new Vector2(MovableCatcher.ToLocalSpace(hitObject.ScreenSpaceDrawQuad.Centre).X - MovableCatcher.DrawSize.X / 2, 0); - caughtFruit.IsOnPlate = true; - - caughtFruit.Anchor = Anchor.TopCentre; - caughtFruit.Origin = Anchor.Centre; - caughtFruit.Scale *= 0.5f; - caughtFruit.LifetimeStart = caughtFruit.HitObject.StartTime; - caughtFruit.LifetimeEnd = double.MaxValue; - - MovableCatcher.PlaceOnPlate(caughtFruit); - lastPlateableFruit = caughtFruit; - - if (!fruit.StaysOnPlate) - runAfterLoaded(() => MovableCatcher.Explode(caughtFruit)); - } - if (hitObject.HitObject.LastInCombo) { if (result.Judgement is CatchJudgement catchJudgement && catchJudgement.ShouldExplodeFor(result)) - runAfterLoaded(() => MovableCatcher.Explode()); + MovableCatcher.Explode(); else MovableCatcher.Drop(); } @@ -104,10 +59,6 @@ namespace osu.Game.Rulesets.Catch.UI public void OnRevertResult(DrawableCatchHitObject fruit, JudgementResult result) => comboDisplay.OnRevertResult(fruit, result); - public void OnReleased(CatchAction action) - { - } - public bool AttemptCatch(CatchHitObject obj) { return MovableCatcher.AttemptCatch(obj); @@ -124,26 +75,5 @@ namespace osu.Game.Rulesets.Catch.UI comboDisplay.X = MovableCatcher.X; } - - private DrawableCatchHitObject createCaughtFruit(DrawablePalpableCatchHitObject hitObject) - { - switch (hitObject.HitObject) - { - case Banana banana: - return new DrawableBanana(banana); - - case Fruit fruit: - return new DrawableFruit(fruit); - - case TinyDroplet tiny: - return new DrawableTinyDroplet(tiny); - - case Droplet droplet: - return new DrawableDroplet(droplet); - - default: - return null; - } - } } }