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/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/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/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/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/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/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/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..5facb04117 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -23,7 +23,7 @@
-
+
diff --git a/osu.iOS.props b/osu.iOS.props
index 7a487a6430..dda1ee5c42 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -70,7 +70,7 @@
-
+
@@ -80,7 +80,7 @@
-
+