diff --git a/osu.Android.props b/osu.Android.props index 6cbb4b2e68..d701aaf199 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -52,6 +52,6 @@ - + diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-0.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-0.png new file mode 100644 index 0000000000..8304617d8c Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-0.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-1.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-1.png new file mode 100644 index 0000000000..c3b85eb873 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-1.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-2.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-2.png new file mode 100644 index 0000000000..7f65eb7ca7 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-2.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-3.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-3.png new file mode 100644 index 0000000000..82bec3babe Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-3.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-4.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-4.png new file mode 100644 index 0000000000..5e38c75a9d Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-4.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-5.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-5.png new file mode 100644 index 0000000000..a562d9f2ac Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-5.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-6.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-6.png new file mode 100644 index 0000000000..b4cf81f26e Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-6.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-7.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-7.png new file mode 100644 index 0000000000..a23f5379b2 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-7.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-8.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-8.png new file mode 100644 index 0000000000..430b18509d Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-8.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-9.png b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-9.png new file mode 100644 index 0000000000..add1202c31 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/old-skin/score-9.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-0@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-0@2x.png new file mode 100644 index 0000000000..508cc85e4a Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-0@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-1@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-1@2x.png new file mode 100644 index 0000000000..84f74e1ec9 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-1@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-2@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-2@2x.png new file mode 100644 index 0000000000..49625c6623 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-2@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-3@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-3@2x.png new file mode 100644 index 0000000000..623b24612f Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-3@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-4@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-4@2x.png new file mode 100644 index 0000000000..a33286dc8f Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-4@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-5@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-5@2x.png new file mode 100644 index 0000000000..d8250b0c63 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-5@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-6@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-6@2x.png new file mode 100644 index 0000000000..75d3cbd3bd Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-6@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-7@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-7@2x.png new file mode 100644 index 0000000000..cfe2021df4 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-7@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-8@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-8@2x.png new file mode 100644 index 0000000000..ba9492c7f8 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-8@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-9@2x.png b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-9@2x.png new file mode 100644 index 0000000000..a7b6b81570 Binary files /dev/null and b/osu.Game.Rulesets.Catch.Tests/Resources/special-skin/score-9@2x.png differ diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs index b4f123598b..e055f08dc2 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs @@ -87,7 +87,7 @@ namespace osu.Game.Rulesets.Catch.Tests Schedule(() => { area.AttemptCatch(fruit); - area.OnResult(drawable, new JudgementResult(fruit, new CatchJudgement()) { Type = miss ? HitResult.Miss : HitResult.Great }); + area.OnNewResult(drawable, new JudgementResult(fruit, new CatchJudgement()) { Type = miss ? HitResult.Miss : HitResult.Great }); drawable.Expire(); }); diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs new file mode 100644 index 0000000000..e79792e04a --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneComboCounter.cs @@ -0,0 +1,65 @@ +// 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 NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Utils; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Tests +{ + public class TestSceneComboCounter : CatchSkinnableTestScene + { + private ScoreProcessor scoreProcessor; + + private Color4 judgedObjectColour = Color4.White; + + [SetUp] + public void SetUp() => Schedule(() => + { + scoreProcessor = new ScoreProcessor(); + + SetContents(() => new CatchComboDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(2.5f), + }); + }); + + [Test] + public void TestCatchComboCounter() + { + AddRepeatStep("perform hit", () => performJudgement(HitResult.Perfect), 20); + AddStep("perform miss", () => performJudgement(HitResult.Miss)); + + AddStep("randomize judged object colour", () => + { + judgedObjectColour = new Color4( + RNG.NextSingle(1f), + RNG.NextSingle(1f), + RNG.NextSingle(1f), + 1f + ); + }); + } + + private void performJudgement(HitResult type, Judgement judgement = null) + { + var judgedObject = new DrawableFruit(new Fruit()) { AccentColour = { Value = judgedObjectColour } }; + + var result = new JudgementResult(judgedObject.HitObject, judgement ?? new Judgement()) { Type = type }; + scoreProcessor.ApplyResult(result); + + foreach (var counter in CreatedDrawables.Cast()) + counter.OnNewResult(judgedObject, result); + } + } +} diff --git a/osu.Game.Rulesets.Catch/CatchSkinComponents.cs b/osu.Game.Rulesets.Catch/CatchSkinComponents.cs index 80390705fe..23d8428fec 100644 --- a/osu.Game.Rulesets.Catch/CatchSkinComponents.cs +++ b/osu.Game.Rulesets.Catch/CatchSkinComponents.cs @@ -13,6 +13,7 @@ namespace osu.Game.Rulesets.Catch Droplet, CatcherIdle, CatcherFail, - CatcherKiai + CatcherKiai, + CatchComboCounter } } diff --git a/osu.Game.Rulesets.Catch/Skinning/CatchLegacySkinTransformer.cs b/osu.Game.Rulesets.Catch/Skinning/CatchLegacySkinTransformer.cs index ea2f031d65..47224bd195 100644 --- a/osu.Game.Rulesets.Catch/Skinning/CatchLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Catch/Skinning/CatchLegacySkinTransformer.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Game.Skinning; using osuTK; using osuTK.Graphics; +using static osu.Game.Skinning.LegacySkinConfiguration; namespace osu.Game.Rulesets.Catch.Skinning { @@ -52,6 +53,15 @@ namespace osu.Game.Rulesets.Catch.Skinning case CatchSkinComponents.CatcherKiai: return this.GetAnimation("fruit-catcher-kiai", true, true, true) ?? this.GetAnimation("fruit-ryuuta", true, true, true); + + case CatchSkinComponents.CatchComboCounter: + var comboFont = GetConfig(LegacySetting.ComboPrefix)?.Value ?? "score"; + + // For simplicity, let's use legacy combo font texture existence as a way to identify legacy skins from default. + if (this.HasFont(comboFont)) + return new LegacyComboCounter(Source); + + break; } return null; diff --git a/osu.Game.Rulesets.Catch/Skinning/LegacyComboCounter.cs b/osu.Game.Rulesets.Catch/Skinning/LegacyComboCounter.cs new file mode 100644 index 0000000000..c8abc9e832 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Skinning/LegacyComboCounter.cs @@ -0,0 +1,103 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Skinning; +using osuTK; +using osuTK.Graphics; +using static osu.Game.Skinning.LegacySkinConfiguration; + +namespace osu.Game.Rulesets.Catch.Skinning +{ + /// + /// A combo counter implementation that visually behaves almost similar to stable's osu!catch combo counter. + /// + public class LegacyComboCounter : CompositeDrawable, ICatchComboCounter + { + private readonly LegacyRollingCounter counter; + + private readonly LegacyRollingCounter explosion; + + public LegacyComboCounter(ISkin skin) + { + var fontName = skin.GetConfig(LegacySetting.ComboPrefix)?.Value ?? "score"; + var fontOverlap = skin.GetConfig(LegacySetting.ComboOverlap)?.Value ?? -2f; + + AutoSizeAxes = Axes.Both; + + Alpha = 0f; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + Scale = new Vector2(0.8f); + + InternalChildren = new Drawable[] + { + explosion = new LegacyRollingCounter(skin, fontName, fontOverlap) + { + Alpha = 0.65f, + Blending = BlendingParameters.Additive, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(1.5f), + }, + counter = new LegacyRollingCounter(skin, fontName, fontOverlap) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + }; + } + + private int lastDisplayedCombo; + + public void UpdateCombo(int combo, Color4? hitObjectColour = null) + { + if (combo == lastDisplayedCombo) + return; + + // There may still be existing transforms to the counter (including value change after 250ms), + // finish them immediately before new transforms. + counter.SetCountWithoutRolling(lastDisplayedCombo); + + lastDisplayedCombo = combo; + + if (Time.Elapsed < 0) + { + // needs more work to make rewind somehow look good. + // basically we want the previous increment to play... or turning off RemoveCompletedTransforms (not feasible from a performance angle). + Hide(); + return; + } + + // Combo fell to zero, roll down and fade out the counter. + if (combo == 0) + { + counter.Current.Value = 0; + explosion.Current.Value = 0; + + this.FadeOut(400, Easing.Out); + } + else + { + this.FadeInFromZero().Then().Delay(1000).FadeOut(300); + + counter.ScaleTo(1.5f) + .ScaleTo(0.8f, 250, Easing.Out) + .OnComplete(c => c.SetCountWithoutRolling(combo)); + + counter.Delay(250) + .ScaleTo(1f) + .ScaleTo(1.1f, 60).Then().ScaleTo(1f, 30); + + explosion.Colour = hitObjectColour ?? Color4.White; + + explosion.SetCountWithoutRolling(combo); + explosion.ScaleTo(1.5f) + .ScaleTo(1.9f, 400, Easing.Out) + .FadeOutFromOne(400); + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs b/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs new file mode 100644 index 0000000000..58a3140bb5 --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/CatchComboDisplay.cs @@ -0,0 +1,62 @@ +// 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.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.UI +{ + /// + /// Represents a component that displays a skinned and handles combo judgement results for updating it accordingly. + /// + public class CatchComboDisplay : SkinnableDrawable + { + private int currentCombo; + + [CanBeNull] + public ICatchComboCounter ComboCounter => Drawable as ICatchComboCounter; + + public CatchComboDisplay() + : base(new CatchSkinComponent(CatchSkinComponents.CatchComboCounter), _ => Empty()) + { + } + + protected override void SkinChanged(ISkinSource skin, bool allowFallback) + { + base.SkinChanged(skin, allowFallback); + ComboCounter?.UpdateCombo(currentCombo); + } + + public void OnNewResult(DrawableCatchHitObject judgedObject, JudgementResult result) + { + if (!result.Judgement.AffectsCombo || !result.HasResult) + return; + + if (result.Type == HitResult.Miss) + { + updateCombo(0, null); + return; + } + + updateCombo(result.ComboAtJudgement + 1, judgedObject.AccentColour.Value); + } + + public void OnRevertResult(DrawableCatchHitObject judgedObject, JudgementResult result) + { + if (!result.Judgement.AffectsCombo || !result.HasResult) + return; + + updateCombo(result.ComboAtJudgement, judgedObject.AccentColour.Value); + } + + private void updateCombo(int newCombo, Color4? hitObjectColour) + { + currentCombo = newCombo; + ComboCounter?.UpdateCombo(newCombo, hitObjectColour); + } + } +} diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs index 154e1576db..735d7fc300 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs @@ -53,7 +53,7 @@ namespace osu.Game.Rulesets.Catch.UI explodingFruitContainer, CatcherArea.MovableCatcher.CreateProxiedContent(), HitObjectContainer, - CatcherArea + CatcherArea, }; } @@ -62,6 +62,7 @@ namespace osu.Game.Rulesets.Catch.UI public override void Add(DrawableHitObject h) { h.OnNewResult += onNewResult; + h.OnRevertResult += onRevertResult; base.Add(h); @@ -70,6 +71,9 @@ namespace osu.Game.Rulesets.Catch.UI } private void onNewResult(DrawableHitObject judgedObject, JudgementResult result) - => CatcherArea.OnResult((DrawableCatchHitObject)judgedObject, result); + => CatcherArea.OnNewResult((DrawableCatchHitObject)judgedObject, result); + + private void onRevertResult(DrawableHitObject judgedObject, JudgementResult result) + => CatcherArea.OnRevertResult((DrawableCatchHitObject)judgedObject, result); } } diff --git a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs index 03ebf01b9b..d3e63b0333 100644 --- a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs +++ b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs @@ -23,6 +23,7 @@ namespace osu.Game.Rulesets.Catch.UI public Func> CreateDrawableRepresentation; public readonly Catcher MovableCatcher; + private readonly CatchComboDisplay comboDisplay; public Container ExplodingFruitTarget { @@ -34,10 +35,22 @@ namespace osu.Game.Rulesets.Catch.UI public CatcherArea(BeatmapDifficulty difficulty = null) { Size = new Vector2(CatchPlayfield.WIDTH, CATCHER_SIZE); - Child = MovableCatcher = new Catcher(this, difficulty) { X = CatchPlayfield.CENTER_X }; + Children = new Drawable[] + { + comboDisplay = new CatchComboDisplay + { + RelativeSizeAxes = Axes.None, + AutoSizeAxes = Axes.Both, + Anchor = Anchor.TopLeft, + Origin = Anchor.Centre, + Margin = new MarginPadding { Bottom = 350f }, + X = CatchPlayfield.CENTER_X + }, + MovableCatcher = new Catcher(this, difficulty) { X = CatchPlayfield.CENTER_X }, + }; } - public void OnResult(DrawableCatchHitObject fruit, JudgementResult result) + public void OnNewResult(DrawableCatchHitObject fruit, JudgementResult result) { if (result.Judgement is IgnoreJudgement) return; @@ -86,8 +99,13 @@ namespace osu.Game.Rulesets.Catch.UI else MovableCatcher.Drop(); } + + comboDisplay.OnNewResult(fruit, result); } + public void OnRevertResult(DrawableCatchHitObject fruit, JudgementResult result) + => comboDisplay.OnRevertResult(fruit, result); + public void OnReleased(CatchAction action) { } @@ -105,6 +123,8 @@ namespace osu.Game.Rulesets.Catch.UI if (state?.CatcherX != null) MovableCatcher.X = state.CatcherX.Value; + + comboDisplay.X = MovableCatcher.X; } } } diff --git a/osu.Game.Rulesets.Catch/UI/ICatchComboCounter.cs b/osu.Game.Rulesets.Catch/UI/ICatchComboCounter.cs new file mode 100644 index 0000000000..cfb6879067 --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/ICatchComboCounter.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; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.UI +{ + /// + /// An interface providing a set of methods to update the combo counter. + /// + public interface ICatchComboCounter : IDrawable + { + /// + /// Updates the counter to animate a transition from the old combo value it had to the current provided one. + /// + /// + /// This is called regardless of whether the clock is rewinding. + /// + /// The new combo value. + /// The colour of the object if hit, null on miss. + void UpdateCombo(int combo, Color4? hitObjectColour = null); + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/Components/SpinnerPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/Components/SpinnerPiece.cs index 65c8720031..2347d8a34c 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/Components/SpinnerPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/Components/SpinnerPiece.cs @@ -34,11 +34,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners.Components Alpha = 0.5f, Child = new Box { RelativeSizeAxes = Axes.Both } }, - ring = new RingPiece - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre - } + ring = new RingPiece() }; } diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index f87bd53ec3..e1cbfa93f6 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -46,14 +46,24 @@ namespace osu.Game.Rulesets.Osu.Edit distanceSnapToggle }; + private BindableList selectedHitObjects; + + private Bindable placementObject; + [BackgroundDependencyLoader] private void load() { LayerBelowRuleset.Add(distanceSnapGridContainer = new Container { RelativeSizeAxes = Axes.Both }); - EditorBeatmap.SelectedHitObjects.CollectionChanged += (_, __) => updateDistanceSnapGrid(); - EditorBeatmap.PlacementObject.ValueChanged += _ => updateDistanceSnapGrid(); + selectedHitObjects = EditorBeatmap.SelectedHitObjects.GetBoundCopy(); + selectedHitObjects.CollectionChanged += (_, __) => updateDistanceSnapGrid(); + + placementObject = EditorBeatmap.PlacementObject.GetBoundCopy(); + placementObject.ValueChanged += _ => updateDistanceSnapGrid(); distanceSnapToggle.ValueChanged += _ => updateDistanceSnapGrid(); + + // we may be entering the screen with a selection already active + updateDistanceSnapGrid(); } protected override ComposeBlueprintContainer CreateBlueprintContainer(IEnumerable hitObjects) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs index 4d73e711bb..11571ea761 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Connections/FollowPointRenderer.cs @@ -46,7 +46,20 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Connections private void addConnection(FollowPointConnection connection) { // Groups are sorted by their start time when added such that the index can be used to post-process other surrounding connections - int index = connections.AddInPlace(connection, Comparer.Create((g1, g2) => g1.StartTime.Value.CompareTo(g2.StartTime.Value))); + int index = connections.AddInPlace(connection, Comparer.Create((g1, g2) => + { + int comp = g1.StartTime.Value.CompareTo(g2.StartTime.Value); + + if (comp != 0) + return comp; + + // we always want to insert the new item after equal ones. + // this is important for beatmaps with multiple hitobjects at the same point in time. + // if we use standard comparison insert order, there will be a churn of connections getting re-updated to + // the next object at the point-in-time, adding a construction/disposal overhead (see FollowPointConnection.End implementation's ClearInternal). + // this is easily visible on https://osu.ppy.sh/beatmapsets/150945#osu/372245 + return -1; + })); if (index < connections.Count - 1) { diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/RingPiece.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/RingPiece.cs index 82e4383143..bcf64b81a6 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/RingPiece.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/Pieces/RingPiece.cs @@ -9,7 +9,7 @@ using osu.Framework.Graphics.Shapes; namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces { - public class RingPiece : Container + public class RingPiece : CircularContainer { public RingPiece() { @@ -18,21 +18,15 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables.Pieces Anchor = Anchor.Centre; Origin = Anchor.Centre; - InternalChild = new CircularContainer + Masking = true; + BorderThickness = 10; + BorderColour = Color4.White; + + Child = new Box { - Masking = true, - BorderThickness = 10, - BorderColour = Color4.White, - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - AlwaysPresent = true, - Alpha = 0, - RelativeSizeAxes = Axes.Both - } - } + AlwaysPresent = true, + Alpha = 0, + RelativeSizeAxes = Axes.Both }; } } diff --git a/osu.Game.Rulesets.Osu/Skinning/OsuLegacySkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/OsuLegacySkinTransformer.cs index 81d1d05b66..851a8d56c9 100644 --- a/osu.Game.Rulesets.Osu/Skinning/OsuLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/OsuLegacySkinTransformer.cs @@ -94,7 +94,7 @@ namespace osu.Game.Rulesets.Osu.Skinning var font = GetConfig(OsuSkinConfiguration.HitCirclePrefix)?.Value ?? "default"; var overlap = GetConfig(OsuSkinConfiguration.HitCircleOverlap)?.Value ?? -2; - return !hasFont(font) + return !this.HasFont(font) ? null : new LegacySpriteText(Source, font) { @@ -145,7 +145,5 @@ namespace osu.Game.Rulesets.Osu.Skinning return Source.GetConfig(lookup); } - - private bool hasFont(string fontName) => Source.GetTexture($"{fontName}-0") != null; } } diff --git a/osu.Game.Rulesets.Taiko.Tests/DrawableTestStrongHit.cs b/osu.Game.Rulesets.Taiko.Tests/DrawableTestStrongHit.cs new file mode 100644 index 0000000000..7cb984b254 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/DrawableTestStrongHit.cs @@ -0,0 +1,44 @@ +// 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 osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + public class DrawableTestStrongHit : DrawableHit + { + private readonly HitResult type; + private readonly bool hitBoth; + + public DrawableTestStrongHit(double startTime, HitResult type = HitResult.Great, bool hitBoth = true) + : base(new Hit + { + IsStrong = true, + StartTime = startTime, + }) + { + // in order to create nested strong hit + HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + this.type = type; + this.hitBoth = hitBoth; + } + + protected override void LoadAsyncComplete() + { + base.LoadAsyncComplete(); + + Result.Type = type; + + var nestedStrongHit = (DrawableStrongNestedHit)NestedHitObjects.Single(); + nestedStrongHit.Result.Type = hitBoth ? type : HitResult.Miss; + } + + public override bool OnPressed(TaikoAction action) => false; + } +} diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.cs index 2b5efec7f9..48969e0f5a 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneHitExplosion.cs @@ -2,7 +2,6 @@ // 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.Containers; using osu.Game.Rulesets.Scoring; @@ -15,24 +14,29 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning [TestFixture] public class TestSceneHitExplosion : TaikoSkinnableTestScene { - [BackgroundDependencyLoader] - private void load() + [Test] + public void TestNormalHit() { - AddStep("Great", () => SetContents(() => getContentFor(HitResult.Great))); - AddStep("Good", () => SetContents(() => getContentFor(HitResult.Good))); - AddStep("Miss", () => SetContents(() => getContentFor(HitResult.Miss))); + AddStep("Great", () => SetContents(() => getContentFor(createHit(HitResult.Great)))); + AddStep("Good", () => SetContents(() => getContentFor(createHit(HitResult.Good)))); + AddStep("Miss", () => SetContents(() => getContentFor(createHit(HitResult.Miss)))); } - private Drawable getContentFor(HitResult type) + [Test] + public void TestStrongHit([Values(false, true)] bool hitBoth) { - DrawableTaikoHitObject hit; + AddStep("Great", () => SetContents(() => getContentFor(createStrongHit(HitResult.Great, hitBoth)))); + AddStep("Good", () => SetContents(() => getContentFor(createStrongHit(HitResult.Good, hitBoth)))); + } + private Drawable getContentFor(DrawableTaikoHitObject hit) + { return new Container { RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - hit = createHit(type), + hit, new HitExplosion(hit) { Anchor = Anchor.Centre, @@ -43,5 +47,8 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning } private DrawableTaikoHitObject createHit(HitResult type) => new DrawableTestHit(new Hit { StartTime = Time.Current }, type); + + private DrawableTaikoHitObject createStrongHit(HitResult type, bool hitBoth) + => new DrawableTestStrongHit(Time.Current, type, hitBoth); } } diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs index 44452d70c1..99d1b72ea4 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneHits.cs @@ -174,7 +174,9 @@ namespace osu.Game.Rulesets.Taiko.Tests private void addMissJudgement() { - ((TaikoPlayfield)drawableRuleset.Playfield).OnNewResult(new DrawableTestHit(new Hit()), new JudgementResult(new HitObject(), new TaikoJudgement()) { Type = HitResult.Miss }); + DrawableTestHit h; + Add(h = new DrawableTestHit(new Hit(), HitResult.Miss)); + ((TaikoPlayfield)drawableRuleset.Playfield).OnNewResult(h, new JudgementResult(new HitObject(), new TaikoJudgement()) { Type = HitResult.Miss }); } private void addBarLine(bool major, double delay = scroll_time) diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs index eebf6980fe..40565048c2 100644 --- a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs @@ -22,6 +22,8 @@ namespace osu.Game.Rulesets.Taiko.Edit yield return new TernaryStateMenuItem("Rim", action: state => { + ChangeHandler.BeginChange(); + foreach (var h in hits) { switch (state) @@ -35,6 +37,8 @@ namespace osu.Game.Rulesets.Taiko.Edit break; } } + + ChangeHandler.EndChange(); }) { State = { Value = getTernaryState(hits, h => h.Type == HitType.Rim) } @@ -47,6 +51,8 @@ namespace osu.Game.Rulesets.Taiko.Edit yield return new TernaryStateMenuItem("Strong", action: state => { + ChangeHandler.BeginChange(); + foreach (var h in hits) { switch (state) @@ -62,6 +68,8 @@ namespace osu.Game.Rulesets.Taiko.Edit EditorBeatmap?.UpdateHitObject(h); } + + ChangeHandler.EndChange(); }) { State = { Value = getTernaryState(hits, h => h.IsStrong) } diff --git a/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacySkinTransformer.cs b/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacySkinTransformer.cs index f032c5f485..c222ccb51f 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/TaikoLegacySkinTransformer.cs @@ -75,7 +75,9 @@ namespace osu.Game.Rulesets.Taiko.Skinning return null; case TaikoSkinComponents.TaikoExplosionGood: + case TaikoSkinComponents.TaikoExplosionGoodStrong: case TaikoSkinComponents.TaikoExplosionGreat: + case TaikoSkinComponents.TaikoExplosionGreatStrong: case TaikoSkinComponents.TaikoExplosionMiss: var sprite = this.GetAnimation(getHitName(taikoComponent.Component), true, false); @@ -107,8 +109,14 @@ namespace osu.Game.Rulesets.Taiko.Skinning case TaikoSkinComponents.TaikoExplosionGood: return "taiko-hit100"; + case TaikoSkinComponents.TaikoExplosionGoodStrong: + return "taiko-hit100k"; + case TaikoSkinComponents.TaikoExplosionGreat: return "taiko-hit300"; + + case TaikoSkinComponents.TaikoExplosionGreatStrong: + return "taiko-hit300k"; } throw new ArgumentOutOfRangeException(nameof(component), "Invalid result type"); diff --git a/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs b/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs index ac4fb51661..0d785adb4a 100644 --- a/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs +++ b/osu.Game.Rulesets.Taiko/TaikoSkinComponents.cs @@ -17,7 +17,9 @@ namespace osu.Game.Rulesets.Taiko BarLine, TaikoExplosionMiss, TaikoExplosionGood, + TaikoExplosionGoodStrong, TaikoExplosionGreat, + TaikoExplosionGreatStrong, Scroller, Mascot, } diff --git a/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs b/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs index f0585b9c50..e3eabbf88f 100644 --- a/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs +++ b/osu.Game.Rulesets.Taiko/UI/HitExplosion.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using osuTK; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -9,6 +10,7 @@ using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; using osu.Game.Skinning; namespace osu.Game.Rulesets.Taiko.UI @@ -45,24 +47,41 @@ namespace osu.Game.Rulesets.Taiko.UI [BackgroundDependencyLoader] private void load() { - Child = skinnable = new SkinnableDrawable(new TaikoSkinComponent(getComponentName(JudgedObject.Result?.Type ?? HitResult.Great)), _ => new DefaultHitExplosion()); + Child = skinnable = new SkinnableDrawable(new TaikoSkinComponent(getComponentName(JudgedObject)), _ => new DefaultHitExplosion()); } - private TaikoSkinComponents getComponentName(HitResult resultType) + private TaikoSkinComponents getComponentName(DrawableHitObject judgedObject) { + var resultType = judgedObject.Result?.Type ?? HitResult.Great; + switch (resultType) { case HitResult.Miss: return TaikoSkinComponents.TaikoExplosionMiss; case HitResult.Good: - return TaikoSkinComponents.TaikoExplosionGood; + return useStrongExplosion(judgedObject) + ? TaikoSkinComponents.TaikoExplosionGoodStrong + : TaikoSkinComponents.TaikoExplosionGood; case HitResult.Great: - return TaikoSkinComponents.TaikoExplosionGreat; + return useStrongExplosion(judgedObject) + ? TaikoSkinComponents.TaikoExplosionGreatStrong + : TaikoSkinComponents.TaikoExplosionGreat; } - throw new ArgumentOutOfRangeException(nameof(resultType), "Invalid result type"); + throw new ArgumentOutOfRangeException(nameof(judgedObject), "Invalid result type"); + } + + private bool useStrongExplosion(DrawableHitObject judgedObject) + { + if (!(judgedObject.HitObject is Hit)) + return false; + + if (!(judgedObject.NestedHitObjects.SingleOrDefault() is DrawableStrongNestedHit nestedHit)) + return false; + + return judgedObject.Result.Type == nestedHit.Result.Type; } /// diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs index dabdfe6f44..7976d5bc6d 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs @@ -205,9 +205,6 @@ namespace osu.Game.Rulesets.Taiko.UI X = result.IsHit ? judgedObject.Position.X : 0, }); - if (!result.IsHit) - break; - var type = (judgedObject.HitObject as Hit)?.Type ?? HitType.Centre; addExplosion(judgedObject, type); @@ -218,12 +215,16 @@ namespace osu.Game.Rulesets.Taiko.UI private void addDrumRollHit(DrawableDrumRollTick drawableTick) => drumRollHitContainer.Add(new DrawableFlyingHit(drawableTick)); - private void addExplosion(DrawableHitObject drawableObject, HitType type) + /// + /// As legacy skins have different explosions for singular and double strong hits, + /// explosion addition is scheduled to ensure that both hits are processed if they occur on the same frame. + /// + private void addExplosion(DrawableHitObject drawableObject, HitType type) => Schedule(() => { hitExplosionContainer.Add(new HitExplosion(drawableObject)); if (drawableObject.HitObject.Kiai) kiaiExplosionContainer.Add(new KiaiHitExplosion(drawableObject, type)); - } + }); private class ProxyContainer : LifetimeManagementContainer { diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs index 2d5e4b911e..58cc324233 100644 --- a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs +++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs @@ -81,7 +81,7 @@ namespace osu.Game.Tests.Gameplay private class TestHitObjectWithCombo : ConvertHitObject, IHasComboInformation { - public bool NewCombo { get; } = false; + public bool NewCombo { get; set; } = false; public int ComboOffset { get; } = 0; public Bindable IndexInCurrentComboBindable { get; } = new Bindable(); diff --git a/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs b/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs new file mode 100644 index 0000000000..89e3b48aa3 --- /dev/null +++ b/osu.Game.Tests/Rulesets/TestSceneDrawableRulesetDependencies.cs @@ -0,0 +1,134 @@ +// 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.IO; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Textures; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.UI; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.Rulesets +{ + public class TestSceneDrawableRulesetDependencies : OsuTestScene + { + [Test] + public void TestDisposalDoesNotDisposeParentStores() + { + DrawableWithDependencies drawable = null; + TestTextureStore textureStore = null; + TestSampleStore sampleStore = null; + + AddStep("add dependencies", () => + { + Child = drawable = new DrawableWithDependencies(); + textureStore = drawable.ParentTextureStore; + sampleStore = drawable.ParentSampleStore; + }); + + AddStep("clear children", Clear); + AddUntilStep("wait for disposal", () => drawable.IsDisposed); + + AddStep("GC", () => + { + drawable = null; + + GC.Collect(); + GC.WaitForPendingFinalizers(); + }); + + AddAssert("parent texture store not disposed", () => !textureStore.IsDisposed); + AddAssert("parent sample store not disposed", () => !sampleStore.IsDisposed); + } + + private class DrawableWithDependencies : CompositeDrawable + { + public TestTextureStore ParentTextureStore { get; private set; } + public TestSampleStore ParentSampleStore { get; private set; } + + public DrawableWithDependencies() + { + InternalChild = new Box { RelativeSizeAxes = Axes.Both }; + } + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + + dependencies.CacheAs(ParentTextureStore = new TestTextureStore()); + dependencies.CacheAs(ParentSampleStore = new TestSampleStore()); + + return new DrawableRulesetDependencies(new OsuRuleset(), dependencies); + } + + public new bool IsDisposed { get; private set; } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + IsDisposed = true; + } + } + + private class TestTextureStore : TextureStore + { + public override Texture Get(string name, WrapMode wrapModeS, WrapMode wrapModeT) => null; + + public bool IsDisposed { get; private set; } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + IsDisposed = true; + } + } + + private class TestSampleStore : ISampleStore + { + public bool IsDisposed { get; private set; } + + public void Dispose() + { + IsDisposed = true; + } + + public SampleChannel Get(string name) => null; + + public Task GetAsync(string name) => null; + + public Stream GetStream(string name) => null; + + public IEnumerable GetAvailableResources() => throw new NotImplementedException(); + + public BindableNumber Volume => throw new NotImplementedException(); + public BindableNumber Balance => throw new NotImplementedException(); + public BindableNumber Frequency => throw new NotImplementedException(); + public BindableNumber Tempo => throw new NotImplementedException(); + + public void AddAdjustment(AdjustableProperty type, BindableNumber adjustBindable) => throw new NotImplementedException(); + + public void RemoveAdjustment(AdjustableProperty type, BindableNumber adjustBindable) => throw new NotImplementedException(); + + public void RemoveAllAdjustments(AdjustableProperty type) => throw new NotImplementedException(); + + public IBindable AggregateVolume => throw new NotImplementedException(); + public IBindable AggregateBalance => throw new NotImplementedException(); + public IBindable AggregateFrequency => throw new NotImplementedException(); + public IBindable AggregateTempo => throw new NotImplementedException(); + + public int PlaybackConcurrency { get; set; } + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs index e0a1f947ec..ed75d83151 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs @@ -1,12 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Audio; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.OpenGL.Textures; +using osu.Framework.Graphics.Textures; using osu.Framework.Testing; using osu.Framework.Timing; using osu.Game.Audio; @@ -20,6 +25,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Cached] private GameplayClock gameplayClock = new GameplayClock(new FramedClock()); + private TestSkinSourceContainer skinSource; private SkinnableSound skinnableSound; [SetUp] @@ -29,7 +35,7 @@ namespace osu.Game.Tests.Visual.Gameplay Children = new Drawable[] { - new Container + skinSource = new TestSkinSourceContainer { Clock = gameplayClock, RelativeSizeAxes = Axes.Both, @@ -101,5 +107,55 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("sample not playing", () => !sample.Playing); AddAssert("sample not playing", () => !sample.Playing); } + + [Test] + public void TestSkinChangeDoesntPlayOnPause() + { + DrawableSample sample = null; + AddStep("start sample", () => + { + skinnableSound.Play(); + sample = skinnableSound.ChildrenOfType().Single(); + }); + + AddAssert("sample playing", () => sample.Playing); + + AddStep("pause gameplay clock", () => gameplayClock.IsPaused.Value = true); + AddUntilStep("wait for sample to stop playing", () => !sample.Playing); + + AddStep("trigger skin change", () => skinSource.TriggerSourceChanged()); + + AddAssert("retrieve and ensure current sample is different", () => + { + DrawableSample oldSample = sample; + sample = skinnableSound.ChildrenOfType().Single(); + return sample != oldSample; + }); + + AddAssert("new sample stopped", () => !sample.Playing); + AddStep("resume gameplay clock", () => gameplayClock.IsPaused.Value = false); + + AddWaitStep("wait a bit", 5); + AddAssert("new sample not played", () => !sample.Playing); + } + + [Cached(typeof(ISkinSource))] + private class TestSkinSourceContainer : Container, ISkinSource + { + [Resolved] + private ISkinSource source { get; set; } + + public event Action SourceChanged; + + public Drawable GetDrawableComponent(ISkinComponent component) => source?.GetDrawableComponent(component); + public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => source?.GetTexture(componentName, wrapModeS, wrapModeT); + public SampleChannel GetSample(ISampleInfo sampleInfo) => source?.GetSample(sampleInfo); + public IBindable GetConfig(TLookup lookup) => source?.GetConfig(lookup); + + public void TriggerSourceChanged() + { + SourceChanged?.Invoke(); + } + } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs index 08130e60db..c2a18330c9 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneCommentsContainer.cs @@ -68,7 +68,7 @@ namespace osu.Game.Tests.Visual.Online public void TestMultipleLoads() { var comments = exampleComments; - int topLevelCommentCount = exampleComments.Comments.Count(comment => comment.IsTopLevel); + int topLevelCommentCount = exampleComments.Comments.Count; AddStep("hide container", () => commentsContainer.Hide()); setUpCommentsResponse(comments); diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs index 03cb5fa3db..ff96a999ec 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs @@ -220,7 +220,7 @@ namespace osu.Game.Tests.Visual.Ranking AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen())); - AddAssert("download button is disabled", () => !screen.ChildrenOfType().Single().Enabled.Value); + AddAssert("download button is disabled", () => !screen.ChildrenOfType().Last().Enabled.Value); AddStep("click contracted panel", () => { @@ -229,7 +229,7 @@ namespace osu.Game.Tests.Visual.Ranking InputManager.Click(MouseButton.Left); }); - AddAssert("download button is enabled", () => screen.ChildrenOfType().Single().Enabled.Value); + AddAssert("download button is enabled", () => screen.ChildrenOfType().Last().Enabled.Value); } private class TestResultsContainer : Container diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index 45b07581ec..41be4cfcc3 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -21,7 +21,7 @@ namespace osu.Game.Input.Bindings handler = game; } - public override IEnumerable DefaultKeyBindings => GlobalKeyBindings.Concat(InGameKeyBindings).Concat(AudioControlKeyBindings); + public override IEnumerable DefaultKeyBindings => GlobalKeyBindings.Concat(InGameKeyBindings).Concat(AudioControlKeyBindings).Concat(EditorKeyBindings); public IEnumerable GlobalKeyBindings => new[] { @@ -50,6 +50,14 @@ namespace osu.Game.Input.Bindings new KeyBinding(InputKey.KeypadEnter, GlobalAction.Select), }; + public IEnumerable EditorKeyBindings => new[] + { + new KeyBinding(new[] { InputKey.F1 }, GlobalAction.EditorComposeMode), + new KeyBinding(new[] { InputKey.F2 }, GlobalAction.EditorDesignMode), + new KeyBinding(new[] { InputKey.F3 }, GlobalAction.EditorTimingMode), + new KeyBinding(new[] { InputKey.F4 }, GlobalAction.EditorSetupMode), + }; + public IEnumerable InGameKeyBindings => new[] { new KeyBinding(InputKey.Space, GlobalAction.SkipCutscene), @@ -68,7 +76,7 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Alt, InputKey.Down }, GlobalAction.DecreaseVolume), new KeyBinding(new[] { InputKey.Alt, InputKey.MouseWheelDown }, GlobalAction.DecreaseVolume), - new KeyBinding(InputKey.F4, GlobalAction.ToggleMute), + new KeyBinding(new[] { InputKey.Control, InputKey.F4 }, GlobalAction.ToggleMute), new KeyBinding(InputKey.TrackPrevious, GlobalAction.MusicPrev), new KeyBinding(InputKey.F1, GlobalAction.MusicPrev), @@ -139,7 +147,7 @@ namespace osu.Game.Input.Bindings [Description("Quick exit (Hold)")] QuickExit, - // Game-wide beatmap msi ccotolle keybindings + // Game-wide beatmap music controller keybindings [Description("Next track")] MusicNext, @@ -166,5 +174,18 @@ namespace osu.Game.Input.Bindings [Description("Pause")] PauseGameplay, + + // Editor + [Description("Setup Mode")] + EditorSetupMode, + + [Description("Compose Mode")] + EditorComposeMode, + + [Description("Design Mode")] + EditorDesignMode, + + [Description("Timing Mode")] + EditorTimingMode, } } diff --git a/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs b/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs index 5b44c486a3..9a27c55c53 100644 --- a/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs +++ b/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs @@ -22,6 +22,7 @@ namespace osu.Game.Overlays.KeyBinding Add(new DefaultBindingsSubsection(manager)); Add(new AudioControlKeyBindingsSubsection(manager)); Add(new InGameKeyBindingsSubsection(manager)); + Add(new EditorKeyBindingsSubsection(manager)); } private class DefaultBindingsSubsection : KeyBindingsSubsection @@ -56,5 +57,16 @@ namespace osu.Game.Overlays.KeyBinding Defaults = manager.AudioControlKeyBindings; } } + + private class EditorKeyBindingsSubsection : KeyBindingsSubsection + { + protected override string Header => "Editor"; + + public EditorKeyBindingsSubsection(GlobalActionContainer manager) + : base(null) + { + Defaults = manager.EditorKeyBindings; + } + } } } diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index e42a359d2e..b81e0ce159 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -58,6 +58,8 @@ namespace osu.Game.Rulesets.Edit private RadioButtonCollection toolboxCollection; + private ToolboxGroup togglesCollection; + protected HitObjectComposer(Ruleset ruleset) { Ruleset = ruleset; @@ -78,7 +80,7 @@ namespace osu.Game.Rulesets.Edit } catch (Exception e) { - Logger.Error(e, "Could not load beatmap sucessfully!"); + Logger.Error(e, "Could not load beatmap successfully!"); return; } @@ -115,7 +117,7 @@ namespace osu.Game.Rulesets.Edit Children = new Drawable[] { new ToolboxGroup("toolbox") { Child = toolboxCollection = new RadioButtonCollection { RelativeSizeAxes = Axes.X } }, - new ToolboxGroup("toggles") + togglesCollection = new ToolboxGroup("toggles") { ChildrenEnumerable = Toggles.Select(b => new SettingsCheckbox { @@ -190,9 +192,9 @@ namespace osu.Game.Rulesets.Edit protected override bool OnKeyDown(KeyDownEvent e) { - if (e.Key >= Key.Number1 && e.Key <= Key.Number9) + if (checkLeftToggleFromKey(e.Key, out var leftIndex)) { - var item = toolboxCollection.Items.ElementAtOrDefault(e.Key - Key.Number1); + var item = toolboxCollection.Items.ElementAtOrDefault(leftIndex); if (item != null) { @@ -201,9 +203,84 @@ namespace osu.Game.Rulesets.Edit } } + if (checkRightToggleFromKey(e.Key, out var rightIndex)) + { + var item = togglesCollection.Children[rightIndex]; + + if (item is SettingsCheckbox checkbox) + { + checkbox.Bindable.Value = !checkbox.Bindable.Value; + return true; + } + } + return base.OnKeyDown(e); } + private bool checkLeftToggleFromKey(Key key, out int index) + { + if (key < Key.Number1 || key > Key.Number9) + { + index = -1; + return false; + } + + index = key - Key.Number1; + return true; + } + + private bool checkRightToggleFromKey(Key key, out int index) + { + switch (key) + { + case Key.Q: + index = 0; + break; + + case Key.W: + index = 1; + break; + + case Key.E: + index = 2; + break; + + case Key.R: + index = 3; + break; + + case Key.T: + index = 4; + break; + + case Key.Y: + index = 5; + break; + + case Key.U: + index = 6; + break; + + case Key.I: + index = 7; + break; + + case Key.O: + index = 8; + break; + + case Key.P: + index = 9; + break; + + default: + index = -1; + break; + } + + return index >= 0; + } + private void selectionChanged(object sender, NotifyCollectionChangedEventArgs changedArgs) { if (EditorBeatmap.SelectedHitObjects.Any()) diff --git a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs index 4e3de04278..211c077d4f 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs @@ -24,6 +24,11 @@ namespace osu.Game.Rulesets.Objects.Types /// int ComboIndex { get; set; } + /// + /// Whether the HitObject starts a new combo. + /// + new bool NewCombo { get; set; } + Bindable LastInComboBindable { get; } /// diff --git a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs index 83a1077d70..a9b2a15b35 100644 --- a/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs +++ b/osu.Game/Rulesets/UI/DrawableRulesetDependencies.cs @@ -10,6 +10,7 @@ using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Audio.Track; using osu.Framework.Bindables; +using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Game.Rulesets.Configuration; @@ -46,12 +47,11 @@ namespace osu.Game.Rulesets.UI if (resources != null) { TextureStore = new TextureStore(new TextureLoaderStore(new NamespacedResourceStore(resources, @"Textures"))); - TextureStore.AddStore(parent.Get()); - Cache(TextureStore); + CacheAs(TextureStore = new FallbackTextureStore(TextureStore, parent.Get())); SampleStore = parent.Get().GetSampleStore(new NamespacedResourceStore(resources, @"Samples")); SampleStore.PlaybackConcurrency = OsuGameBase.SAMPLE_CONCURRENCY; - CacheAs(new FallbackSampleStore(SampleStore, parent.Get())); + CacheAs(SampleStore = new FallbackSampleStore(SampleStore, parent.Get())); } RulesetConfigManager = parent.Get().GetConfigFor(ruleset); @@ -82,69 +82,92 @@ namespace osu.Game.Rulesets.UI isDisposed = true; SampleStore?.Dispose(); + TextureStore?.Dispose(); RulesetConfigManager = null; } #endregion - } - /// - /// A sample store which adds a fallback source. - /// - /// - /// This is a temporary implementation to workaround ISampleStore limitations. - /// - public class FallbackSampleStore : ISampleStore - { - private readonly ISampleStore primary; - private readonly ISampleStore secondary; - - public FallbackSampleStore(ISampleStore primary, ISampleStore secondary) + /// + /// A sample store which adds a fallback source and prevents disposal of the fallback source. + /// + private class FallbackSampleStore : ISampleStore { - this.primary = primary; - this.secondary = secondary; + private readonly ISampleStore primary; + private readonly ISampleStore fallback; + + public FallbackSampleStore(ISampleStore primary, ISampleStore fallback) + { + this.primary = primary; + this.fallback = fallback; + } + + public SampleChannel Get(string name) => primary.Get(name) ?? fallback.Get(name); + + public Task GetAsync(string name) => primary.GetAsync(name) ?? fallback.GetAsync(name); + + public Stream GetStream(string name) => primary.GetStream(name) ?? fallback.GetStream(name); + + public IEnumerable GetAvailableResources() => throw new NotSupportedException(); + + public void AddAdjustment(AdjustableProperty type, BindableNumber adjustBindable) => throw new NotSupportedException(); + + public void RemoveAdjustment(AdjustableProperty type, BindableNumber adjustBindable) => throw new NotSupportedException(); + + public void RemoveAllAdjustments(AdjustableProperty type) => throw new NotSupportedException(); + + public BindableNumber Volume => throw new NotSupportedException(); + + public BindableNumber Balance => throw new NotSupportedException(); + + public BindableNumber Frequency => throw new NotSupportedException(); + + public BindableNumber Tempo => throw new NotSupportedException(); + + public IBindable GetAggregate(AdjustableProperty type) => throw new NotSupportedException(); + + public IBindable AggregateVolume => throw new NotSupportedException(); + + public IBindable AggregateBalance => throw new NotSupportedException(); + + public IBindable AggregateFrequency => throw new NotSupportedException(); + + public IBindable AggregateTempo => throw new NotSupportedException(); + + public int PlaybackConcurrency + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public void Dispose() + { + primary?.Dispose(); + } } - public SampleChannel Get(string name) => primary.Get(name) ?? secondary.Get(name); - - public Task GetAsync(string name) => primary.GetAsync(name) ?? secondary.GetAsync(name); - - public Stream GetStream(string name) => primary.GetStream(name) ?? secondary.GetStream(name); - - public IEnumerable GetAvailableResources() => throw new NotSupportedException(); - - public void AddAdjustment(AdjustableProperty type, BindableNumber adjustBindable) => throw new NotSupportedException(); - - public void RemoveAdjustment(AdjustableProperty type, BindableNumber adjustBindable) => throw new NotSupportedException(); - - public void RemoveAllAdjustments(AdjustableProperty type) => throw new NotSupportedException(); - - public BindableNumber Volume => throw new NotSupportedException(); - - public BindableNumber Balance => throw new NotSupportedException(); - - public BindableNumber Frequency => throw new NotSupportedException(); - - public BindableNumber Tempo => throw new NotSupportedException(); - - public IBindable GetAggregate(AdjustableProperty type) => throw new NotSupportedException(); - - public IBindable AggregateVolume => throw new NotSupportedException(); - - public IBindable AggregateBalance => throw new NotSupportedException(); - - public IBindable AggregateFrequency => throw new NotSupportedException(); - - public IBindable AggregateTempo => throw new NotSupportedException(); - - public int PlaybackConcurrency + /// + /// A texture store which adds a fallback source and prevents disposal of the fallback source. + /// + private class FallbackTextureStore : TextureStore { - get => throw new NotSupportedException(); - set => throw new NotSupportedException(); - } + private readonly TextureStore primary; + private readonly TextureStore fallback; - public void Dispose() - { + public FallbackTextureStore(TextureStore primary, TextureStore fallback) + { + this.primary = primary; + this.fallback = fallback; + } + + public override Texture Get(string name, WrapMode wrapModeS, WrapMode wrapModeT) + => primary.Get(name, wrapModeS, wrapModeT) ?? fallback.Get(name, wrapModeS, wrapModeT); + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + primary?.Dispose(); + } } } } diff --git a/osu.Game/Rulesets/UI/HitObjectContainer.cs b/osu.Game/Rulesets/UI/HitObjectContainer.cs index f4f66f1272..9a0217a1eb 100644 --- a/osu.Game/Rulesets/UI/HitObjectContainer.cs +++ b/osu.Game/Rulesets/UI/HitObjectContainer.cs @@ -43,10 +43,20 @@ namespace osu.Game.Rulesets.UI return true; } + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + unbindStartTimeMap(); + } + public virtual void Clear(bool disposeChildren = true) { ClearInternal(disposeChildren); + unbindStartTimeMap(); + } + private void unbindStartTimeMap() + { foreach (var kvp in startTimeMap) kvp.Value.bindable.UnbindAll(); startTimeMap.Clear(); diff --git a/osu.Game/Rulesets/UI/Playfield.cs b/osu.Game/Rulesets/UI/Playfield.cs index c52183f3f2..d92ba210db 100644 --- a/osu.Game/Rulesets/UI/Playfield.cs +++ b/osu.Game/Rulesets/UI/Playfield.cs @@ -37,7 +37,21 @@ namespace osu.Game.Rulesets.UI /// /// All the s contained in this and all . /// - public IEnumerable AllHitObjects => HitObjectContainer?.Objects.Concat(NestedPlayfields.SelectMany(p => p.AllHitObjects)) ?? Enumerable.Empty(); + public IEnumerable AllHitObjects + { + get + { + if (HitObjectContainer == null) + return Enumerable.Empty(); + + var enumerable = HitObjectContainer.Objects; + + if (nestedPlayfields.IsValueCreated) + enumerable = enumerable.Concat(NestedPlayfields.SelectMany(p => p.AllHitObjects)); + + return enumerable; + } + } /// /// All s nested inside this . diff --git a/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs b/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs index c1f54d7938..c68eeeb4f9 100644 --- a/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs +++ b/osu.Game/Screens/Edit/Components/TimeInfoContainer.cs @@ -22,10 +22,12 @@ namespace osu.Game.Screens.Edit.Components { trackTimer = new OsuSpriteText { - Origin = Anchor.BottomLeft, - RelativePositionAxes = Axes.Y, - Font = OsuFont.GetFont(size: 22, fixedWidth: true), - Y = 0.5f, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + // intentionally fudged centre to avoid movement of the number portion when + // going negative. + X = -35, + Font = OsuFont.GetFont(size: 25, fixedWidth: true), } }; } @@ -34,7 +36,8 @@ namespace osu.Game.Screens.Edit.Components { base.Update(); - trackTimer.Text = TimeSpan.FromMilliseconds(editorClock.CurrentTime).ToString(@"mm\:ss\:fff"); + var timespan = TimeSpan.FromMilliseconds(editorClock.CurrentTime); + trackTimer.Text = $"{(timespan < TimeSpan.Zero ? "-" : string.Empty)}{timespan:mm\\:ss\\:fff}"; } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index f95bf350b6..6e2c8bd01c 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -20,6 +20,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Objects.Types; using osuTK; namespace osu.Game.Screens.Edit.Compose.Components @@ -44,7 +45,7 @@ namespace osu.Game.Screens.Edit.Compose.Components protected EditorBeatmap EditorBeatmap { get; private set; } [Resolved(CanBeNull = true)] - private IEditorChangeHandler changeHandler { get; set; } + protected IEditorChangeHandler ChangeHandler { get; private set; } public SelectionHandler() { @@ -193,12 +194,12 @@ namespace osu.Game.Screens.Edit.Compose.Components private void deleteSelected() { - changeHandler?.BeginChange(); + ChangeHandler?.BeginChange(); foreach (var h in selectedBlueprints.ToList()) EditorBeatmap?.Remove(h.HitObject); - changeHandler?.EndChange(); + ChangeHandler?.EndChange(); } #endregion @@ -254,7 +255,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// The name of the hit sample. public void AddHitSample(string sampleName) { - changeHandler?.BeginChange(); + ChangeHandler?.BeginChange(); foreach (var h in SelectedHitObjects) { @@ -265,7 +266,30 @@ namespace osu.Game.Screens.Edit.Compose.Components h.Samples.Add(new HitSampleInfo { Name = sampleName }); } - changeHandler?.EndChange(); + ChangeHandler?.EndChange(); + } + + /// + /// Set the new combo state of all selected s. + /// + /// Whether to set or unset. + /// Throws if any selected object doesn't implement + public void SetNewCombo(bool state) + { + ChangeHandler?.BeginChange(); + + foreach (var h in SelectedHitObjects) + { + var comboInfo = h as IHasComboInformation; + + if (comboInfo == null) + throw new InvalidOperationException($"Tried to change combo state of a {h.GetType()}, which doesn't implement {nameof(IHasComboInformation)}"); + + comboInfo.NewCombo = state; + EditorBeatmap?.UpdateHitObject(h); + } + + ChangeHandler?.EndChange(); } /// @@ -274,12 +298,12 @@ namespace osu.Game.Screens.Edit.Compose.Components /// The name of the hit sample. public void RemoveHitSample(string sampleName) { - changeHandler?.BeginChange(); + ChangeHandler?.BeginChange(); foreach (var h in SelectedHitObjects) h.SamplesBindable.RemoveAll(s => s.Name == sampleName); - changeHandler?.EndChange(); + ChangeHandler?.EndChange(); } #endregion @@ -297,6 +321,9 @@ namespace osu.Game.Screens.Edit.Compose.Components items.AddRange(GetContextMenuItemsForSelection(selectedBlueprints)); + if (selectedBlueprints.All(b => b.HitObject is IHasComboInformation)) + items.Add(createNewComboMenuItem()); + if (selectedBlueprints.Count == 1) items.AddRange(selectedBlueprints[0].ContextMenuItems); @@ -326,6 +353,41 @@ namespace osu.Game.Screens.Edit.Compose.Components protected virtual IEnumerable GetContextMenuItemsForSelection(IEnumerable selection) => Enumerable.Empty(); + private MenuItem createNewComboMenuItem() + { + return new TernaryStateMenuItem("New combo", MenuItemType.Standard, setNewComboState) + { + State = { Value = getHitSampleState() } + }; + + void setNewComboState(TernaryState state) + { + switch (state) + { + case TernaryState.False: + SetNewCombo(false); + break; + + case TernaryState.True: + SetNewCombo(true); + break; + } + } + + TernaryState getHitSampleState() + { + int countExisting = selectedBlueprints.Select(b => (IHasComboInformation)b.HitObject).Count(h => h.NewCombo); + + if (countExisting == 0) + return TernaryState.False; + + if (countExisting < SelectedHitObjects.Count()) + return TernaryState.Indeterminate; + + return TernaryState.True; + } + } + private MenuItem createHitSampleMenuItem(string name, string sampleName) { return new TernaryStateMenuItem(name, MenuItemType.Standard, setHitSampleState) diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 71340041f0..b7a59bc2e2 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -79,6 +79,8 @@ namespace osu.Game.Screens.Edit private EditorBeatmap editorBeatmap; private EditorChangeHandler changeHandler; + private EditorMenuBar menuBar; + private DependencyContainer dependencies; protected override UserActivity InitialActivity => new UserActivity.Editing(Beatmap.Value.BeatmapInfo); @@ -133,8 +135,6 @@ namespace osu.Game.Screens.Edit updateLastSavedHash(); - EditorMenuBar menuBar; - OsuMenuItem undoMenuItem; OsuMenuItem redoMenuItem; @@ -374,14 +374,32 @@ namespace osu.Game.Screens.Edit public bool OnPressed(GlobalAction action) { - if (action == GlobalAction.Back) + switch (action) { - // as we don't want to display the back button, manual handling of exit action is required. - this.Exit(); - return true; - } + case GlobalAction.Back: + // as we don't want to display the back button, manual handling of exit action is required. + this.Exit(); + return true; - return false; + case GlobalAction.EditorComposeMode: + menuBar.Mode.Value = EditorScreenMode.Compose; + return true; + + case GlobalAction.EditorDesignMode: + menuBar.Mode.Value = EditorScreenMode.Design; + return true; + + case GlobalAction.EditorTimingMode: + menuBar.Mode.Value = EditorScreenMode.Timing; + return true; + + case GlobalAction.EditorSetupMode: + menuBar.Mode.Value = EditorScreenMode.SongSetup; + return true; + + default: + return false; + } } public void OnReleased(GlobalAction action) diff --git a/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs b/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs index 67442aa55e..66d90809db 100644 --- a/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs +++ b/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs @@ -94,6 +94,7 @@ namespace osu.Game.Screens.Edit } }, }; + LoadComponentAsync(CreateMainContent(), content => { spinner.State.Value = Visibility.Hidden; diff --git a/osu.Game/Screens/Play/FailAnimation.cs b/osu.Game/Screens/Play/FailAnimation.cs index 54c644c999..608f20affd 100644 --- a/osu.Game/Screens/Play/FailAnimation.cs +++ b/osu.Game/Screens/Play/FailAnimation.cs @@ -89,6 +89,8 @@ namespace osu.Game.Screens.Play private void applyToPlayfield(Playfield playfield) { + double failTime = playfield.Time.Current; + foreach (var nested in playfield.NestedPlayfields) applyToPlayfield(nested); @@ -97,13 +99,29 @@ namespace osu.Game.Screens.Play if (appliedObjects.Contains(obj)) continue; - obj.RotateTo(RNG.NextSingle(-90, 90), duration); - obj.ScaleTo(obj.Scale * 0.5f, duration); - obj.MoveToOffset(new Vector2(0, 400), duration); + float rotation = RNG.NextSingle(-90, 90); + Vector2 originalPosition = obj.Position; + Vector2 originalScale = obj.Scale; + + dropOffScreen(obj, failTime, rotation, originalScale, originalPosition); + + // need to reapply the fail drop after judgement state changes + obj.ApplyCustomUpdateState += (o, _) => dropOffScreen(obj, failTime, rotation, originalScale, originalPosition); + appliedObjects.Add(obj); } } + private void dropOffScreen(DrawableHitObject obj, double failTime, float randomRotation, Vector2 originalScale, Vector2 originalPosition) + { + using (obj.BeginAbsoluteSequence(failTime)) + { + obj.RotateTo(randomRotation, duration); + obj.ScaleTo(originalScale * 0.5f, duration); + obj.MoveTo(originalPosition + new Vector2(0, 400), duration); + } + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 539f9227a3..8e2ed583f2 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -399,7 +399,7 @@ namespace osu.Game.Screens.Play } catch (Exception e) { - Logger.Error(e, "Could not load beatmap sucessfully!"); + Logger.Error(e, "Could not load beatmap successfully!"); //couldn't load, hard abort! return null; } diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs index c95cf1066e..c48cd238c0 100644 --- a/osu.Game/Screens/Ranking/ResultsScreen.cs +++ b/osu.Game/Screens/Ranking/ResultsScreen.cs @@ -273,10 +273,10 @@ namespace osu.Game.Screens.Ranking detachedPanelContainer.Add(expandedPanel); // Move into its original location in the local container first, then to the final location. - var origLocation = detachedPanelContainer.ToLocalSpace(screenSpacePos); - expandedPanel.MoveTo(origLocation) + var origLocation = detachedPanelContainer.ToLocalSpace(screenSpacePos).X; + expandedPanel.MoveToX(origLocation) .Then() - .MoveTo(new Vector2(StatisticsPanel.SIDE_PADDING, origLocation.Y), 150, Easing.OutQuint); + .MoveToX(StatisticsPanel.SIDE_PADDING, 150, Easing.OutQuint); // Hide contracted panels. foreach (var contracted in ScorePanelList.GetScorePanels().Where(p => p.State == PanelState.Contracted)) diff --git a/osu.Game/Screens/Ranking/ScorePanelList.cs b/osu.Game/Screens/Ranking/ScorePanelList.cs index 10bd99c8ce..0d7d339df0 100644 --- a/osu.Game/Screens/Ranking/ScorePanelList.cs +++ b/osu.Game/Screens/Ranking/ScorePanelList.cs @@ -99,6 +99,8 @@ namespace osu.Game.Screens.Ranking { var panel = new ScorePanel(score) { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, PostExpandAction = () => PostExpandAction?.Invoke() }.With(p => { diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs index c2ace6a04e..f1ae1f9d73 100644 --- a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs +++ b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs @@ -75,7 +75,23 @@ namespace osu.Game.Screens.Ranking.Statistics return; if (newScore.HitEvents == null || newScore.HitEvents.Count == 0) - content.Add(new MessagePlaceholder("Score has no statistics :(")); + { + content.Add(new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new MessagePlaceholder("Extended statistics are only available after watching a replay!"), + new ReplayDownloadButton(newScore) + { + Scale = new Vector2(1.5f), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + } + }); + } else { spinner.Show(); @@ -91,7 +107,10 @@ namespace osu.Game.Screens.Ranking.Statistics { var rows = new FillFlowContainer { - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, Direction = FillDirection.Vertical, Spacing = new Vector2(30, 15), Alpha = 0 diff --git a/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs b/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs index 40335db697..fc01f0bd31 100644 --- a/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs +++ b/osu.Game/Skinning/BeatmapSkinProvidingContainer.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Audio; @@ -13,25 +14,62 @@ namespace osu.Game.Skinning /// public class BeatmapSkinProvidingContainer : SkinProvidingContainer { - private readonly Bindable beatmapSkins = new Bindable(); - private readonly Bindable beatmapHitsounds = new Bindable(); + private Bindable beatmapSkins; + private Bindable beatmapHitsounds; - protected override bool AllowConfigurationLookup => beatmapSkins.Value; - protected override bool AllowDrawableLookup(ISkinComponent component) => beatmapSkins.Value; - protected override bool AllowTextureLookup(string componentName) => beatmapSkins.Value; - protected override bool AllowSampleLookup(ISampleInfo componentName) => beatmapHitsounds.Value; + protected override bool AllowConfigurationLookup + { + get + { + if (beatmapSkins == null) + throw new InvalidOperationException($"{nameof(BeatmapSkinProvidingContainer)} needs to be loaded before being consumed."); + + return beatmapSkins.Value; + } + } + + protected override bool AllowDrawableLookup(ISkinComponent component) + { + if (beatmapSkins == null) + throw new InvalidOperationException($"{nameof(BeatmapSkinProvidingContainer)} needs to be loaded before being consumed."); + + return beatmapSkins.Value; + } + + protected override bool AllowTextureLookup(string componentName) + { + if (beatmapSkins == null) + throw new InvalidOperationException($"{nameof(BeatmapSkinProvidingContainer)} needs to be loaded before being consumed."); + + return beatmapSkins.Value; + } + + protected override bool AllowSampleLookup(ISampleInfo componentName) + { + if (beatmapSkins == null) + throw new InvalidOperationException($"{nameof(BeatmapSkinProvidingContainer)} needs to be loaded before being consumed."); + + return beatmapHitsounds.Value; + } public BeatmapSkinProvidingContainer(ISkin skin) : base(skin) { } - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { - config.BindWith(OsuSetting.BeatmapSkins, beatmapSkins); - config.BindWith(OsuSetting.BeatmapHitsounds, beatmapHitsounds); + var config = parent.Get(); + beatmapSkins = config.GetBindable(OsuSetting.BeatmapSkins); + beatmapHitsounds = config.GetBindable(OsuSetting.BeatmapHitsounds); + + return base.CreateChildDependencies(parent); + } + + [BackgroundDependencyLoader] + private void load() + { beatmapSkins.BindValueChanged(_ => TriggerSourceChanged()); beatmapHitsounds.BindValueChanged(_ => TriggerSourceChanged()); } diff --git a/osu.Game/Skinning/LegacyRollingCounter.cs b/osu.Game/Skinning/LegacyRollingCounter.cs new file mode 100644 index 0000000000..8aa9d4e9af --- /dev/null +++ b/osu.Game/Skinning/LegacyRollingCounter.cs @@ -0,0 +1,51 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK; + +namespace osu.Game.Skinning +{ + /// + /// An integer that uses number sprites from a legacy skin. + /// + public class LegacyRollingCounter : RollingCounter + { + private readonly ISkin skin; + + private readonly string fontName; + private readonly float fontOverlap; + + protected override bool IsRollingProportional => true; + + /// + /// Creates a new . + /// + /// The from which to get counter number sprites. + /// The name of the legacy font to use. + /// + /// The numeric overlap of number sprites to use. + /// A positive number will bring the number sprites closer together, while a negative number + /// will split them apart more. + /// + public LegacyRollingCounter(ISkin skin, string fontName, float fontOverlap) + { + this.skin = skin; + this.fontName = fontName; + this.fontOverlap = fontOverlap; + } + + protected override double GetProportionalDuration(int currentValue, int newValue) + { + return Math.Abs(newValue - currentValue) * 75.0; + } + + protected sealed override OsuSpriteText CreateSpriteText() => + new LegacySpriteText(skin, fontName) + { + Spacing = new Vector2(-fontOverlap, 0f) + }; + } +} diff --git a/osu.Game/Skinning/LegacySkinConfiguration.cs b/osu.Game/Skinning/LegacySkinConfiguration.cs index 41b7aea34b..1d5412d93f 100644 --- a/osu.Game/Skinning/LegacySkinConfiguration.cs +++ b/osu.Game/Skinning/LegacySkinConfiguration.cs @@ -15,6 +15,8 @@ namespace osu.Game.Skinning public enum LegacySetting { Version, + ComboPrefix, + ComboOverlap, AnimationFramerate, LayeredHitSounds, } diff --git a/osu.Game/Skinning/LegacySkinExtensions.cs b/osu.Game/Skinning/LegacySkinExtensions.cs index bb46dc8b9f..0ee02a2442 100644 --- a/osu.Game/Skinning/LegacySkinExtensions.cs +++ b/osu.Game/Skinning/LegacySkinExtensions.cs @@ -62,6 +62,9 @@ namespace osu.Game.Skinning } } + public static bool HasFont(this ISkin source, string fontPrefix) + => source.GetTexture($"{fontPrefix}-0") != null; + public class SkinnableTextureAnimation : TextureAnimation { [Resolved(canBeNull: true)] diff --git a/osu.Game/Skinning/SkinnableSound.cs b/osu.Game/Skinning/SkinnableSound.cs index cf629f231f..ba14049b41 100644 --- a/osu.Game/Skinning/SkinnableSound.cs +++ b/osu.Game/Skinning/SkinnableSound.cs @@ -114,6 +114,8 @@ namespace osu.Game.Skinning protected override void SkinChanged(ISkinSource skin, bool allowFallback) { + bool wasPlaying = IsPlaying; + var channels = hitSamples.Select(s => { var ch = skin.GetSample(s); @@ -138,8 +140,9 @@ namespace osu.Game.Skinning samplesContainer.ChildrenEnumerable = channels.Select(c => new DrawableSample(c)); - if (requestedPlaying) - Play(); + // Start playback internally for the new samples if the previous ones were playing beforehand. + if (wasPlaying) + play(); } #region Re-expose AudioContainer diff --git a/osu.Game/Tests/Visual/SkinnableTestScene.cs b/osu.Game/Tests/Visual/SkinnableTestScene.cs index 81c13112d0..a856789d96 100644 --- a/osu.Game/Tests/Visual/SkinnableTestScene.cs +++ b/osu.Game/Tests/Visual/SkinnableTestScene.cs @@ -160,6 +160,11 @@ namespace osu.Game.Tests.Visual public override Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) { + var lookup = base.GetTexture(componentName, wrapModeS, wrapModeT); + + if (lookup != null) + return lookup; + // extrapolate frames to test longer animations if (extrapolateAnimations) { @@ -169,7 +174,7 @@ namespace osu.Game.Tests.Visual return base.GetTexture(componentName.Replace($"-{number}", $"-{number % 2}"), wrapModeS, wrapModeT); } - return base.GetTexture(componentName, wrapModeS, wrapModeT); + return null; } } } diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 8d23a32c3c..71826e161c 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -24,7 +24,7 @@ - + diff --git a/osu.iOS.props b/osu.iOS.props index d00b174195..90aa903318 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -70,7 +70,7 @@ - + @@ -80,7 +80,7 @@ - +