diff --git a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs index 5e8a0b1216..6a24c26844 100644 --- a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs +++ b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs @@ -41,7 +41,6 @@ namespace osu.Game.Rulesets.Catch.Beatmaps X = xPositionData?.X ?? 0, NewCombo = comboData?.NewCombo ?? false, ComboOffset = comboData?.ComboOffset ?? 0, - LegacyLastTickOffset = (obj as IHasLegacyLastTickOffset)?.LegacyLastTickOffset ?? 0, LegacyConvertedY = yPositionData?.Y ?? CatchHitObject.DEFAULT_LEGACY_CONVERT_Y, SliderVelocityMultiplier = sliderVelocityData?.SliderVelocityMultiplier ?? 1 }.Yield(); diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs index 724bdc3401..fb1a86d8c0 100644 --- a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs +++ b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs @@ -77,7 +77,7 @@ namespace osu.Game.Rulesets.Catch.Objects int nodeIndex = 0; SliderEventDescriptor? lastEvent = null; - foreach (var e in SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset, cancellationToken)) + foreach (var e in SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), cancellationToken)) { // generate tiny droplets since the last point if (lastEvent != null) @@ -104,8 +104,8 @@ namespace osu.Game.Rulesets.Catch.Objects } } - // this also includes LegacyLastTick and this is used for TinyDroplet generation above. - // this means that the final segment of TinyDroplets are increasingly mistimed where LegacyLastTickOffset is being applied. + // this also includes LastTick and this is used for TinyDroplet generation above. + // this means that the final segment of TinyDroplets are increasingly mistimed where LastTick is being applied. lastEvent = e; switch (e.Type) @@ -162,7 +162,5 @@ namespace osu.Game.Rulesets.Catch.Objects public double Distance => Path.Distance; public IList> NodeSamples { get; set; } = new List>(); - - public double? LegacyLastTickOffset { get; set; } } } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs index 8ba97892fe..7315344295 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs @@ -163,7 +163,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor slider = new Slider { Position = new Vector2(0, 50), - LegacyLastTickOffset = 36, // This is necessary for undo to retain the sample control point Path = new SliderPath(new[] { new PathControlPoint(Vector2.Zero, PathType.PerfectCurve), diff --git a/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/reversearrow.png b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/reversearrow.png new file mode 100644 index 0000000000..7ebdec37d3 Binary files /dev/null and b/osu.Game.Rulesets.Osu.Tests/Resources/old-skin/reversearrow.png differ diff --git a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs index f3aaf831d3..3c051a6bb1 100644 --- a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs +++ b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs @@ -44,7 +44,6 @@ namespace osu.Game.Rulesets.Osu.Beatmaps Position = positionData?.Position ?? Vector2.Zero, NewCombo = comboData?.NewCombo ?? false, ComboOffset = comboData?.ComboOffset ?? 0, - LegacyLastTickOffset = (original as IHasLegacyLastTickOffset)?.LegacyLastTickOffset, // prior to v8, speed multipliers don't adjust for how many ticks are generated over the same distance. // this results in more (or less) ticks being generated in s.With()).ToList(), RepeatCount = HitObject.RepeatCount, NodeSamples = HitObject.NodeSamples.Select(n => (IList)n.Select(s => s.With()).ToList()).ToList(), diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs index 9b5d405025..78062a0632 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs @@ -96,14 +96,13 @@ namespace osu.Game.Rulesets.Osu.Mods Position = original.Position; NewCombo = original.NewCombo; ComboOffset = original.ComboOffset; - LegacyLastTickOffset = original.LegacyLastTickOffset; TickDistanceMultiplier = original.TickDistanceMultiplier; SliderVelocityMultiplier = original.SliderVelocityMultiplier; } protected override void CreateNestedHitObjects(CancellationToken cancellationToken) { - var sliderEvents = SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset, cancellationToken); + var sliderEvents = SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), cancellationToken); foreach (var e in sliderEvents) { @@ -130,7 +129,7 @@ namespace osu.Game.Rulesets.Osu.Mods }); break; - case SliderEventType.LegacyLastTick: + case SliderEventType.LastTick: AddNested(TailCircle = new StrictTrackingSliderTailCircle(this) { RepeatIndex = e.SpanIndex, diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModSynesthesia.cs b/osu.Game.Rulesets.Osu/Mods/OsuModSynesthesia.cs index 9537f8b388..e1123807cd 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModSynesthesia.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModSynesthesia.cs @@ -36,7 +36,7 @@ namespace osu.Game.Rulesets.Osu.Mods d.HitObjectApplied += _ => { - // slider tails are a painful edge case, as their start time is offset 36ms back (see `LegacyLastTick`). + // slider tails are a painful edge case, as their start time is offset 36ms back (see `LastTick`). // to work around this, look up the slider tail's parenting slider's end time instead to ensure proper snap. double snapTime = d is DrawableSliderTail tail ? tail.Slider.GetEndTime() diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index 07e448937f..789755652f 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -296,7 +296,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public override void PlaySamples() { // rather than doing it this way, we should probably attach the sample to the tail circle. - // this can only be done after we stop using LegacyLastTick. + // this can only be done if we stop using LastTick. if (!TailCircle.SamplePlaysOnlyOnHit || TailCircle.IsHit) base.PlaySamples(); } diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs index 5721328057..ac4d733672 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables public SkinnableDrawable CirclePiece { get; private set; } - public ReverseArrowPiece Arrow { get; private set; } + public SkinnableDrawable Arrow { get; private set; } private Drawable scaleContainer; @@ -65,7 +65,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Anchor = Anchor.Centre, Origin = Anchor.Centre, }, - Arrow = new ReverseArrowPiece(), + Arrow = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.ReverseArrow), _ => new DefaultReverseArrow()) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, } }); diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index e05dbd8ea6..443e4229d2 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -71,8 +71,6 @@ namespace osu.Game.Rulesets.Osu.Objects } } - public double? LegacyLastTickOffset { get; set; } - /// /// The position of the cursor at the point of completion of this if it was hit /// with as few movements as possible. This is set and used by difficulty calculation. @@ -179,7 +177,7 @@ namespace osu.Game.Rulesets.Osu.Objects { base.CreateNestedHitObjects(cancellationToken); - var sliderEvents = SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset, cancellationToken); + var sliderEvents = SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), cancellationToken); foreach (var e in sliderEvents) { @@ -206,10 +204,11 @@ namespace osu.Game.Rulesets.Osu.Objects }); break; - case SliderEventType.LegacyLastTick: - // we need to use the LegacyLastTick here for compatibility reasons (difficulty). - // it is *okay* to use this because the TailCircle is not used for any meaningful purpose in gameplay. - // if this is to change, we should revisit this. + case SliderEventType.LastTick: + // Of note, we are directly mapping LastTick (instead of `SliderEventType.Tail`) to SliderTailCircle. + // It is required as difficulty calculation and gameplay relies on reading this value. + // (although it is displayed in classic skins, which may be a concern). + // If this is to change, we should revisit this. AddNested(TailCircle = new SliderTailCircle(this) { RepeatIndex = e.SpanIndex, @@ -264,7 +263,9 @@ namespace osu.Game.Rulesets.Osu.Objects if (HeadCircle != null) HeadCircle.Samples = this.GetNodeSamples(0); - // The samples should be attached to the slider tail, however this can only be done after LegacyLastTick is removed otherwise they would play earlier than they're intended to. + // The samples should be attached to the slider tail, however this can only be done if LastTick is removed otherwise they would play earlier than they're intended to. + // (see mapping logic in `CreateNestedHitObjects` above) + // // For now, the samples are played by the slider itself at the correct end time. TailSamples = this.GetNodeSamples(repeatCount + 1); } diff --git a/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs b/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs index b4574791d2..fb81936837 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs @@ -10,7 +10,7 @@ namespace osu.Game.Rulesets.Osu.Objects { /// /// Note that this should not be used for timing correctness. - /// See usage in for more information. + /// See usage in for more information. /// public class SliderTailCircle : SliderEndCircle { diff --git a/osu.Game.Rulesets.Osu/Objects/Spinner.cs b/osu.Game.Rulesets.Osu/Objects/Spinner.cs index aca55c6bd9..e3dfe8e69a 100644 --- a/osu.Game.Rulesets.Osu/Objects/Spinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Spinner.cs @@ -70,8 +70,11 @@ namespace osu.Game.Rulesets.Osu.Objects double secondsDuration = Duration / 1000; - SpinsRequired = (int)(minRps * secondsDuration); - MaximumBonusSpins = Math.Max(0, (int)(maxRps * secondsDuration) - SpinsRequired - bonus_spins_gap); + // Allow a 0.1ms floating point precision error in the calculation of the duration. + const double duration_error = 0.0001; + + SpinsRequired = (int)(minRps * secondsDuration + duration_error); + MaximumBonusSpins = Math.Max(0, (int)(maxRps * secondsDuration + duration_error) - SpinsRequired - bonus_spins_gap); } protected override void CreateNestedHitObjects(CancellationToken cancellationToken) diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs index 67fc1b2304..160edb6f67 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs @@ -4,10 +4,12 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; using osuTK; @@ -17,12 +19,17 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon { public partial class ArgonReverseArrow : CompositeDrawable { + [Resolved] + private DrawableHitObject drawableObject { get; set; } = null!; + private Bindable accentColour = null!; private SpriteIcon icon = null!; + private Container main = null!; + private Sprite side = null!; [BackgroundDependencyLoader] - private void load(DrawableHitObject hitObject) + private void load(TextureStore textures) { Anchor = Anchor.Centre; Origin = Anchor.Centre; @@ -31,24 +38,73 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon InternalChildren = new Drawable[] { - new Circle + main = new Container { - Size = new Vector2(40, 20), - Colour = Color4.White, + RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, + Children = new Drawable[] + { + new Circle + { + Size = new Vector2(40, 20), + Colour = Color4.White, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + icon = new SpriteIcon + { + Icon = FontAwesome.Solid.AngleDoubleRight, + Size = new Vector2(16), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + } }, - icon = new SpriteIcon + side = new Sprite { - Icon = FontAwesome.Solid.AngleDoubleRight, - Size = new Vector2(16), Anchor = Anchor.Centre, Origin = Anchor.Centre, - }, + Texture = textures.Get("Gameplay/osu/repeat-edge-piece"), + Size = new Vector2(ArgonMainCirclePiece.OUTER_GRADIENT_SIZE), + } }; - accentColour = hitObject.AccentColour.GetBoundCopy(); + accentColour = drawableObject.AccentColour.GetBoundCopy(); accentColour.BindValueChanged(accent => icon.Colour = accent.NewValue.Darken(4), true); + + drawableObject.ApplyCustomUpdateState += updateStateTransforms; + } + + private void updateStateTransforms(DrawableHitObject hitObject, ArmedState state) + { + const float move_distance = -12; + const double move_out_duration = 35; + const double move_in_duration = 250; + const double total = 300; + + switch (state) + { + case ArmedState.Idle: + main.ScaleTo(1.3f, move_out_duration, Easing.Out) + .Then() + .ScaleTo(1f, move_in_duration, Easing.Out) + .Loop(total - (move_in_duration + move_out_duration)); + side + .MoveToX(move_distance, move_out_duration, Easing.Out) + .Then() + .MoveToX(0, move_in_duration, Easing.Out) + .Loop(total - (move_in_duration + move_out_duration)); + break; + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableObject.IsNotNull()) + drawableObject.ApplyCustomUpdateState -= updateStateTransforms; } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/DefaultReverseArrow.cs b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultReverseArrow.cs new file mode 100644 index 0000000000..b44f6571b9 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Skinning/Default/DefaultReverseArrow.cs @@ -0,0 +1,69 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Osu.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Skinning.Default +{ + public partial class DefaultReverseArrow : CompositeDrawable + { + [Resolved] + private DrawableHitObject drawableObject { get; set; } = null!; + + public DefaultReverseArrow() + { + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + Size = OsuHitObject.OBJECT_DIMENSIONS; + + InternalChild = new SpriteIcon + { + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Icon = FontAwesome.Solid.ChevronRight, + Size = new Vector2(0.35f), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }; + } + + [BackgroundDependencyLoader] + private void load() + { + drawableObject.ApplyCustomUpdateState += updateStateTransforms; + } + + private void updateStateTransforms(DrawableHitObject hitObject, ArmedState state) + { + const double move_out_duration = 35; + const double move_in_duration = 250; + const double total = 300; + + switch (state) + { + case ArmedState.Idle: + InternalChild.ScaleTo(1.3f, move_out_duration, Easing.Out) + .Then() + .ScaleTo(1f, move_in_duration, Easing.Out) + .Loop(total - (move_in_duration + move_out_duration)); + break; + } + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (drawableObject.IsNotNull()) + drawableObject.ApplyCustomUpdateState -= updateStateTransforms; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/ReverseArrowPiece.cs b/osu.Game.Rulesets.Osu/Skinning/Default/ReverseArrowPiece.cs deleted file mode 100644 index 27868db2f6..0000000000 --- a/osu.Game.Rulesets.Osu/Skinning/Default/ReverseArrowPiece.cs +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Audio.Track; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Graphics.Containers; -using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Skinning; -using osuTK; - -namespace osu.Game.Rulesets.Osu.Skinning.Default -{ - public partial class ReverseArrowPiece : BeatSyncedContainer - { - [Resolved] - private DrawableHitObject drawableRepeat { get; set; } = null!; - - public ReverseArrowPiece() - { - Divisor = 2; - MinimumBeatLength = 200; - - Anchor = Anchor.Centre; - Origin = Anchor.Centre; - - Size = OsuHitObject.OBJECT_DIMENSIONS; - - Child = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.ReverseArrow), _ => new SpriteIcon - { - RelativeSizeAxes = Axes.Both, - Blending = BlendingParameters.Additive, - Icon = FontAwesome.Solid.ChevronRight, - Size = new Vector2(0.35f) - }) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }; - } - - protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) - { - if (!drawableRepeat.IsHit) - Child.ScaleTo(1.3f).ScaleTo(1f, timingPoint.BeatLength, Easing.Out); - } - } -} diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs index 3a80607522..25de6d2381 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/LegacyReverseArrow.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects.Drawables; @@ -16,8 +17,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy { public partial class LegacyReverseArrow : CompositeDrawable { - [Resolved(canBeNull: true)] - private DrawableHitObject? drawableHitObject { get; set; } + [Resolved] + private DrawableHitObject drawableObject { get; set; } = null!; private Drawable proxy = null!; @@ -27,6 +28,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy private Drawable arrow = null!; + private bool shouldRotate; + [BackgroundDependencyLoader] private void load(ISkinSource skinSource) { @@ -36,8 +39,17 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy var skin = skinSource.FindProvider(s => s.GetTexture(lookupName) != null); - InternalChild = arrow = (skin?.GetAnimation(lookupName, true, true, maxSize: OsuHitObject.OBJECT_DIMENSIONS) ?? Empty()); + InternalChild = arrow = (skin?.GetAnimation(lookupName, true, true, maxSize: OsuHitObject.OBJECT_DIMENSIONS) ?? Empty()).With(d => + { + d.Anchor = Anchor.Centre; + d.Origin = Anchor.Centre; + }); + textureIsDefaultSkin = skin is ISkinTransformer transformer && transformer.Skin is DefaultLegacySkin; + + drawableObject.ApplyCustomUpdateState += updateStateTransforms; + + shouldRotate = skinSource.GetConfig(SkinConfiguration.LegacySetting.Version)?.Value <= 1; } protected override void LoadComplete() @@ -46,17 +58,14 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy proxy = CreateProxy(); - if (drawableHitObject != null) - { - drawableHitObject.HitObjectApplied += onHitObjectApplied; - onHitObjectApplied(drawableHitObject); + drawableObject.HitObjectApplied += onHitObjectApplied; + onHitObjectApplied(drawableObject); - accentColour = drawableHitObject.AccentColour.GetBoundCopy(); - accentColour.BindValueChanged(c => - { - arrow.Colour = textureIsDefaultSkin && c.NewValue.R + c.NewValue.G + c.NewValue.B > (600 / 255f) ? Color4.Black : Color4.White; - }, true); - } + accentColour = drawableObject.AccentColour.GetBoundCopy(); + accentColour.BindValueChanged(c => + { + arrow.Colour = textureIsDefaultSkin && c.NewValue.R + c.NewValue.G + c.NewValue.B > (600 / 255f) ? Color4.Black : Color4.White; + }, true); } private void onHitObjectApplied(DrawableHitObject drawableObject) @@ -68,11 +77,43 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy .OverlayElementContainer.Add(proxy); } + private void updateStateTransforms(DrawableHitObject hitObject, ArmedState state) + { + const double duration = 300; + const float rotation = 5.625f; + + switch (state) + { + case ArmedState.Idle: + if (shouldRotate) + { + InternalChild.ScaleTo(1.3f) + .RotateTo(rotation) + .Then() + .ScaleTo(1f, duration) + .RotateTo(-rotation, duration) + .Loop(); + } + else + { + InternalChild.ScaleTo(1.3f).Then() + .ScaleTo(1f, duration, Easing.Out) + .Loop(); + } + + break; + } + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - if (drawableHitObject != null) - drawableHitObject.HitObjectApplied -= onHitObjectApplied; + + if (drawableObject.IsNotNull()) + { + drawableObject.HitObjectApplied -= onHitObjectApplied; + drawableObject.ApplyCustomUpdateState -= updateStateTransforms; + } } } } diff --git a/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs b/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs index a26c8121dd..37a91c8611 100644 --- a/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs +++ b/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs @@ -16,7 +16,7 @@ namespace osu.Game.Tests.Beatmaps [Test] public void TestSingleSpan() { - var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1, null).ToArray(); + var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1).ToArray(); Assert.That(events[0].Type, Is.EqualTo(SliderEventType.Head)); Assert.That(events[0].Time, Is.EqualTo(start_time)); @@ -31,7 +31,7 @@ namespace osu.Game.Tests.Beatmaps [Test] public void TestRepeat() { - var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 2, null).ToArray(); + var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 2).ToArray(); Assert.That(events[0].Type, Is.EqualTo(SliderEventType.Head)); Assert.That(events[0].Time, Is.EqualTo(start_time)); @@ -52,7 +52,7 @@ namespace osu.Game.Tests.Beatmaps [Test] public void TestNonEvenTicks() { - var events = SliderEventGenerator.Generate(start_time, span_duration, 1, 300, span_duration, 2, null).ToArray(); + var events = SliderEventGenerator.Generate(start_time, span_duration, 1, 300, span_duration, 2).ToArray(); Assert.That(events[0].Type, Is.EqualTo(SliderEventType.Head)); Assert.That(events[0].Time, Is.EqualTo(start_time)); @@ -83,12 +83,12 @@ namespace osu.Game.Tests.Beatmaps } [Test] - public void TestLegacyLastTickOffset() + public void TestLastTickOffset() { - var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1, 100).ToArray(); + var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1).ToArray(); - Assert.That(events[2].Type, Is.EqualTo(SliderEventType.LegacyLastTick)); - Assert.That(events[2].Time, Is.EqualTo(900)); + Assert.That(events[2].Type, Is.EqualTo(SliderEventType.LastTick)); + Assert.That(events[2].Time, Is.EqualTo(span_duration + SliderEventGenerator.LAST_TICK_OFFSET)); } [Test] @@ -97,7 +97,7 @@ namespace osu.Game.Tests.Beatmaps const double velocity = 5; const double min_distance = velocity * 10; - var events = SliderEventGenerator.Generate(start_time, span_duration, velocity, velocity, span_duration, 2, 0).ToArray(); + var events = SliderEventGenerator.Generate(start_time, span_duration, velocity, velocity, span_duration, 2).ToArray(); Assert.Multiple(() => { diff --git a/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs b/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs index 0144c0bf97..5f722e381c 100644 --- a/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs +++ b/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs @@ -44,17 +44,23 @@ namespace osu.Game.Tests.Database createFile(subdirectory2, Path.Combine("beatmap5", "beatmap.osu")); createFile(subdirectory2, Path.Combine("beatmap6", "beatmap.osu")); + // songs subdirectory with random file + var subdirectory3 = songsStorage.GetStorageForDirectory("subdirectory3"); + createFile(subdirectory3, "silly readme.txt"); + createFile(subdirectory3, Path.Combine("beatmap7", "beatmap.osu")); + // empty songs subdirectory songsStorage.GetStorageForDirectory("subdirectory3"); string[] paths = importer.GetStableImportPaths(songsStorage).ToArray(); - Assert.That(paths.Length, Is.EqualTo(6)); + Assert.That(paths.Length, Is.EqualTo(7)); Assert.That(paths.Contains(songsStorage.GetFullPath("beatmap1"))); Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory", "beatmap2")))); Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory", "beatmap3")))); Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory", "sub-subdirectory", "beatmap4")))); Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory2", "beatmap5")))); Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory2", "beatmap6")))); + Assert.That(paths.Contains(songsStorage.GetFullPath(Path.Combine("subdirectory3", "beatmap7")))); } static void createFile(Storage storage, string path) diff --git a/osu.Game.Tests/Resources/Archives/modified-argon-pro-20231001.osk b/osu.Game.Tests/Resources/Archives/modified-argon-pro-20231001.osk new file mode 100644 index 0000000000..081bb73b9e Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-argon-pro-20231001.osk differ diff --git a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs index 82d204f134..98008a003d 100644 --- a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs +++ b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs @@ -52,7 +52,9 @@ namespace osu.Game.Tests.Skins // Covers player avatar and flag. "Archives/modified-argon-20230305.osk", // Covers key counters - "Archives/modified-argon-pro-20230618.osk" + "Archives/modified-argon-pro-20230618.osk", + // Covers "Argon" health display + "Archives/modified-argon-pro-20231001.osk" }; /// diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneArgonHealthDisplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneArgonHealthDisplay.cs new file mode 100644 index 0000000000..06a7763711 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneArgonHealthDisplay.cs @@ -0,0 +1,78 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public partial class TestSceneArgonHealthDisplay : OsuTestScene + { + [Cached(typeof(HealthProcessor))] + private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep(@"Reset all", delegate + { + healthProcessor.Health.Value = 1; + healthProcessor.Failed += () => false; // health won't be updated if the processor gets into a "fail" state. + + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Gray, + }, + new ArgonHealthDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(2f), + }, + }; + }); + } + + [Test] + public void TestHealthDisplayIncrementing() + { + AddRepeatStep("apply miss judgement", delegate + { + healthProcessor.ApplyResult(new JudgementResult(new HitObject(), new Judgement()) { Type = HitResult.Miss }); + }, 5); + + AddRepeatStep(@"decrease hp slightly", delegate + { + healthProcessor.Health.Value -= 0.01f; + }, 10); + + AddRepeatStep(@"increase hp without flash", delegate + { + healthProcessor.Health.Value += 0.1f; + }, 3); + + AddRepeatStep(@"increase hp with flash", delegate + { + healthProcessor.Health.Value += 0.1f; + healthProcessor.ApplyResult(new JudgementResult(new HitCircle(), new OsuJudgement()) + { + Type = HitResult.Perfect + }); + }, 3); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs index feda251744..6a7fab86d6 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.IO; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -173,7 +174,7 @@ namespace osu.Game.Tests.Visual.Gameplay string? filePath = null; // Files starting with _ are temporary, created by CreateFileSafely call. - AddUntilStep("wait for export file", () => filePath = LocalStorage.GetFiles("exports").SingleOrDefault(f => !f.StartsWith("_", StringComparison.Ordinal)), () => Is.Not.Null); + AddUntilStep("wait for export file", () => filePath = LocalStorage.GetFiles("exports").SingleOrDefault(f => !Path.GetFileName(f).StartsWith("_", StringComparison.Ordinal)), () => Is.Not.Null); AddAssert("filesize is non-zero", () => { using (var stream = LocalStorage.GetStream(filePath)) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs index b4ebb7c410..4d8ddcd581 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs @@ -6,6 +6,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; @@ -19,6 +20,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Cached(typeof(HealthProcessor))] private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); + protected override Drawable CreateArgonImplementation() => new ArgonHealthDisplay(); protected override Drawable CreateDefaultImplementation() => new DefaultHealthDisplay(); protected override Drawable CreateLegacyImplementation() => new LegacyHealthDisplay(); @@ -28,15 +30,21 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep(@"Reset all", delegate { healthProcessor.Health.Value = 1; + healthProcessor.Failed += () => false; // health won't be updated if the processor gets into a "fail" state. }); } [Test] public void TestHealthDisplayIncrementing() { - AddRepeatStep(@"decrease hp", delegate + AddRepeatStep("apply miss judgement", delegate { - healthProcessor.Health.Value -= 0.08f; + healthProcessor.ApplyResult(new JudgementResult(new HitObject(), new Judgement()) { Type = HitResult.Miss }); + }, 5); + + AddRepeatStep(@"decrease hp slightly", delegate + { + healthProcessor.Health.Value -= 0.01f; }, 10); AddRepeatStep(@"increase hp without flash", delegate @@ -54,4 +62,4 @@ namespace osu.Game.Tests.Visual.Gameplay }, 3); } } -} +} \ No newline at end of file diff --git a/osu.Game/Database/LegacyBeatmapImporter.cs b/osu.Game/Database/LegacyBeatmapImporter.cs index 20add54949..a090698a68 100644 --- a/osu.Game/Database/LegacyBeatmapImporter.cs +++ b/osu.Game/Database/LegacyBeatmapImporter.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.IO.Stores; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Beatmaps; @@ -34,9 +33,9 @@ namespace osu.Game.Database try { - if (!directoryStorage.GetFiles(string.Empty).ExcludeSystemFileNames().Any()) + if (!directoryStorage.GetFiles(string.Empty, "*.osu").Any()) { - // if a directory doesn't contain files, attempt looking for beatmaps inside of that directory. + // if a directory doesn't contain any beatmap files, look for further nested beatmap directories. // this is a special behaviour in stable for beatmaps only, see https://github.com/ppy/osu/issues/18615. foreach (string subDirectory in GetStableImportPaths(directoryStorage)) paths.Add(subDirectory); diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs index d91ecf956a..ab8fd2c662 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs @@ -13,7 +13,7 @@ using osu.Game.Beatmaps.ControlPoints; namespace osu.Game.Rulesets.Objects.Legacy { - internal abstract class ConvertSlider : ConvertHitObject, IHasPathWithRepeats, IHasLegacyLastTickOffset, IHasSliderVelocity + internal abstract class ConvertSlider : ConvertHitObject, IHasPathWithRepeats, IHasSliderVelocity { /// /// Scoring distance with a speed-adjusted beat length of 1 second. @@ -59,7 +59,5 @@ namespace osu.Game.Rulesets.Objects.Legacy Velocity = scoringDistance / timingPoint.BeatLength; } - - public double LegacyLastTickOffset => 36; } } diff --git a/osu.Game/Rulesets/Objects/SliderEventGenerator.cs b/osu.Game/Rulesets/Objects/SliderEventGenerator.cs index 7013d32cbc..b3477a5fde 100644 --- a/osu.Game/Rulesets/Objects/SliderEventGenerator.cs +++ b/osu.Game/Rulesets/Objects/SliderEventGenerator.cs @@ -10,9 +10,17 @@ namespace osu.Game.Rulesets.Objects { public static class SliderEventGenerator { - // ReSharper disable once MethodOverloadWithOptionalParameter + /// + /// Historically, slider's final tick (aka the place where the slider would receive a final judgement) was offset by -36 ms. Originally this was + /// done to workaround a technical detail (unimportant), but over the years it has become an expectation of players that you don't need to hold + /// until the true end of the slider. This very small amount of leniency makes it easier to jump away from fast sliders to the next hit object. + /// + /// After discussion on how this should be handled going forward, players have unanimously stated that this lenience should remain in some way. + /// + public const double LAST_TICK_OFFSET = -36; + public static IEnumerable Generate(double startTime, double spanDuration, double velocity, double tickDistance, double totalDistance, int spanCount, - double? legacyLastTickOffset, CancellationToken cancellationToken = default) + CancellationToken cancellationToken = default) { // A very lenient maximum length of a slider for ticks to be generated. // This exists for edge cases such as /b/1573664 where the beatmap has been edited by the user, and should never be reached in normal usage. @@ -76,14 +84,14 @@ namespace osu.Game.Rulesets.Objects int finalSpanIndex = spanCount - 1; double finalSpanStartTime = startTime + finalSpanIndex * spanDuration; - double finalSpanEndTime = Math.Max(startTime + totalDuration / 2, (finalSpanStartTime + spanDuration) - (legacyLastTickOffset ?? 0)); + double finalSpanEndTime = Math.Max(startTime + totalDuration / 2, (finalSpanStartTime + spanDuration) + LAST_TICK_OFFSET); double finalProgress = (finalSpanEndTime - finalSpanStartTime) / spanDuration; if (spanCount % 2 == 0) finalProgress = 1 - finalProgress; yield return new SliderEventDescriptor { - Type = SliderEventType.LegacyLastTick, + Type = SliderEventType.LastTick, SpanIndex = finalSpanIndex, SpanStartTime = finalSpanStartTime, Time = finalSpanEndTime, @@ -173,7 +181,11 @@ namespace osu.Game.Rulesets.Objects public enum SliderEventType { Tick, - LegacyLastTick, + + /// + /// Occurs just before the tail. See . + /// + LastTick, Head, Tail, Repeat diff --git a/osu.Game/Rulesets/Objects/Types/IHasLegacyLastTickOffset.cs b/osu.Game/Rulesets/Objects/Types/IHasLegacyLastTickOffset.cs deleted file mode 100644 index caf22c3023..0000000000 --- a/osu.Game/Rulesets/Objects/Types/IHasLegacyLastTickOffset.cs +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -namespace osu.Game.Rulesets.Objects.Types -{ - /// - /// A type of which may require the last tick to be offset. - /// This is specific to osu!stable conversion, and should not be used elsewhere. - /// - public interface IHasLegacyLastTickOffset - { - double LegacyLastTickOffset { get; } - } -} diff --git a/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs new file mode 100644 index 0000000000..62a4b958c2 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/ArgonHealthDisplay.cs @@ -0,0 +1,323 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Lines; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Threading; +using osu.Framework.Utils; +using osu.Game.Graphics; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Play.HUD +{ + public partial class ArgonHealthDisplay : HealthDisplay, ISerialisableDrawable + { + public bool UsesFixedAnchor { get; set; } + + private BarPath mainBar = null!; + + /// + /// Used to show a glow at the end of the main bar, or red "damage" area when missing. + /// + private BarPath glowBar = null!; + + private BackgroundPath background = null!; + + private SliderPath barPath = null!; + + private static readonly Colour4 main_bar_colour = Colour4.White; + private static readonly Colour4 main_bar_glow_colour = Color4Extensions.FromHex("#7ED7FD").Opacity(0.5f); + + private ScheduledDelegate? resetMissBarDelegate; + + private readonly List missBarVertices = new List(); + private readonly List healthBarVertices = new List(); + + private double glowBarValue = 1; + + public double GlowBarValue + { + get => glowBarValue; + set + { + if (glowBarValue == value) + return; + + glowBarValue = value; + updatePathVertices(); + } + } + + private double healthBarValue = 1; + + public double HealthBarValue + { + get => healthBarValue; + set + { + if (healthBarValue == value) + return; + + healthBarValue = value; + updatePathVertices(); + } + } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4f, 0f), + Children = new Drawable[] + { + new Circle + { + Margin = new MarginPadding { Top = 8.5f, Left = -2 }, + Size = new Vector2(50f, 3f), + }, + new Container + { + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + background = new BackgroundPath + { + PathRadius = 10f, + }, + glowBar = new BarPath + { + BarColour = Color4.White, + GlowColour = OsuColour.Gray(0.5f), + Blending = BlendingParameters.Additive, + Colour = ColourInfo.GradientHorizontal(Color4.White.Opacity(0.8f), Color4.White), + PathRadius = 40f, + // Kinda hacky, but results in correct positioning with increased path radius. + Margin = new MarginPadding(-30f), + GlowPortion = 0.9f, + }, + mainBar = new BarPath + { + AutoSizeAxes = Axes.None, + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + BarColour = main_bar_colour, + GlowColour = main_bar_glow_colour, + PathRadius = 10f, + GlowPortion = 0.6f, + }, + } + } + }, + }; + + updatePath(); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(v => + { + if (v.NewValue >= GlowBarValue) + finishMissDisplay(); + + this.TransformTo(nameof(HealthBarValue), v.NewValue, 300, Easing.OutQuint); + if (resetMissBarDelegate == null) + this.TransformTo(nameof(GlowBarValue), v.NewValue, 300, Easing.OutQuint); + }, true); + } + + protected override void Update() + { + base.Update(); + + mainBar.Alpha = (float)Interpolation.DampContinuously(mainBar.Alpha, Current.Value > 0 ? 1 : 0, 40, Time.Elapsed); + glowBar.Alpha = (float)Interpolation.DampContinuously(glowBar.Alpha, GlowBarValue > 0 ? 1 : 0, 40, Time.Elapsed); + } + + protected override void Flash(JudgementResult result) + { + base.Flash(result); + + mainBar.TransformTo(nameof(BarPath.GlowColour), main_bar_glow_colour.Opacity(0.8f)) + .TransformTo(nameof(BarPath.GlowColour), main_bar_glow_colour, 300, Easing.OutQuint); + + if (resetMissBarDelegate == null) + { + glowBar.TransformTo(nameof(BarPath.BarColour), Colour4.White, 100, Easing.OutQuint) + .Then() + .TransformTo(nameof(BarPath.BarColour), main_bar_colour, 800, Easing.OutQuint); + + glowBar.TransformTo(nameof(BarPath.GlowColour), Colour4.White) + .TransformTo(nameof(BarPath.GlowColour), main_bar_glow_colour, 800, Easing.OutQuint); + } + } + + protected override void Miss(JudgementResult result) + { + base.Miss(result); + + if (resetMissBarDelegate != null) + { + resetMissBarDelegate.Cancel(); + resetMissBarDelegate = null; + } + else + { + // Reset any ongoing animation immediately, else things get weird. + this.TransformTo(nameof(GlowBarValue), HealthBarValue); + } + + this.Delay(500).Schedule(() => + { + this.TransformTo(nameof(GlowBarValue), Current.Value, 300, Easing.OutQuint); + finishMissDisplay(); + }, out resetMissBarDelegate); + + glowBar.TransformTo(nameof(BarPath.BarColour), new Colour4(255, 147, 147, 255), 100, Easing.OutQuint).Then() + .TransformTo(nameof(BarPath.BarColour), new Colour4(255, 93, 93, 255), 800, Easing.OutQuint); + + glowBar.TransformTo(nameof(BarPath.GlowColour), new Colour4(253, 0, 0, 255).Lighten(0.2f)) + .TransformTo(nameof(BarPath.GlowColour), new Colour4(253, 0, 0, 255), 800, Easing.OutQuint); + } + + private void finishMissDisplay() + { + if (Current.Value > 0) + { + glowBar.TransformTo(nameof(BarPath.BarColour), main_bar_colour, 300, Easing.In); + glowBar.TransformTo(nameof(BarPath.GlowColour), main_bar_glow_colour, 300, Easing.In); + } + + resetMissBarDelegate?.Cancel(); + resetMissBarDelegate = null; + } + + private void updatePath() + { + const float curve_start = 280; + const float curve_end = 310; + const float curve_smoothness = 10; + + const float bar_length = 350; + const float bar_verticality = 32.5f; + + Vector2 diagonalDir = (new Vector2(curve_end, bar_verticality) - new Vector2(curve_start, 0)).Normalized(); + + barPath = new SliderPath(new[] + { + new PathControlPoint(new Vector2(0, 0), PathType.Linear), + new PathControlPoint(new Vector2(curve_start - curve_smoothness, 0), PathType.Bezier), + new PathControlPoint(new Vector2(curve_start, 0)), + new PathControlPoint(new Vector2(curve_start, 0) + diagonalDir * curve_smoothness, PathType.Linear), + new PathControlPoint(new Vector2(curve_end, bar_verticality) - diagonalDir * curve_smoothness, PathType.Bezier), + new PathControlPoint(new Vector2(curve_end, bar_verticality)), + new PathControlPoint(new Vector2(curve_end + curve_smoothness, bar_verticality), PathType.Linear), + new PathControlPoint(new Vector2(bar_length, bar_verticality)), + }); + + List vertices = new List(); + barPath.GetPathToProgress(vertices, 0.0, 1.0); + + background.Vertices = vertices; + mainBar.Vertices = vertices; + glowBar.Vertices = vertices; + + updatePathVertices(); + } + + private void updatePathVertices() + { + barPath.GetPathToProgress(healthBarVertices, 0.0, healthBarValue); + barPath.GetPathToProgress(missBarVertices, healthBarValue, Math.Max(glowBarValue, healthBarValue)); + + if (healthBarVertices.Count == 0) + healthBarVertices.Add(Vector2.Zero); + + if (missBarVertices.Count == 0) + missBarVertices.Add(Vector2.Zero); + + glowBar.Vertices = missBarVertices.Select(v => v - missBarVertices[0]).ToList(); + glowBar.Position = missBarVertices[0]; + + mainBar.Vertices = healthBarVertices.Select(v => v - healthBarVertices[0]).ToList(); + mainBar.Position = healthBarVertices[0]; + } + + private partial class BackgroundPath : SmoothPath + { + protected override Color4 ColourAt(float position) + { + if (position <= 0.128f) + return Color4.White.Opacity(0.8f); + + return Interpolation.ValueAt(position, + Color4.White.Opacity(0.8f), + Color4.Black.Opacity(0.2f), + -0.5f, 1f, Easing.OutQuint); + } + } + + private partial class BarPath : SmoothPath + { + private Colour4 barColour; + + public Colour4 BarColour + { + get => barColour; + set + { + if (barColour == value) + return; + + barColour = value; + InvalidateTexture(); + } + } + + private Colour4 glowColour; + + public Colour4 GlowColour + { + get => glowColour; + set + { + if (glowColour == value) + return; + + glowColour = value; + InvalidateTexture(); + } + } + + public float GlowPortion { get; init; } + + protected override Color4 ColourAt(float position) + { + if (position >= GlowPortion) + return BarColour; + + return Interpolation.ValueAt(position, Colour4.Black.Opacity(0.0f), GlowColour, 0.0, GlowPortion, Easing.InQuint); + } + } + } +} diff --git a/osu.Game/Screens/Play/HUD/HealthDisplay.cs b/osu.Game/Screens/Play/HUD/HealthDisplay.cs index 9fdd735804..5131f93ca2 100644 --- a/osu.Game/Screens/Play/HUD/HealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/HealthDisplay.cs @@ -29,10 +29,22 @@ namespace osu.Game.Screens.Play.HUD MaxValue = 1 }; + /// + /// Triggered when a is a successful hit, signaling the health display to perform a flash animation (if designed to do so). + /// + /// The judgement result. protected virtual void Flash(JudgementResult result) { } + /// + /// Triggered when a resulted in the player losing health. + /// + /// The judgement result. + protected virtual void Miss(JudgementResult result) + { + } + [Resolved] private HUDOverlay? hudOverlay { get; set; } @@ -54,6 +66,8 @@ namespace osu.Game.Screens.Play.HUD { if (judgement.IsHit && judgement.Type != HitResult.IgnoreHit) Flash(judgement); + else if (judgement.Judgement.HealthIncreaseFor(judgement) < 0) + Miss(judgement); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 58e18d3187..0ee922e53a 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -37,7 +37,7 @@ - +