diff --git a/osu.Android.props b/osu.Android.props index 723844155f..d2bdbc8b61 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -52,6 +52,6 @@ - + diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs new file mode 100644 index 0000000000..a48ecb9b79 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs @@ -0,0 +1,132 @@ +// 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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Rulesets.Catch.Skinning; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Skinning; +using osu.Game.Tests.Visual; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Tests +{ + public class TestSceneHyperDashColouring : OsuTestScene + { + [Resolved] + private SkinManager skins { get; set; } + + [Test] + public void TestDefaultFruitColour() + { + var skin = new TestSkin(); + + checkHyperDashFruitColour(skin, Catcher.DEFAULT_HYPER_DASH_COLOUR); + } + + [Test] + public void TestCustomFruitColour() + { + var skin = new TestSkin + { + HyperDashFruitColour = Color4.Cyan + }; + + checkHyperDashFruitColour(skin, skin.HyperDashFruitColour); + } + + [Test] + public void TestCustomFruitColourPriority() + { + var skin = new TestSkin + { + HyperDashColour = Color4.Goldenrod, + HyperDashFruitColour = Color4.Cyan + }; + + checkHyperDashFruitColour(skin, skin.HyperDashFruitColour); + } + + [Test] + public void TestFruitColourFallback() + { + var skin = new TestSkin + { + HyperDashColour = Color4.Goldenrod + }; + + checkHyperDashFruitColour(skin, skin.HyperDashColour); + } + + private void checkHyperDashFruitColour(ISkin skin, Color4 expectedColour) + { + DrawableFruit drawableFruit = null; + + AddStep("create hyper-dash fruit", () => + { + var fruit = new Fruit { HyperDashTarget = new Banana() }; + fruit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + + Child = setupSkinHierarchy(drawableFruit = new DrawableFruit(fruit) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(4f), + }, skin); + }); + + AddAssert("hyper-dash colour is correct", () => checkLegacyFruitHyperDashColour(drawableFruit, expectedColour)); + } + + private Drawable setupSkinHierarchy(Drawable child, ISkin skin) + { + var legacySkinProvider = new SkinProvidingContainer(skins.GetSkin(DefaultLegacySkin.Info)); + var testSkinProvider = new SkinProvidingContainer(skin); + var legacySkinTransformer = new SkinProvidingContainer(new CatchLegacySkinTransformer(testSkinProvider)); + + return legacySkinProvider + .WithChild(testSkinProvider + .WithChild(legacySkinTransformer + .WithChild(child))); + } + + private bool checkLegacyFruitHyperDashColour(DrawableFruit fruit, Color4 expectedColour) => + fruit.ChildrenOfType().First().Drawable.ChildrenOfType().Any(c => c.Colour == expectedColour); + + private class TestSkin : LegacySkin + { + public Color4 HyperDashColour + { + get => Configuration.CustomColours[CatchSkinColour.HyperDash.ToString()]; + set => Configuration.CustomColours[CatchSkinColour.HyperDash.ToString()] = value; + } + + public Color4 HyperDashAfterImageColour + { + get => Configuration.CustomColours[CatchSkinColour.HyperDashAfterImage.ToString()]; + set => Configuration.CustomColours[CatchSkinColour.HyperDashAfterImage.ToString()] = value; + } + + public Color4 HyperDashFruitColour + { + get => Configuration.CustomColours[CatchSkinColour.HyperDashFruit.ToString()]; + set => Configuration.CustomColours[CatchSkinColour.HyperDashFruit.ToString()] = value; + } + + public TestSkin() + : base(new SkinInfo(), null, null, string.Empty) + { + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/Drawables/FruitPiece.cs b/osu.Game.Rulesets.Catch/Objects/Drawables/FruitPiece.cs index 5797588ded..7ac9f11ad6 100644 --- a/osu.Game.Rulesets.Catch/Objects/Drawables/FruitPiece.cs +++ b/osu.Game.Rulesets.Catch/Objects/Drawables/FruitPiece.cs @@ -7,6 +7,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Objects.Drawables; using osuTK.Graphics; @@ -67,7 +68,7 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, Origin = Anchor.Centre, - BorderColour = Color4.Red, + BorderColour = Catcher.DEFAULT_HYPER_DASH_COLOUR, BorderThickness = 12f * RADIUS_ADJUST, Children = new Drawable[] { @@ -77,7 +78,7 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables Alpha = 0.3f, Blending = BlendingParameters.Additive, RelativeSizeAxes = Axes.Both, - Colour = Color4.Red, + Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR, } } }); diff --git a/osu.Game.Rulesets.Catch/Skinning/CatchLegacySkinTransformer.cs b/osu.Game.Rulesets.Catch/Skinning/CatchLegacySkinTransformer.cs index 65e6e6f209..4a87eb95e7 100644 --- a/osu.Game.Rulesets.Catch/Skinning/CatchLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Catch/Skinning/CatchLegacySkinTransformer.cs @@ -65,6 +65,15 @@ namespace osu.Game.Rulesets.Catch.Skinning public SampleChannel GetSample(ISampleInfo sample) => source.GetSample(sample); - public IBindable GetConfig(TLookup lookup) => source.GetConfig(lookup); + public IBindable GetConfig(TLookup lookup) + { + switch (lookup) + { + case CatchSkinColour colour: + return source.GetConfig(new SkinCustomColourLookup(colour)); + } + + return source.GetConfig(lookup); + } } } diff --git a/osu.Game.Rulesets.Catch/Skinning/CatchSkinColour.cs b/osu.Game.Rulesets.Catch/Skinning/CatchSkinColour.cs new file mode 100644 index 0000000000..4506111498 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Skinning/CatchSkinColour.cs @@ -0,0 +1,23 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Rulesets.Catch.Skinning +{ + public enum CatchSkinColour + { + /// + /// The colour to be used for the catcher while in hyper-dashing state. + /// + HyperDash, + + /// + /// The colour to be used for fruits that grant the catcher the ability to hyper-dash. + /// + HyperDashFruit, + + /// + /// The colour to be used for the "exploding" catcher sprite on beginning of hyper-dashing. + /// + HyperDashAfterImage, + } +} diff --git a/osu.Game.Rulesets.Catch/Skinning/LegacyFruitPiece.cs b/osu.Game.Rulesets.Catch/Skinning/LegacyFruitPiece.cs index 25ee0811d0..5be54d3882 100644 --- a/osu.Game.Rulesets.Catch/Skinning/LegacyFruitPiece.cs +++ b/osu.Game.Rulesets.Catch/Skinning/LegacyFruitPiece.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Skinning; using osuTK; @@ -55,14 +56,16 @@ namespace osu.Game.Rulesets.Catch.Skinning { var hyperDash = new Sprite { - Texture = skin.GetTexture(lookupName), - Colour = Color4.Red, Anchor = Anchor.Centre, Origin = Anchor.Centre, Blending = BlendingParameters.Additive, Depth = 1, Alpha = 0.7f, - Scale = new Vector2(1.2f) + Scale = new Vector2(1.2f), + Texture = skin.GetTexture(lookupName), + Colour = skin.GetConfig(CatchSkinColour.HyperDashFruit)?.Value ?? + skin.GetConfig(CatchSkinColour.HyperDash)?.Value ?? + Catcher.DEFAULT_HYPER_DASH_COLOUR, }; AddInternal(hyperDash); diff --git a/osu.Game.Rulesets.Catch/UI/Catcher.cs b/osu.Game.Rulesets.Catch/UI/Catcher.cs index 7c815370c8..920d804e72 100644 --- a/osu.Game.Rulesets.Catch/UI/Catcher.cs +++ b/osu.Game.Rulesets.Catch/UI/Catcher.cs @@ -21,6 +21,8 @@ namespace osu.Game.Rulesets.Catch.UI { public class Catcher : Container, IKeyBindingHandler { + public static readonly Color4 DEFAULT_HYPER_DASH_COLOUR = Color4.Red; + /// /// Whether we are hyper-dashing or not. /// diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs new file mode 100644 index 0000000000..8bd3d3c7cc --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs @@ -0,0 +1,106 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Tests.Mods +{ + public class TestSceneOsuModHidden : ModTestScene + { + public TestSceneOsuModHidden() + : base(new OsuRuleset()) + { + } + + [Test] + public void TestDefaultBeatmapTest() => CreateModTest(new ModTestData + { + Mod = new OsuModHidden(), + Autoplay = true, + PassCondition = checkSomeHit + }); + + [Test] + public void FirstCircleAfterTwoSpinners() => CreateModTest(new ModTestData + { + Mod = new OsuModHidden(), + Autoplay = true, + Beatmap = new Beatmap + { + HitObjects = new List + { + new Spinner + { + Position = new Vector2(256, 192), + EndTime = 1000, + }, + new Spinner + { + Position = new Vector2(256, 192), + StartTime = 1200, + EndTime = 2200, + }, + new HitCircle + { + Position = new Vector2(300, 192), + StartTime = 3200, + }, + new HitCircle + { + Position = new Vector2(384, 192), + StartTime = 4200, + } + } + }, + PassCondition = checkSomeHit + }); + + [Test] + public void FirstSliderAfterTwoSpinners() => CreateModTest(new ModTestData + { + Mod = new OsuModHidden(), + Autoplay = true, + Beatmap = new Beatmap + { + HitObjects = new List + { + new Spinner + { + Position = new Vector2(256, 192), + EndTime = 1000, + }, + new Spinner + { + Position = new Vector2(256, 192), + StartTime = 1200, + EndTime = 2200, + }, + new Slider + { + StartTime = 3200, + Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(100, 0), }) + }, + new Slider + { + StartTime = 5200, + Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(100, 0), }) + } + } + }, + PassCondition = checkSomeHit + }); + + private bool checkSomeHit() + { + return Player.ScoreProcessor.JudgedHits >= 4; + } + } +} diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneOutOfOrderHits.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneOutOfOrderHits.cs index d6858f831e..40ee53e8f2 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneOutOfOrderHits.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneOutOfOrderHits.cs @@ -296,6 +296,44 @@ namespace osu.Game.Rulesets.Osu.Tests addJudgementAssert(hitObjects[1], HitResult.Great); } + [Test] + public void TestHitSliderHeadBeforeHitCircle() + { + const double time_circle = 1000; + const double time_slider = 1200; + Vector2 positionCircle = Vector2.Zero; + Vector2 positionSlider = new Vector2(80); + + var hitObjects = new List + { + new TestHitCircle + { + StartTime = time_circle, + Position = positionCircle + }, + new TestSlider + { + StartTime = time_slider, + Position = positionSlider, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(25, 0), + }) + } + }; + + performTest(hitObjects, new List + { + new OsuReplayFrame { Time = time_circle - 100, Position = positionSlider, Actions = { OsuAction.LeftButton } }, + new OsuReplayFrame { Time = time_circle, Position = positionCircle, Actions = { OsuAction.RightButton } }, + new OsuReplayFrame { Time = time_slider, Position = positionSlider, Actions = { OsuAction.LeftButton } }, + }); + + addJudgementAssert(hitObjects[0], HitResult.Great); + addJudgementAssert(hitObjects[1], HitResult.Great); + } + private void addJudgementAssert(OsuHitObject hitObject, HitResult result) { AddAssert($"({hitObject.GetType().ReadableName()} @ {hitObject.StartTime}) judgement is {result}", @@ -371,6 +409,9 @@ namespace osu.Game.Rulesets.Osu.Tests { HeadCircle.HitWindows = new TestHitWindows(); TailCircle.HitWindows = new TestHitWindows(); + + HeadCircle.HitWindows.SetDifficulty(0); + TailCircle.HitWindows.SetDifficulty(0); }; } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs index 91a4e049e3..fdba03f260 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs @@ -23,6 +23,8 @@ namespace osu.Game.Rulesets.Osu.Mods private const double fade_in_duration_multiplier = 0.4; private const double fade_out_duration_multiplier = 0.3; + protected override bool IsFirstHideableObject(DrawableHitObject hitObject) => !(hitObject is DrawableSpinner); + public override void ApplyToDrawableHitObjects(IEnumerable drawables) { static void adjustFadeIn(OsuHitObject h) => h.TimeFadeIn = h.TimePreempt * fade_in_duration_multiplier; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index 522217a916..72502c02cd 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -125,7 +125,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables return new DrawableSliderTail(slider, tail); case SliderHeadCircle head: - return new DrawableSliderHead(slider, head) { OnShake = Shake }; + return new DrawableSliderHead(slider, head) + { + OnShake = Shake, + CheckHittable = (d, t) => CheckHittable?.Invoke(d, t) ?? true + }; case SliderTick tick: return new DrawableSliderTick(tick) { Position = tick.Position - slider.Position }; diff --git a/osu.Game.Rulesets.Osu/UI/OrderedHitPolicy.cs b/osu.Game.Rulesets.Osu/UI/OrderedHitPolicy.cs index dfca2aff7b..8e4f81347d 100644 --- a/osu.Game.Rulesets.Osu/UI/OrderedHitPolicy.cs +++ b/osu.Game.Rulesets.Osu/UI/OrderedHitPolicy.cs @@ -1,16 +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.Collections.Generic; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Osu.UI { /// - /// Ensures that s are hit in-order. + /// Ensures that s are hit in-order. Affectionately known as "note lock". /// If a is hit out of order: /// /// The hit is blocked if it occurred earlier than the previous 's start time. @@ -36,13 +37,9 @@ namespace osu.Game.Rulesets.Osu.UI { DrawableHitObject blockingObject = null; - // Find the last hitobject which blocks future hits. - foreach (var obj in hitObjectContainer.AliveObjects) + foreach (var obj in enumerateHitObjectsUpTo(hitObject.HitObject.StartTime)) { - if (obj == hitObject) - break; - - if (drawableCanBlockFutureHits(obj)) + if (hitObjectCanBlockFutureHits(obj)) blockingObject = obj; } @@ -54,74 +51,56 @@ namespace osu.Game.Rulesets.Osu.UI // 1. The last blocking hitobject has been judged. // 2. The current time is after the last hitobject's start time. // Hits at exactly the same time as the blocking hitobject are allowed for maps that contain simultaneous hitobjects (e.g. /b/372245). - if (blockingObject.Judged || time >= blockingObject.HitObject.StartTime) - return true; - - return false; + return blockingObject.Judged || time >= blockingObject.HitObject.StartTime; } /// /// Handles a being hit to potentially miss all earlier s. /// /// The that was hit. - public void HandleHit(HitObject hitObject) + public void HandleHit(DrawableHitObject hitObject) { // Hitobjects which themselves don't block future hitobjects don't cause misses (e.g. slider ticks, spinners). if (!hitObjectCanBlockFutureHits(hitObject)) return; - double maximumTime = hitObject.StartTime; + if (!IsHittable(hitObject, hitObject.HitObject.StartTime + hitObject.Result.TimeOffset)) + throw new InvalidOperationException($"A {hitObject} was hit before it became hittable!"); - // Iterate through and apply miss results to all top-level and nested hitobjects which block future hits. - foreach (var obj in hitObjectContainer.AliveObjects) + foreach (var obj in enumerateHitObjectsUpTo(hitObject.HitObject.StartTime)) { - if (obj.Judged || obj.HitObject.StartTime >= maximumTime) + if (obj.Judged) continue; - if (hitObjectCanBlockFutureHits(obj.HitObject)) - applyMiss(obj); - - foreach (var nested in obj.NestedHitObjects) - { - if (nested.Judged || nested.HitObject.StartTime >= maximumTime) - continue; - - if (hitObjectCanBlockFutureHits(nested.HitObject)) - applyMiss(nested); - } + if (hitObjectCanBlockFutureHits(obj)) + ((DrawableOsuHitObject)obj).MissForcefully(); } - - static void applyMiss(DrawableHitObject obj) => ((DrawableOsuHitObject)obj).MissForcefully(); - } - - /// - /// Whether a blocks hits on future s until its start time is reached. - /// - /// - /// This will ONLY match on top-most s. - /// - /// The to test. - private static bool drawableCanBlockFutureHits(DrawableHitObject hitObject) - { - // Special considerations for slider tails aren't required since only top-most drawable hitobjects are being iterated over. - return hitObject is DrawableHitCircle || hitObject is DrawableSlider; } /// /// Whether a blocks hits on future s until its start time is reached. /// - /// - /// This is more rigorous and may not match on top-most s as does. - /// /// The to test. - private static bool hitObjectCanBlockFutureHits(HitObject hitObject) - { - // Unlike the above we will receive slider tails, but they do not block future hits. - if (hitObject is SliderTailCircle) - return false; + private static bool hitObjectCanBlockFutureHits(DrawableHitObject hitObject) + => hitObject is DrawableHitCircle; - // All other hitcircles continue to block future hits. - return hitObject is HitCircle; + private IEnumerable enumerateHitObjectsUpTo(double targetTime) + { + foreach (var obj in hitObjectContainer.AliveObjects) + { + if (obj.HitObject.StartTime >= targetTime) + yield break; + + yield return obj; + + foreach (var nestedObj in obj.NestedHitObjects) + { + if (nestedObj.HitObject.StartTime >= targetTime) + break; + + yield return nestedObj; + } + } } } } diff --git a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs index 2f222f59b4..4b1a2ce43c 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs @@ -86,7 +86,7 @@ namespace osu.Game.Rulesets.Osu.UI private void onNewResult(DrawableHitObject judgedObject, JudgementResult result) { // Hitobjects that block future hits should miss previous hitobjects if they're hit out-of-order. - hitPolicy.HandleHit(result.HitObject); + hitPolicy.HandleHit(judgedObject); if (!judgedObject.DisplayResult || !DisplayJudgements.Value) return; diff --git a/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs b/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs index 2782e902fe..158954106d 100644 --- a/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs +++ b/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs @@ -29,11 +29,17 @@ namespace osu.Game.Tests.NonVisual var cpi = new ControlPointInfo(); cpi.Add(0, new TimingControlPoint()); // is *not* redundant, special exception for first timing point. - cpi.Add(1000, new TimingControlPoint()); // is redundant + cpi.Add(1000, new TimingControlPoint()); // is also not redundant, due to change of offset - Assert.That(cpi.Groups.Count, Is.EqualTo(1)); - Assert.That(cpi.TimingPoints.Count, Is.EqualTo(1)); - Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(1)); + Assert.That(cpi.Groups.Count, Is.EqualTo(2)); + Assert.That(cpi.TimingPoints.Count, Is.EqualTo(2)); + Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(2)); + + cpi.Add(1000, new TimingControlPoint()); //is redundant + + Assert.That(cpi.Groups.Count, Is.EqualTo(2)); + Assert.That(cpi.TimingPoints.Count, Is.EqualTo(2)); + Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(2)); } [Test] @@ -86,11 +92,12 @@ namespace osu.Game.Tests.NonVisual Assert.That(cpi.EffectPoints.Count, Is.EqualTo(0)); Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(0)); - cpi.Add(1000, new EffectControlPoint { KiaiMode = true }); // is not redundant + cpi.Add(1000, new EffectControlPoint { KiaiMode = true, OmitFirstBarLine = true }); // is not redundant + cpi.Add(1400, new EffectControlPoint { KiaiMode = true, OmitFirstBarLine = true }); // same settings, but is not redundant - Assert.That(cpi.Groups.Count, Is.EqualTo(1)); - Assert.That(cpi.EffectPoints.Count, Is.EqualTo(1)); - Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(1)); + Assert.That(cpi.Groups.Count, Is.EqualTo(2)); + Assert.That(cpi.EffectPoints.Count, Is.EqualTo(2)); + Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(2)); } [Test] diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneKeyBindings.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneKeyBindings.cs new file mode 100644 index 0000000000..db65e91d17 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneKeyBindings.cs @@ -0,0 +1,98 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Bindings; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Input.Bindings; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Gameplay +{ + [HeadlessTest] + public class TestSceneKeyBindings : OsuManualInputManagerTestScene + { + private readonly ActionReceiver receiver; + + public TestSceneKeyBindings() + { + Add(new TestKeyBindingContainer + { + Child = receiver = new ActionReceiver() + }); + } + + [Test] + public void TestDefaultsWhenNotDatabased() + { + AddStep("fire key", () => + { + InputManager.PressKey(Key.A); + InputManager.ReleaseKey(Key.A); + }); + + AddAssert("received key", () => receiver.ReceivedAction); + } + + private class TestRuleset : Ruleset + { + public override IEnumerable GetModsFor(ModType type) => + throw new System.NotImplementedException(); + + public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => + throw new System.NotImplementedException(); + + public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => + throw new System.NotImplementedException(); + + public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => + throw new System.NotImplementedException(); + + public override IEnumerable GetDefaultKeyBindings(int variant = 0) + { + return new[] + { + new KeyBinding(InputKey.A, TestAction.Down), + }; + } + + public override string Description => "test"; + public override string ShortName => "test"; + } + + private enum TestAction + { + Down, + } + + private class TestKeyBindingContainer : DatabasedKeyBindingContainer + { + public TestKeyBindingContainer() + : base(new TestRuleset().RulesetInfo, 0) + { + } + } + + private class ActionReceiver : CompositeDrawable, IKeyBindingHandler + { + public bool ReceivedAction; + + public bool OnPressed(TestAction action) + { + ReceivedAction = action == TestAction.Down; + return true; + } + + public void OnReleased(TestAction action) + { + } + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.cs new file mode 100644 index 0000000000..df95f24686 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.cs @@ -0,0 +1,43 @@ +// 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 NUnit.Framework; +using osu.Game.Overlays; +using osu.Game.Overlays.Dashboard; +using osu.Game.Overlays.Dashboard.Friends; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneDashboardOverlay : OsuTestScene + { + public override IReadOnlyList RequiredTypes => new[] + { + typeof(DashboardOverlay), + typeof(DashboardOverlayHeader), + typeof(FriendDisplay) + }; + + protected override bool UseOnlineAPI => true; + + private readonly DashboardOverlay overlay; + + public TestSceneDashboardOverlay() + { + Add(overlay = new DashboardOverlay()); + } + + [Test] + public void TestShow() + { + AddStep("Show", overlay.Show); + } + + [Test] + public void TestHide() + { + AddStep("Hide", overlay.Hide); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs index cf365a7614..0b5ff1c960 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs @@ -10,6 +10,7 @@ using osu.Game.Users; using osu.Game.Overlays; using osu.Framework.Allocation; using NUnit.Framework; +using osu.Game.Online.API; namespace osu.Game.Tests.Visual.Online { @@ -27,7 +28,7 @@ namespace osu.Game.Tests.Visual.Online [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); - private FriendDisplay display; + private TestFriendDisplay display; [SetUp] public void Setup() => Schedule(() => @@ -35,7 +36,7 @@ namespace osu.Game.Tests.Visual.Online Child = new BasicScrollContainer { RelativeSizeAxes = Axes.Both, - Child = display = new FriendDisplay() + Child = display = new TestFriendDisplay() }; }); @@ -83,5 +84,17 @@ namespace osu.Game.Tests.Visual.Online LastVisit = DateTimeOffset.Now } }; + + private class TestFriendDisplay : FriendDisplay + { + public void Fetch() + { + base.APIStateChanged(API, APIState.Online); + } + + public override void APIStateChanged(IAPIProvider api, APIState state) + { + } + } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.cs b/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.cs new file mode 100644 index 0000000000..103308d34d --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneNowPlayingCommand.cs @@ -0,0 +1,85 @@ +// 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.Testing; +using osu.Game.Beatmaps; +using osu.Game.Online.Chat; +using osu.Game.Rulesets; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Online +{ + [HeadlessTest] + public class TestSceneNowPlayingCommand : OsuTestScene + { + [Cached(typeof(IChannelPostTarget))] + private PostTarget postTarget { get; set; } + + public TestSceneNowPlayingCommand() + { + Add(postTarget = new PostTarget()); + } + + [Test] + public void TestGenericActivity() + { + AddStep("Set activity", () => API.Activity.Value = new UserActivity.InLobby()); + + AddStep("Run command", () => Add(new NowPlayingCommand())); + + AddAssert("Check correct response", () => postTarget.LastMessage.Contains("is listening")); + } + + [Test] + public void TestEditActivity() + { + AddStep("Set activity", () => API.Activity.Value = new UserActivity.Editing(new BeatmapInfo())); + + AddStep("Run command", () => Add(new NowPlayingCommand())); + + AddAssert("Check correct response", () => postTarget.LastMessage.Contains("is editing")); + } + + [Test] + public void TestPlayActivity() + { + AddStep("Set activity", () => API.Activity.Value = new UserActivity.SoloGame(new BeatmapInfo(), new RulesetInfo())); + + AddStep("Run command", () => Add(new NowPlayingCommand())); + + AddAssert("Check correct response", () => postTarget.LastMessage.Contains("is playing")); + } + + [TestCase(true)] + [TestCase(false)] + public void TestLinkPresence(bool hasOnlineId) + { + AddStep("Set activity", () => API.Activity.Value = new UserActivity.InLobby()); + + AddStep("Set beatmap", () => Beatmap.Value = new DummyWorkingBeatmap(null, null) + { + BeatmapInfo = { OnlineBeatmapID = hasOnlineId ? 1234 : (int?)null } + }); + + AddStep("Run command", () => Add(new NowPlayingCommand())); + + if (hasOnlineId) + AddAssert("Check link presence", () => postTarget.LastMessage.Contains("https://osu.ppy.sh/b/1234")); + else + AddAssert("Check link not present", () => !postTarget.LastMessage.Contains("https://")); + } + + public class PostTarget : Component, IChannelPostTarget + { + public void PostMessage(string text, bool isAction = false, Channel target = null) + { + LastMessage = text; + } + + public string LastMessage { get; private set; } + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index 4405c75744..39e04ed39a 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -359,6 +359,68 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("no selection", () => songSelect.Carousel.SelectedBeatmap == null); } + [Test] + public void TestPresentNewRulesetNewBeatmap() + { + createSongSelect(); + changeRuleset(2); + + addRulesetImportStep(2); + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap.RulesetID == 2); + + addRulesetImportStep(0); + addRulesetImportStep(0); + addRulesetImportStep(0); + + BeatmapInfo target = null; + + AddStep("select beatmap/ruleset externally", () => + { + target = manager.GetAllUsableBeatmapSets() + .Last(b => b.Beatmaps.Any(bi => bi.RulesetID == 0)).Beatmaps.Last(); + + Ruleset.Value = rulesets.AvailableRulesets.First(r => r.ID == 0); + Beatmap.Value = manager.GetWorkingBeatmap(target); + }); + + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap.Equals(target)); + + // this is an important check, to make sure updateComponentFromBeatmap() was actually run + AddUntilStep("selection shown on wedge", () => songSelect.CurrentBeatmapDetailsBeatmap.BeatmapInfo == target); + } + + [Test] + public void TestPresentNewBeatmapNewRuleset() + { + createSongSelect(); + changeRuleset(2); + + addRulesetImportStep(2); + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap.RulesetID == 2); + + addRulesetImportStep(0); + addRulesetImportStep(0); + addRulesetImportStep(0); + + BeatmapInfo target = null; + + AddStep("select beatmap/ruleset externally", () => + { + target = manager.GetAllUsableBeatmapSets() + .Last(b => b.Beatmaps.Any(bi => bi.RulesetID == 0)).Beatmaps.Last(); + + Beatmap.Value = manager.GetWorkingBeatmap(target); + Ruleset.Value = rulesets.AvailableRulesets.First(r => r.ID == 0); + }); + + AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmap.Equals(target)); + + AddUntilStep("has correct ruleset", () => Ruleset.Value.ID == 0); + + // this is an important check, to make sure updateComponentFromBeatmap() was actually run + AddUntilStep("selection shown on wedge", () => songSelect.CurrentBeatmapDetailsBeatmap.BeatmapInfo == target); + } + [Test] public void TestRulesetChangeResetsMods() { diff --git a/osu.Game.Tests/Visual/TestSceneOsuGame.cs b/osu.Game.Tests/Visual/TestSceneOsuGame.cs index 492494ada3..8793d880e3 100644 --- a/osu.Game.Tests/Visual/TestSceneOsuGame.cs +++ b/osu.Game.Tests/Visual/TestSceneOsuGame.cs @@ -48,7 +48,7 @@ namespace osu.Game.Tests.Visual typeof(OnScreenDisplay), typeof(NotificationOverlay), typeof(DirectOverlay), - typeof(SocialOverlay), + typeof(DashboardOverlay), typeof(ChannelManager), typeof(ChatOverlay), typeof(SettingsOverlay), diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs index 39a0e6f6d4..a1822a1163 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs @@ -5,7 +5,7 @@ using System; namespace osu.Game.Beatmaps.ControlPoints { - public abstract class ControlPoint : IComparable, IEquatable + public abstract class ControlPoint : IComparable { /// /// The time at which the control point takes effect. @@ -19,12 +19,10 @@ namespace osu.Game.Beatmaps.ControlPoints public int CompareTo(ControlPoint other) => Time.CompareTo(other.Time); /// - /// Whether this control point is equivalent to another, ignoring time. + /// Determines whether this results in a meaningful change when placed alongside another. /// - /// Another control point to compare with. - /// Whether equivalent. - public abstract bool EquivalentTo(ControlPoint other); - - public bool Equals(ControlPoint other) => Time == other?.Time && EquivalentTo(other); + /// An existing control point to compare with. + /// Whether this is redundant when placed alongside . + public abstract bool IsRedundant(ControlPoint existing); } } diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs index df68d8acd2..d33a922a32 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs @@ -247,7 +247,7 @@ namespace osu.Game.Beatmaps.ControlPoints break; } - return existing?.EquivalentTo(newPoint) == true; + return newPoint?.IsRedundant(existing) == true; } private void groupItemAdded(ControlPoint controlPoint) diff --git a/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs index 8b21098a51..2448b2b25c 100644 --- a/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs @@ -27,7 +27,8 @@ namespace osu.Game.Beatmaps.ControlPoints set => SpeedMultiplierBindable.Value = value; } - public override bool EquivalentTo(ControlPoint other) => - other is DifficultyControlPoint otherTyped && otherTyped.SpeedMultiplier.Equals(SpeedMultiplier); + public override bool IsRedundant(ControlPoint existing) + => existing is DifficultyControlPoint existingDifficulty + && SpeedMultiplier == existingDifficulty.SpeedMultiplier; } } diff --git a/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs index 369b93ff3d..9b69147468 100644 --- a/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs @@ -35,8 +35,10 @@ namespace osu.Game.Beatmaps.ControlPoints set => KiaiModeBindable.Value = value; } - public override bool EquivalentTo(ControlPoint other) => - other is EffectControlPoint otherTyped && - KiaiMode == otherTyped.KiaiMode && OmitFirstBarLine == otherTyped.OmitFirstBarLine; + public override bool IsRedundant(ControlPoint existing) + => !OmitFirstBarLine + && existing is EffectControlPoint existingEffect + && KiaiMode == existingEffect.KiaiMode + && OmitFirstBarLine == existingEffect.OmitFirstBarLine; } } diff --git a/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs index 393bcfdb3c..61851a00d7 100644 --- a/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs @@ -68,8 +68,9 @@ namespace osu.Game.Beatmaps.ControlPoints return newSampleInfo; } - public override bool EquivalentTo(ControlPoint other) => - other is SampleControlPoint otherTyped && - SampleBank == otherTyped.SampleBank && SampleVolume == otherTyped.SampleVolume; + public override bool IsRedundant(ControlPoint existing) + => existing is SampleControlPoint existingSample + && SampleBank == existingSample.SampleBank + && SampleVolume == existingSample.SampleVolume; } } diff --git a/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs index 51b3377394..1927dd6575 100644 --- a/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/TimingControlPoint.cs @@ -48,8 +48,7 @@ namespace osu.Game.Beatmaps.ControlPoints /// public double BPM => 60000 / BeatLength; - public override bool EquivalentTo(ControlPoint other) => - other is TimingControlPoint otherTyped - && TimeSignature == otherTyped.TimeSignature && BeatLength.Equals(otherTyped.BeatLength); + // Timing points are never redundant as they can change the time signature. + public override bool IsRedundant(ControlPoint existing) => false; } } diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs index 12f2c58e35..fe63eec3f9 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs @@ -187,27 +187,13 @@ namespace osu.Game.Beatmaps.Formats writer.WriteLine("[HitObjects]"); + // TODO: implement other legacy rulesets switch (beatmap.BeatmapInfo.RulesetID) { case 0: foreach (var h in beatmap.HitObjects) handleOsuHitObject(writer, h); break; - - case 1: - foreach (var h in beatmap.HitObjects) - handleTaikoHitObject(writer, h); - break; - - case 2: - foreach (var h in beatmap.HitObjects) - handleCatchHitObject(writer, h); - break; - - case 3: - foreach (var h in beatmap.HitObjects) - handleManiaHitObject(writer, h); - break; } } @@ -328,12 +314,6 @@ namespace osu.Game.Beatmaps.Formats } } - private void handleTaikoHitObject(TextWriter writer, HitObject hitObject) => throw new NotImplementedException(); - - private void handleCatchHitObject(TextWriter writer, HitObject hitObject) => throw new NotImplementedException(); - - private void handleManiaHitObject(TextWriter writer, HitObject hitObject) => throw new NotImplementedException(); - private string getSampleBank(IList samples, bool banksOnly = false, bool zeroBanks = false) { LegacySampleBank normalBank = toLegacySampleBank(samples.SingleOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL)?.Bank); diff --git a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs index 5b2b213322..6406bd88a5 100644 --- a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs @@ -150,7 +150,8 @@ namespace osu.Game.Beatmaps.Formats HitObjects, Variables, Fonts, - Mania + CatchTheBeat, + Mania, } internal class LegacyDifficultyControlPoint : DifficultyControlPoint @@ -178,9 +179,10 @@ namespace osu.Game.Beatmaps.Formats return baseInfo; } - public override bool EquivalentTo(ControlPoint other) => - base.EquivalentTo(other) && other is LegacySampleControlPoint otherTyped && - CustomSampleBank == otherTyped.CustomSampleBank; + public override bool IsRedundant(ControlPoint existing) + => base.IsRedundant(existing) + && existing is LegacySampleControlPoint existingSample + && CustomSampleBank == existingSample.CustomSampleBank; } } } diff --git a/osu.Game/Graphics/Containers/BeatSyncedContainer.cs b/osu.Game/Graphics/Containers/BeatSyncedContainer.cs index f36079682e..5a613d1a54 100644 --- a/osu.Game/Graphics/Containers/BeatSyncedContainer.cs +++ b/osu.Game/Graphics/Containers/BeatSyncedContainer.cs @@ -103,7 +103,7 @@ namespace osu.Game.Graphics.Containers TimeSinceLastBeat = beatLength - TimeUntilNextBeat; - if (timingPoint.Equals(lastTimingPoint) && beatIndex == lastBeat) + if (timingPoint == lastTimingPoint && beatIndex == lastBeat) return; using (BeginDelayedSequence(-TimeSinceLastBeat, true)) diff --git a/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs b/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs index e83d899469..94edc33099 100644 --- a/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs +++ b/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs @@ -62,6 +62,14 @@ namespace osu.Game.Input.Bindings store.KeyBindingChanged -= ReloadMappings; } - protected override void ReloadMappings() => KeyBindings = store.Query(ruleset?.ID, variant).ToList(); + protected override void ReloadMappings() + { + if (ruleset != null && !ruleset.ID.HasValue) + // if the provided ruleset is not stored to the database, we have no way to retrieve custom bindings. + // fallback to defaults instead. + KeyBindings = DefaultKeyBindings; + else + KeyBindings = store.Query(ruleset?.ID, variant).ToList(); + } } } diff --git a/osu.Game/Online/API/APIRequest.cs b/osu.Game/Online/API/APIRequest.cs index 47600e4f68..0bba04cac3 100644 --- a/osu.Game/Online/API/APIRequest.cs +++ b/osu.Game/Online/API/APIRequest.cs @@ -18,24 +18,32 @@ namespace osu.Game.Online.API public T Result { get; private set; } - protected APIRequest() - { - base.Success += () => TriggerSuccess(((OsuJsonWebRequest)WebRequest)?.ResponseObject); - } - /// /// Invoked on successful completion of an API request. /// This will be scheduled to the API's internal scheduler (run on update thread automatically). /// public new event APISuccessHandler Success; + protected override void PostProcess() + { + base.PostProcess(); + Result = ((OsuJsonWebRequest)WebRequest)?.ResponseObject; + } + internal void TriggerSuccess(T result) { if (Result != null) throw new InvalidOperationException("Attempted to trigger success more than once"); Result = result; - Success?.Invoke(result); + + TriggerSuccess(); + } + + internal override void TriggerSuccess() + { + base.TriggerSuccess(); + Success?.Invoke(Result); } } @@ -99,6 +107,8 @@ namespace osu.Game.Online.API if (checkAndScheduleFailure()) return; + PostProcess(); + API.Schedule(delegate { if (cancelled) return; @@ -107,7 +117,14 @@ namespace osu.Game.Online.API }); } - internal void TriggerSuccess() + /// + /// Perform any post-processing actions after a successful request. + /// + protected virtual void PostProcess() + { + } + + internal virtual void TriggerSuccess() { Success?.Invoke(); } diff --git a/osu.Game/Online/Chat/ChannelManager.cs b/osu.Game/Online/Chat/ChannelManager.cs index 2c37216fd6..822f628dd2 100644 --- a/osu.Game/Online/Chat/ChannelManager.cs +++ b/osu.Game/Online/Chat/ChannelManager.cs @@ -18,7 +18,7 @@ namespace osu.Game.Online.Chat /// /// Manages everything channel related /// - public class ChannelManager : PollingComponent + public class ChannelManager : PollingComponent, IChannelPostTarget { /// /// The channels the player joins on startup @@ -204,6 +204,10 @@ namespace osu.Game.Online.Chat switch (command) { + case "np": + AddInternal(new NowPlayingCommand()); + break; + case "me": if (string.IsNullOrWhiteSpace(content)) { @@ -234,7 +238,7 @@ namespace osu.Game.Online.Chat break; case "help": - target.AddNewMessages(new InfoMessage("Supported commands: /help, /me [action], /join [channel]")); + target.AddNewMessages(new InfoMessage("Supported commands: /help, /me [action], /join [channel], /np")); break; default: diff --git a/osu.Game/Online/Chat/IChannelPostTarget.cs b/osu.Game/Online/Chat/IChannelPostTarget.cs new file mode 100644 index 0000000000..5697e918f0 --- /dev/null +++ b/osu.Game/Online/Chat/IChannelPostTarget.cs @@ -0,0 +1,19 @@ +// 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; + +namespace osu.Game.Online.Chat +{ + [Cached(typeof(IChannelPostTarget))] + public interface IChannelPostTarget + { + /// + /// Posts a message to the currently opened channel. + /// + /// The message text that is going to be posted + /// Is true if the message is an action, e.g.: user is currently eating + /// An optional target channel. If null, will be used. + void PostMessage(string text, bool isAction = false, Channel target = null); + } +} diff --git a/osu.Game/Online/Chat/NowPlayingCommand.cs b/osu.Game/Online/Chat/NowPlayingCommand.cs new file mode 100644 index 0000000000..c0b54812b6 --- /dev/null +++ b/osu.Game/Online/Chat/NowPlayingCommand.cs @@ -0,0 +1,55 @@ +// 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.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Users; + +namespace osu.Game.Online.Chat +{ + public class NowPlayingCommand : Component + { + [Resolved] + private IChannelPostTarget channelManager { get; set; } + + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private Bindable currentBeatmap { get; set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + string verb; + BeatmapInfo beatmap; + + switch (api.Activity.Value) + { + case UserActivity.SoloGame solo: + verb = "playing"; + beatmap = solo.Beatmap; + break; + + case UserActivity.Editing edit: + verb = "editing"; + beatmap = edit.Beatmap; + break; + + default: + verb = "listening to"; + beatmap = currentBeatmap.Value.BeatmapInfo; + break; + } + + var beatmapString = beatmap.OnlineBeatmapID.HasValue ? $"[https://osu.ppy.sh/b/{beatmap.OnlineBeatmapID} {beatmap}]" : beatmap.ToString(); + + channelManager.PostMessage($"is {verb} {beatmapString}", true); + Expire(); + } + } +} diff --git a/osu.Game/Online/PollingComponent.cs b/osu.Game/Online/PollingComponent.cs index acbb2c39f4..228f147835 100644 --- a/osu.Game/Online/PollingComponent.cs +++ b/osu.Game/Online/PollingComponent.cs @@ -3,7 +3,7 @@ using System; using System.Threading.Tasks; -using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Threading; namespace osu.Game.Online @@ -11,7 +11,7 @@ namespace osu.Game.Online /// /// A component which requires a constant polling process. /// - public abstract class PollingComponent : Component + public abstract class PollingComponent : CompositeDrawable // switch away from Component because InternalChildren are used in usages. { private double? lastTimePolled; diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 5e93d760e3..c861b84835 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -67,7 +67,7 @@ namespace osu.Game private DirectOverlay direct; - private SocialOverlay social; + private DashboardOverlay dashboard; private UserProfileOverlay userProfile; @@ -611,7 +611,7 @@ namespace osu.Game //overlay elements loadComponentSingleFile(direct = new DirectOverlay(), overlayContent.Add, true); - loadComponentSingleFile(social = new SocialOverlay(), overlayContent.Add, true); + loadComponentSingleFile(dashboard = new DashboardOverlay(), overlayContent.Add, true); var rankingsOverlay = loadComponentSingleFile(new RankingsOverlay(), overlayContent.Add, true); loadComponentSingleFile(channelManager = new ChannelManager(), AddInternal, true); loadComponentSingleFile(chatOverlay = new ChatOverlay(), overlayContent.Add, true); @@ -670,7 +670,7 @@ namespace osu.Game } // ensure only one of these overlays are open at once. - var singleDisplayOverlays = new OverlayContainer[] { chatOverlay, social, direct, changelogOverlay, rankingsOverlay }; + var singleDisplayOverlays = new OverlayContainer[] { chatOverlay, dashboard, direct, changelogOverlay, rankingsOverlay }; foreach (var overlay in singleDisplayOverlays) { @@ -842,7 +842,7 @@ namespace osu.Game return true; case GlobalAction.ToggleSocial: - social.ToggleVisibility(); + dashboard.ToggleVisibility(); return true; case GlobalAction.ResetInputSettings: diff --git a/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs b/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs new file mode 100644 index 0000000000..9ee679a866 --- /dev/null +++ b/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.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. + +namespace osu.Game.Overlays.Dashboard +{ + public class DashboardOverlayHeader : TabControlOverlayHeader + { + protected override OverlayTitle CreateTitle() => new DashboardTitle(); + + private class DashboardTitle : OverlayTitle + { + public DashboardTitle() + { + Title = "dashboard"; + IconTexture = "Icons/changelog"; + } + } + } + + public enum DashboardOverlayTabs + { + Friends + } +} diff --git a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs index 3c9b31daae..79fda99c73 100644 --- a/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs +++ b/osu.Game/Overlays/Dashboard/Friends/FriendDisplay.cs @@ -16,7 +16,7 @@ using osuTK; namespace osu.Game.Overlays.Dashboard.Friends { - public class FriendDisplay : CompositeDrawable + public class FriendDisplay : OverlayView> { private List users = new List(); @@ -26,34 +26,29 @@ namespace osu.Game.Overlays.Dashboard.Friends set { users = value; - onlineStreamControl.Populate(value); } } - [Resolved] - private IAPIProvider api { get; set; } - - private GetFriendsRequest request; private CancellationTokenSource cancellationToken; private Drawable currentContent; - private readonly FriendOnlineStreamControl onlineStreamControl; - private readonly Box background; - private readonly Box controlBackground; - private readonly UserListToolbar userListToolbar; - private readonly Container itemsPlaceholder; - private readonly LoadingLayer loading; + private FriendOnlineStreamControl onlineStreamControl; + private Box background; + private Box controlBackground; + private UserListToolbar userListToolbar; + private Container itemsPlaceholder; + private LoadingLayer loading; - public FriendDisplay() + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; InternalChild = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, Children = new Drawable[] { new Container @@ -134,11 +129,7 @@ namespace osu.Game.Overlays.Dashboard.Friends } } }; - } - [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) - { background.Colour = colourProvider.Background4; controlBackground.Colour = colourProvider.Background5; } @@ -152,14 +143,11 @@ namespace osu.Game.Overlays.Dashboard.Friends userListToolbar.SortCriteria.BindValueChanged(_ => recreatePanels()); } - public void Fetch() - { - if (!api.IsLoggedIn) - return; + protected override APIRequest> CreateRequest() => new GetFriendsRequest(); - request = new GetFriendsRequest(); - request.Success += response => Schedule(() => Users = response); - api.Queue(request); + protected override void OnSuccess(List response) + { + Users = response; } private void recreatePanels() @@ -258,9 +246,7 @@ namespace osu.Game.Overlays.Dashboard.Friends protected override void Dispose(bool isDisposing) { - request?.Cancel(); cancellationToken?.Cancel(); - base.Dispose(isDisposing); } } diff --git a/osu.Game/Overlays/DashboardOverlay.cs b/osu.Game/Overlays/DashboardOverlay.cs new file mode 100644 index 0000000000..a72c3f4fa5 --- /dev/null +++ b/osu.Game/Overlays/DashboardOverlay.cs @@ -0,0 +1,150 @@ +// 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.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Overlays.Dashboard; +using osu.Game.Overlays.Dashboard.Friends; + +namespace osu.Game.Overlays +{ + public class DashboardOverlay : FullscreenOverlay + { + private CancellationTokenSource cancellationToken; + + private Box background; + private Container content; + private DashboardOverlayHeader header; + private LoadingLayer loading; + private OverlayScrollContainer scrollFlow; + + public DashboardOverlay() + : base(OverlayColourScheme.Purple) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + scrollFlow = new OverlayScrollContainer + { + RelativeSizeAxes = Axes.Both, + ScrollbarVisible = false, + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + header = new DashboardOverlayHeader + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Depth = -float.MaxValue + }, + content = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + } + } + } + }, + loading = new LoadingLayer(content), + }; + + background.Colour = ColourProvider.Background5; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + header.Current.BindValueChanged(onTabChanged); + } + + private bool displayUpdateRequired = true; + + protected override void PopIn() + { + base.PopIn(); + + // We don't want to create a new display on every call, only when exiting from fully closed state. + if (displayUpdateRequired) + { + header.Current.TriggerChange(); + displayUpdateRequired = false; + } + } + + protected override void PopOutComplete() + { + base.PopOutComplete(); + loadDisplay(Empty()); + displayUpdateRequired = true; + } + + private void loadDisplay(Drawable display) + { + scrollFlow.ScrollToStart(); + + LoadComponentAsync(display, loaded => + { + if (API.IsLoggedIn) + loading.Hide(); + + content.Child = loaded; + }, (cancellationToken = new CancellationTokenSource()).Token); + } + + private void onTabChanged(ValueChangedEvent tab) + { + cancellationToken?.Cancel(); + loading.Show(); + + if (!API.IsLoggedIn) + { + loadDisplay(Empty()); + return; + } + + switch (tab.NewValue) + { + case DashboardOverlayTabs.Friends: + loadDisplay(new FriendDisplay()); + break; + + default: + throw new NotImplementedException($"Display for {tab.NewValue} tab is not implemented"); + } + } + + public override void APIStateChanged(IAPIProvider api, APIState state) + { + if (State.Value == Visibility.Hidden) + return; + + header.Current.TriggerChange(); + } + + protected override void Dispose(bool isDisposing) + { + cancellationToken?.Cancel(); + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Overlays/OverlayHeader.cs b/osu.Game/Overlays/OverlayHeader.cs index 4ac0f697c3..dbc934bde9 100644 --- a/osu.Game/Overlays/OverlayHeader.cs +++ b/osu.Game/Overlays/OverlayHeader.cs @@ -12,6 +12,8 @@ namespace osu.Game.Overlays { public abstract class OverlayHeader : Container { + public const int CONTENT_X_MARGIN = 50; + private readonly Box titleBackground; protected readonly FillFlowContainer HeaderInfo; @@ -54,7 +56,7 @@ namespace osu.Game.Overlays AutoSizeAxes = Axes.Y, Padding = new MarginPadding { - Horizontal = UserProfileOverlay.CONTENT_X_MARGIN, + Horizontal = CONTENT_X_MARGIN, }, Children = new[] { diff --git a/osu.Game/Overlays/OverlayView.cs b/osu.Game/Overlays/OverlayView.cs new file mode 100644 index 0000000000..3e2c54c726 --- /dev/null +++ b/osu.Game/Overlays/OverlayView.cs @@ -0,0 +1,79 @@ +// 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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.API; + +namespace osu.Game.Overlays +{ + /// + /// A subview containing online content, to be displayed inside a . + /// + /// + /// Automatically performs a data fetch on load. + /// + /// The type of the API response. + public abstract class OverlayView : CompositeDrawable, IOnlineComponent + where T : class + { + [Resolved] + protected IAPIProvider API { get; private set; } + + private APIRequest request; + + protected OverlayView() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + API.Register(this); + } + + /// + /// Create the API request for fetching data. + /// + protected abstract APIRequest CreateRequest(); + + /// + /// Fired when results arrive from the main API request. + /// + /// + protected abstract void OnSuccess(T response); + + /// + /// Force a re-request for data from the API. + /// + protected void PerformFetch() + { + request?.Cancel(); + + request = CreateRequest(); + request.Success += response => Schedule(() => OnSuccess(response)); + + API.Queue(request); + } + + public virtual void APIStateChanged(IAPIProvider api, APIState state) + { + switch (state) + { + case APIState.Online: + PerformFetch(); + break; + } + } + + protected override void Dispose(bool isDisposing) + { + request?.Cancel(); + API?.Unregister(this); + base.Dispose(isDisposing); + } + } +} diff --git a/osu.Game/Overlays/TabControlOverlayHeader.cs b/osu.Game/Overlays/TabControlOverlayHeader.cs index ab1a6aff78..e8e000f441 100644 --- a/osu.Game/Overlays/TabControlOverlayHeader.cs +++ b/osu.Game/Overlays/TabControlOverlayHeader.cs @@ -44,7 +44,7 @@ namespace osu.Game.Overlays }, TabControl = CreateTabControl().With(control => { - control.Margin = new MarginPadding { Left = UserProfileOverlay.CONTENT_X_MARGIN }; + control.Margin = new MarginPadding { Left = CONTENT_X_MARGIN }; control.Current = Current; }) } diff --git a/osu.Game/Overlays/Toolbar/ToolbarSocialButton.cs b/osu.Game/Overlays/Toolbar/ToolbarSocialButton.cs index 5e353d3319..f6646eb81d 100644 --- a/osu.Game/Overlays/Toolbar/ToolbarSocialButton.cs +++ b/osu.Game/Overlays/Toolbar/ToolbarSocialButton.cs @@ -14,9 +14,9 @@ namespace osu.Game.Overlays.Toolbar } [BackgroundDependencyLoader(true)] - private void load(SocialOverlay chat) + private void load(DashboardOverlay dashboard) { - StateContainer = chat; + StateContainer = dashboard; } } } diff --git a/osu.Game/Rulesets/Mods/ModHidden.cs b/osu.Game/Rulesets/Mods/ModHidden.cs index 4e4a75db82..a1915b974c 100644 --- a/osu.Game/Rulesets/Mods/ModHidden.cs +++ b/osu.Game/Rulesets/Mods/ModHidden.cs @@ -23,6 +23,13 @@ namespace osu.Game.Rulesets.Mods protected Bindable IncreaseFirstObjectVisibility = new Bindable(); + /// + /// Check whether the provided hitobject should be considered the "first" hideable object. + /// Can be used to skip spinners, for instance. + /// + /// The hitobject to check. + protected virtual bool IsFirstHideableObject(DrawableHitObject hitObject) => true; + public void ReadFromConfig(OsuConfigManager config) { IncreaseFirstObjectVisibility = config.GetBindable(OsuSetting.IncreaseFirstObjectVisibility); @@ -30,8 +37,11 @@ namespace osu.Game.Rulesets.Mods public virtual void ApplyToDrawableHitObjects(IEnumerable drawables) { - foreach (var d in drawables.Skip(IncreaseFirstObjectVisibility.Value ? 1 : 0)) - d.ApplyCustomUpdateState += ApplyHiddenState; + if (IncreaseFirstObjectVisibility.Value) + drawables = drawables.SkipWhile(h => !IsFirstHideableObject(h)).Skip(1); + + foreach (var dho in drawables) + dho.ApplyCustomUpdateState += ApplyHiddenState; } public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 5bc2e1aa56..478c46fb36 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -426,7 +426,7 @@ namespace osu.Game.Screens.Select } /// - /// selection has been changed as the result of a user interaction. + /// Selection has been changed as the result of a user interaction. /// private void performUpdateSelected() { @@ -435,7 +435,7 @@ namespace osu.Game.Screens.Select selectionChangedDebounce?.Cancel(); - if (beatmap == null) + if (beatmapNoDebounce == null) run(); else selectionChangedDebounce = Scheduler.AddDelayed(run, 200); @@ -448,9 +448,11 @@ namespace osu.Game.Screens.Select { Mods.Value = Array.Empty(); - // required to return once in order to have the carousel in a good state. - // if the ruleset changed, the rest of the selection update will happen via updateSelectedRuleset. - return; + // transferRulesetValue() may trigger a refilter. If the current selection does not match the new ruleset, we want to switch away from it. + // The default logic on WorkingBeatmap change is to switch to a matching ruleset (see workingBeatmapChanged()), but we don't want that here. + // We perform an early selection attempt and clear out the beatmap selection to avoid a second ruleset change (revert). + if (beatmap != null && !Carousel.SelectBeatmap(beatmap, false)) + beatmap = null; } // We may be arriving here due to another component changing the bindable Beatmap. @@ -714,7 +716,7 @@ namespace osu.Game.Screens.Select if (decoupledRuleset.Value?.Equals(Ruleset.Value) == true) return false; - Logger.Log($"decoupled ruleset transferred (\"{decoupledRuleset.Value}\" -> \"{Ruleset.Value}\""); + Logger.Log($"decoupled ruleset transferred (\"{decoupledRuleset.Value}\" -> \"{Ruleset.Value}\")"); rulesetNoDebounce = decoupledRuleset.Value = Ruleset.Value; // if we have a pending filter operation, we want to run it now. diff --git a/osu.Game/Skinning/LegacySkinDecoder.cs b/osu.Game/Skinning/LegacySkinDecoder.cs index 5d4b8de7ac..75b7ba28b9 100644 --- a/osu.Game/Skinning/LegacySkinDecoder.cs +++ b/osu.Game/Skinning/LegacySkinDecoder.cs @@ -44,6 +44,12 @@ namespace osu.Game.Skinning } break; + + // osu!catch section only has colour settings + // so no harm in handling the entire section + case Section.CatchTheBeat: + HandleColours(skin, line); + return; } if (!string.IsNullOrEmpty(pair.Key)) diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 76f7a030f9..35ee0864e1 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -19,11 +19,11 @@ - + - + diff --git a/osu.iOS.props b/osu.iOS.props index 7a487a6430..0200fca9a3 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -70,17 +70,17 @@ - + - + - +