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/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/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/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/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/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 @@ - +