diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.cs index fecb5d4a74..61ea8b664d 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.cs @@ -2,8 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.UI; @@ -13,6 +17,8 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [TestFixture] public class TestSceneHitExplosion : TaikoSkinnableTestScene { + protected override double TimePerAction => 100; + [Test] public void TestNormalHit() { @@ -21,11 +27,14 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning AddStep("Miss", () => SetContents(() => getContentFor(createHit(HitResult.Miss)))); } - [Test] - public void TestStrongHit([Values(false, true)] bool hitBoth) + [TestCase(HitResult.Great)] + [TestCase(HitResult.Ok)] + public void TestStrongHit(HitResult type) { - AddStep("Great", () => SetContents(() => getContentFor(createStrongHit(HitResult.Great, hitBoth)))); - AddStep("Good", () => SetContents(() => getContentFor(createStrongHit(HitResult.Ok, hitBoth)))); + AddStep("create hit", () => SetContents(() => getContentFor(createStrongHit(type)))); + AddStep("visualise second hit", + () => this.ChildrenOfType() + .ForEach(e => e.VisualiseSecondHit(new JudgementResult(new HitObject { StartTime = Time.Current }, new Judgement())))); } private Drawable getContentFor(DrawableTestHit hit) @@ -38,17 +47,17 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning // the hit needs to be added to hierarchy in order for nested objects to be created correctly. // setting zero alpha is supposed to prevent the test from looking broken. hit.With(h => h.Alpha = 0), - new HitExplosion(hit, hit.Type) + new HitExplosion(hit.Type) { Anchor = Anchor.Centre, Origin = Anchor.Centre, - } + }.With(explosion => explosion.Apply(hit)) } }; } private DrawableTestHit createHit(HitResult type) => new DrawableTestHit(new Hit { StartTime = Time.Current }, type); - private DrawableTestHit createStrongHit(HitResult type, bool hitBoth) => new DrawableTestStrongHit(Time.Current, type, hitBoth); + private DrawableTestHit createStrongHit(HitResult type) => new DrawableTestStrongHit(Time.Current, type); } } diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs index 7695ca067b..87c936d386 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs @@ -11,7 +11,6 @@ using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Judgements; using osu.Game.Rulesets.Taiko.Objects; @@ -108,12 +107,12 @@ namespace osu.Game.Rulesets.Taiko.Tests { HitResult hitResult = RNG.Next(2) == 0 ? HitResult.Ok : HitResult.Great; - Hit hit = new Hit(); + Hit hit = new Hit { StartTime = DrawableRuleset.Playfield.Time.Current }; var h = new DrawableTestHit(hit, kiai: kiai) { X = RNG.NextSingle(hitResult == HitResult.Ok ? -0.1f : -0.05f, hitResult == HitResult.Ok ? 0.1f : 0.05f) }; DrawableRuleset.Playfield.Add(h); - ((TaikoPlayfield)DrawableRuleset.Playfield).OnNewResult(h, new JudgementResult(new HitObject(), new TaikoJudgement()) { Type = hitResult }); + ((TaikoPlayfield)DrawableRuleset.Playfield).OnNewResult(h, new JudgementResult(hit, new TaikoJudgement()) { Type = hitResult }); } private void addStrongHitJudgement(bool kiai) @@ -122,6 +121,7 @@ namespace osu.Game.Rulesets.Taiko.Tests Hit hit = new Hit { + StartTime = DrawableRuleset.Playfield.Time.Current, IsStrong = true, Samples = createSamples(strong: true) }; @@ -129,8 +129,8 @@ namespace osu.Game.Rulesets.Taiko.Tests DrawableRuleset.Playfield.Add(h); - ((TaikoPlayfield)DrawableRuleset.Playfield).OnNewResult(h, new JudgementResult(new HitObject(), new TaikoJudgement()) { Type = hitResult }); - ((TaikoPlayfield)DrawableRuleset.Playfield).OnNewResult(h.NestedHitObjects.Single(), new JudgementResult(new HitObject(), new TaikoStrongJudgement()) { Type = HitResult.Great }); + ((TaikoPlayfield)DrawableRuleset.Playfield).OnNewResult(h, new JudgementResult(hit, new TaikoJudgement()) { Type = hitResult }); + ((TaikoPlayfield)DrawableRuleset.Playfield).OnNewResult(h.NestedHitObjects.Single(), new JudgementResult(hit.NestedHitObjects.Single(), new TaikoStrongJudgement()) { Type = HitResult.Great }); } private void addMissJudgement() diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyHitExplosion.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyHitExplosion.cs index 651cdd6438..21bd35ad22 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyHitExplosion.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyHitExplosion.cs @@ -1,22 +1,22 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osu.Game.Rulesets.Taiko.UI; namespace osu.Game.Rulesets.Taiko.Skinning.Legacy { - public class LegacyHitExplosion : CompositeDrawable + public class LegacyHitExplosion : CompositeDrawable, IAnimatableHitExplosion { private readonly Drawable sprite; - private readonly Drawable strongSprite; - private DrawableStrongNestedHit nestedStrongHit; - private bool switchedToStrongSprite; + [CanBeNull] + private readonly Drawable strongSprite; /// /// Creates a new legacy hit explosion. @@ -27,14 +27,14 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy /// /// The normal legacy explosion sprite. /// The strong legacy explosion sprite. - public LegacyHitExplosion(Drawable sprite, Drawable strongSprite = null) + public LegacyHitExplosion(Drawable sprite, [CanBeNull] Drawable strongSprite = null) { this.sprite = sprite; this.strongSprite = strongSprite; } [BackgroundDependencyLoader] - private void load(DrawableHitObject judgedObject) + private void load() { Anchor = Anchor.Centre; Origin = Anchor.Centre; @@ -56,45 +56,30 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy s.Origin = Anchor.Centre; })); } - - if (judgedObject is DrawableHit hit) - nestedStrongHit = hit.NestedHitObjects.SingleOrDefault() as DrawableStrongNestedHit; } - protected override void LoadComplete() + public void Animate(DrawableHitObject drawableHitObject) { - base.LoadComplete(); - const double animation_time = 120; + (sprite as IFramedAnimation)?.GotoFrame(0); + (strongSprite as IFramedAnimation)?.GotoFrame(0); + this.FadeInFromZero(animation_time).Then().FadeOut(animation_time * 1.5); this.ScaleTo(0.6f) .Then().ScaleTo(1.1f, animation_time * 0.8) .Then().ScaleTo(0.9f, animation_time * 0.4) .Then().ScaleTo(1f, animation_time * 0.2); - - Expire(true); } - protected override void Update() + public void AnimateSecondHit() { - base.Update(); + if (strongSprite == null) + return; - if (shouldSwitchToStrongSprite() && !switchedToStrongSprite) - { - sprite.FadeOut(50, Easing.OutQuint); - strongSprite.FadeIn(50, Easing.OutQuint); - switchedToStrongSprite = true; - } - } - - private bool shouldSwitchToStrongSprite() - { - if (nestedStrongHit == null || strongSprite == null) - return false; - - return nestedStrongHit.IsHit; + sprite.FadeOut(50, Easing.OutQuint); + strongSprite.FadeIn(50, Easing.OutQuint); } } } diff --git a/osu.Game.Rulesets.Taiko/UI/DefaultHitExplosion.cs b/osu.Game.Rulesets.Taiko/UI/DefaultHitExplosion.cs index 3bd20e4bb4..91e844187a 100644 --- a/osu.Game.Rulesets.Taiko/UI/DefaultHitExplosion.cs +++ b/osu.Game.Rulesets.Taiko/UI/DefaultHitExplosion.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 JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -13,19 +14,23 @@ using osuTK.Graphics; namespace osu.Game.Rulesets.Taiko.UI { - internal class DefaultHitExplosion : CircularContainer + internal class DefaultHitExplosion : CircularContainer, IAnimatableHitExplosion { - private readonly DrawableHitObject judgedObject; private readonly HitResult result; - public DefaultHitExplosion(DrawableHitObject judgedObject, HitResult result) + [CanBeNull] + private Box body; + + [Resolved] + private OsuColour colours { get; set; } + + public DefaultHitExplosion(HitResult result) { - this.judgedObject = judgedObject; this.result = result; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { RelativeSizeAxes = Axes.Both; @@ -40,26 +45,36 @@ namespace osu.Game.Rulesets.Taiko.UI if (!result.IsHit()) return; - bool isRim = (judgedObject.HitObject as Hit)?.Type == HitType.Rim; - InternalChildren = new[] { - new Box + body = new Box { RelativeSizeAxes = Axes.Both, - Colour = isRim ? colours.BlueDarker : colours.PinkDarker, } }; + + updateColour(); } - protected override void LoadComplete() + private void updateColour([CanBeNull] DrawableHitObject judgedObject = null) { - base.LoadComplete(); + if (body == null) + return; + + bool isRim = (judgedObject?.HitObject as Hit)?.Type == HitType.Rim; + body.Colour = isRim ? colours.BlueDarker : colours.PinkDarker; + } + + public void Animate(DrawableHitObject drawableHitObject) + { + updateColour(drawableHitObject); this.ScaleTo(3f, 1000, Easing.OutQuint); this.FadeOut(500); + } - Expire(true); + public void AnimateSecondHit() + { } } } diff --git a/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs b/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs index d1fb3348b9..8f5e9e54ab 100644 --- a/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs +++ b/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs @@ -2,10 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System; +using JetBrains.Annotations; using osuTK; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Objects; @@ -16,31 +18,37 @@ namespace osu.Game.Rulesets.Taiko.UI /// /// A circle explodes from the hit target to indicate a hitobject has been hit. /// - internal class HitExplosion : CircularContainer + internal class HitExplosion : PoolableDrawable { public override bool RemoveWhenNotAlive => true; - - [Cached(typeof(DrawableHitObject))] - public readonly DrawableHitObject JudgedObject; + public override bool RemoveCompletedTransforms => false; private readonly HitResult result; + private double? secondHitTime; + + [CanBeNull] + public DrawableHitObject JudgedObject; + private SkinnableDrawable skinnable; - public override double LifetimeStart => skinnable.Drawable.LifetimeStart; - - public override double LifetimeEnd => skinnable.Drawable.LifetimeEnd; - - public HitExplosion(DrawableHitObject judgedObject, HitResult result) + /// + /// This constructor only exists to meet the new() type constraint of . + /// + public HitExplosion() + : this(HitResult.Great) + { + } + + public HitExplosion(HitResult result) { - JudgedObject = judgedObject; this.result = result; Anchor = Anchor.Centre; Origin = Anchor.Centre; - RelativeSizeAxes = Axes.Both; Size = new Vector2(TaikoHitObject.DEFAULT_SIZE); + RelativeSizeAxes = Axes.Both; RelativePositionAxes = Axes.Both; } @@ -48,7 +56,47 @@ namespace osu.Game.Rulesets.Taiko.UI [BackgroundDependencyLoader] private void load() { - Child = skinnable = new SkinnableDrawable(new TaikoSkinComponent(getComponentName(result)), _ => new DefaultHitExplosion(JudgedObject, result)); + InternalChild = skinnable = new SkinnableDrawable(new TaikoSkinComponent(getComponentName(result)), _ => new DefaultHitExplosion(result)); + skinnable.OnSkinChanged += runAnimation; + } + + public void Apply([CanBeNull] DrawableHitObject drawableHitObject) + { + JudgedObject = drawableHitObject; + secondHitTime = null; + } + + protected override void PrepareForUse() + { + base.PrepareForUse(); + runAnimation(); + } + + private void runAnimation() + { + if (JudgedObject?.Result == null) + return; + + double resultTime = JudgedObject.Result.TimeAbsolute; + + LifetimeStart = resultTime; + + ApplyTransformsAt(double.MinValue, true); + ClearTransforms(true); + + using (BeginAbsoluteSequence(resultTime)) + (skinnable.Drawable as IAnimatableHitExplosion)?.Animate(JudgedObject); + + if (secondHitTime != null) + { + using (BeginAbsoluteSequence(secondHitTime.Value)) + { + this.ResizeTo(new Vector2(TaikoStrongableHitObject.DEFAULT_STRONG_SIZE), 50); + (skinnable.Drawable as IAnimatableHitExplosion)?.AnimateSecondHit(); + } + } + + LifetimeEnd = skinnable.Drawable.LatestTransformEndTime; } private static TaikoSkinComponents getComponentName(HitResult result) @@ -68,12 +116,10 @@ namespace osu.Game.Rulesets.Taiko.UI throw new ArgumentOutOfRangeException(nameof(result), $"Invalid result type: {result}"); } - /// - /// Transforms this hit explosion to visualise a secondary hit. - /// - public void VisualiseSecondHit() + public void VisualiseSecondHit(JudgementResult judgementResult) { - this.ResizeTo(new Vector2(TaikoStrongableHitObject.DEFAULT_STRONG_SIZE), 50); + secondHitTime = judgementResult.TimeAbsolute; + runAnimation(); } } } diff --git a/osu.Game.Rulesets.Taiko/UI/HitExplosionPool.cs b/osu.Game.Rulesets.Taiko/UI/HitExplosionPool.cs new file mode 100644 index 0000000000..badf34554c --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/HitExplosionPool.cs @@ -0,0 +1,24 @@ +// 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.Pooling; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Taiko.UI +{ + /// + /// Pool for hit explosions of a specific type. + /// + internal class HitExplosionPool : DrawablePool + { + private readonly HitResult hitResult; + + public HitExplosionPool(HitResult hitResult) + : base(15) + { + this.hitResult = hitResult; + } + + protected override HitExplosion CreateNewDrawable() => new HitExplosion(hitResult); + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/IAnimatableHitExplosion.cs b/osu.Game.Rulesets.Taiko/UI/IAnimatableHitExplosion.cs new file mode 100644 index 0000000000..cf0f5f9fb6 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/UI/IAnimatableHitExplosion.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Objects.Drawables; + +namespace osu.Game.Rulesets.Taiko.UI +{ + /// + /// A skinnable element of a hit explosion that supports playing an animation from the current point in time. + /// + public interface IAnimatableHitExplosion + { + /// + /// Shows the hit explosion for the supplied . + /// + void Animate(DrawableHitObject drawableHitObject); + + /// + /// Transforms the hit explosion to visualise a secondary hit. + /// + void AnimateSecondHit(); + } +} diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs index d2e7b604bb..46dafc3a30 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs @@ -42,6 +42,7 @@ namespace osu.Game.Rulesets.Taiko.UI private SkinnableDrawable mascot; private readonly IDictionary> judgementPools = new Dictionary>(); + private readonly IDictionary explosionPools = new Dictionary(); private ProxyContainer topLevelHitContainer; private Container rightArea; @@ -166,10 +167,15 @@ namespace osu.Game.Rulesets.Taiko.UI RegisterPool(100); var hitWindows = new TaikoHitWindows(); + foreach (var result in Enum.GetValues(typeof(HitResult)).OfType().Where(r => hitWindows.IsHitResultAllowed(r))) + { judgementPools.Add(result, new DrawablePool(15)); + explosionPools.Add(result, new HitExplosionPool(result)); + } AddRangeInternal(judgementPools.Values); + AddRangeInternal(explosionPools.Values); } protected override void LoadComplete() @@ -281,7 +287,7 @@ namespace osu.Game.Rulesets.Taiko.UI { case TaikoStrongJudgement _: if (result.IsHit) - hitExplosionContainer.Children.FirstOrDefault(e => e.JudgedObject == ((DrawableStrongNestedHit)judgedObject).ParentHitObject)?.VisualiseSecondHit(); + hitExplosionContainer.Children.FirstOrDefault(e => e.JudgedObject == ((DrawableStrongNestedHit)judgedObject).ParentHitObject)?.VisualiseSecondHit(result); break; case TaikoDrumRollTickJudgement _: @@ -315,7 +321,8 @@ namespace osu.Game.Rulesets.Taiko.UI private void addExplosion(DrawableHitObject drawableObject, HitResult result, HitType type) { - hitExplosionContainer.Add(new HitExplosion(drawableObject, result)); + hitExplosionContainer.Add(explosionPools[result] + .Get(explosion => explosion.Apply(drawableObject))); if (drawableObject.HitObject.Kiai) kiaiExplosionContainer.Add(new KiaiHitExplosion(drawableObject, type)); }