diff --git a/.idea/.idea.osu.Desktop/.idea/indexLayout.xml b/.idea/.idea.osu.Desktop/.idea/indexLayout.xml
index 27ba142e96..7b08163ceb 100644
--- a/.idea/.idea.osu.Desktop/.idea/indexLayout.xml
+++ b/.idea/.idea.osu.Desktop/.idea/indexLayout.xml
@@ -1,6 +1,6 @@
-
+
diff --git a/.idea/.idea.osu.Desktop/.idea/modules.xml b/.idea/.idea.osu.Desktop/.idea/modules.xml
deleted file mode 100644
index 680312ad27..0000000000
--- a/.idea/.idea.osu.Desktop/.idea/modules.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/osu.Android.props b/osu.Android.props
index cba3975209..196d122a2a 100644
--- a/osu.Android.props
+++ b/osu.Android.props
@@ -51,7 +51,7 @@
-
-
+
+
diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchReplay.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchReplay.cs
new file mode 100644
index 0000000000..a10371b0f7
--- /dev/null
+++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchReplay.cs
@@ -0,0 +1,53 @@
+// 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.Game.Beatmaps;
+using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Rulesets.Catch.Objects;
+using osu.Game.Rulesets.Catch.UI;
+
+namespace osu.Game.Rulesets.Catch.Tests
+{
+ public class TestSceneCatchReplay : TestSceneCatchPlayer
+ {
+ protected override bool Autoplay => true;
+
+ private const int object_count = 10;
+
+ [Test]
+ public void TestReplayCatcherPositionIsFramePerfect()
+ {
+ AddUntilStep("caught all fruits", () => Player.ScoreProcessor.Combo.Value == object_count);
+ }
+
+ protected override IBeatmap CreateBeatmap(RulesetInfo ruleset)
+ {
+ var beatmap = new Beatmap
+ {
+ BeatmapInfo =
+ {
+ Ruleset = ruleset,
+ }
+ };
+
+ beatmap.ControlPointInfo.Add(0, new TimingControlPoint());
+
+ for (int i = 0; i < object_count / 2; i++)
+ {
+ beatmap.HitObjects.Add(new Fruit
+ {
+ StartTime = (i + 1) * 1000,
+ X = 0
+ });
+ beatmap.HitObjects.Add(new Fruit
+ {
+ StartTime = (i + 1) * 1000 + 1,
+ X = CatchPlayfield.WIDTH
+ });
+ }
+
+ return beatmap;
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs
index 73420a9eda..0e1ef90737 100644
--- a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs
+++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs
@@ -51,8 +51,11 @@ namespace osu.Game.Rulesets.Catch.UI
{
droppedObjectContainer,
CatcherArea.MovableCatcher.CreateProxiedContent(),
- HitObjectContainer,
+ HitObjectContainer.CreateProxy(),
+ // This ordering (`CatcherArea` before `HitObjectContainer`) is important to
+ // make sure the up-to-date catcher position is used for the catcher catching logic of hit objects.
CatcherArea,
+ HitObjectContainer,
};
}
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs
index 738a21b17e..35b79aa8ac 100644
--- a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePathControlPointVisualiser.cs
@@ -4,9 +4,11 @@
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
+using osu.Framework.Graphics.UserInterface;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Tests.Visual;
@@ -14,7 +16,7 @@ using osuTK;
namespace osu.Game.Rulesets.Osu.Tests.Editor
{
- public class TestScenePathControlPointVisualiser : OsuTestScene
+ public class TestScenePathControlPointVisualiser : OsuManualInputManagerTestScene
{
private Slider slider;
private PathControlPointVisualiser visualiser;
@@ -43,12 +45,145 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
});
}
+ [Test]
+ public void TestPerfectCurveTooManyPoints()
+ {
+ createVisualiser(true);
+
+ addControlPointStep(new Vector2(200), PathType.Bezier);
+ addControlPointStep(new Vector2(300));
+ addControlPointStep(new Vector2(500, 300));
+ addControlPointStep(new Vector2(700, 200));
+ addControlPointStep(new Vector2(500, 100));
+
+ // Must be both hovering and selecting the control point for the context menu to work.
+ moveMouseToControlPoint(1);
+ AddStep("select control point", () => visualiser.Pieces[1].IsSelected.Value = true);
+ addContextMenuItemStep("Perfect curve");
+
+ assertControlPointPathType(0, PathType.Bezier);
+ assertControlPointPathType(1, PathType.PerfectCurve);
+ assertControlPointPathType(3, PathType.Bezier);
+ }
+
+ [Test]
+ public void TestPerfectCurveLastThreePoints()
+ {
+ createVisualiser(true);
+
+ addControlPointStep(new Vector2(200), PathType.Bezier);
+ addControlPointStep(new Vector2(300));
+ addControlPointStep(new Vector2(500, 300));
+ addControlPointStep(new Vector2(700, 200));
+ addControlPointStep(new Vector2(500, 100));
+
+ moveMouseToControlPoint(2);
+ AddStep("select control point", () => visualiser.Pieces[2].IsSelected.Value = true);
+ addContextMenuItemStep("Perfect curve");
+
+ assertControlPointPathType(0, PathType.Bezier);
+ assertControlPointPathType(2, PathType.PerfectCurve);
+ assertControlPointPathType(4, null);
+ }
+
+ [Test]
+ public void TestPerfectCurveLastTwoPoints()
+ {
+ createVisualiser(true);
+
+ addControlPointStep(new Vector2(200), PathType.Bezier);
+ addControlPointStep(new Vector2(300));
+ addControlPointStep(new Vector2(500, 300));
+ addControlPointStep(new Vector2(700, 200));
+ addControlPointStep(new Vector2(500, 100));
+
+ moveMouseToControlPoint(3);
+ AddStep("select control point", () => visualiser.Pieces[3].IsSelected.Value = true);
+ addContextMenuItemStep("Perfect curve");
+
+ assertControlPointPathType(0, PathType.Bezier);
+ AddAssert("point 3 is not inherited", () => slider.Path.ControlPoints[3].Type != null);
+ }
+
+ [Test]
+ public void TestPerfectCurveTooManyPointsLinear()
+ {
+ createVisualiser(true);
+
+ addControlPointStep(new Vector2(200), PathType.Linear);
+ addControlPointStep(new Vector2(300));
+ addControlPointStep(new Vector2(500, 300));
+ addControlPointStep(new Vector2(700, 200));
+ addControlPointStep(new Vector2(500, 100));
+
+ // Must be both hovering and selecting the control point for the context menu to work.
+ moveMouseToControlPoint(1);
+ AddStep("select control point", () => visualiser.Pieces[1].IsSelected.Value = true);
+ addContextMenuItemStep("Perfect curve");
+
+ assertControlPointPathType(0, PathType.Linear);
+ assertControlPointPathType(1, PathType.PerfectCurve);
+ assertControlPointPathType(3, PathType.Linear);
+ }
+
+ [Test]
+ public void TestPerfectCurveChangeToBezier()
+ {
+ createVisualiser(true);
+
+ addControlPointStep(new Vector2(200), PathType.Bezier);
+ addControlPointStep(new Vector2(300), PathType.PerfectCurve);
+ addControlPointStep(new Vector2(500, 300));
+ addControlPointStep(new Vector2(700, 200), PathType.Bezier);
+ addControlPointStep(new Vector2(500, 100));
+
+ moveMouseToControlPoint(3);
+ AddStep("select control point", () => visualiser.Pieces[3].IsSelected.Value = true);
+ addContextMenuItemStep("Inherit");
+
+ assertControlPointPathType(0, PathType.Bezier);
+ assertControlPointPathType(1, PathType.Bezier);
+ assertControlPointPathType(3, null);
+ }
+
private void createVisualiser(bool allowSelection) => AddStep("create visualiser", () => Child = visualiser = new PathControlPointVisualiser(slider, allowSelection)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre
});
- private void addControlPointStep(Vector2 position) => AddStep($"add control point {position}", () => slider.Path.ControlPoints.Add(new PathControlPoint(position)));
+ private void addControlPointStep(Vector2 position) => addControlPointStep(position, null);
+
+ private void addControlPointStep(Vector2 position, PathType? type)
+ {
+ AddStep($"add {type} control point at {position}", () =>
+ {
+ slider.Path.ControlPoints.Add(new PathControlPoint(position, type));
+ });
+ }
+
+ private void moveMouseToControlPoint(int index)
+ {
+ AddStep($"move mouse to control point {index}", () =>
+ {
+ Vector2 position = slider.Path.ControlPoints[index].Position.Value;
+ InputManager.MoveMouseTo(visualiser.Pieces[0].Parent.ToScreenSpace(position));
+ });
+ }
+
+ private void assertControlPointPathType(int controlPointIndex, PathType? type)
+ {
+ AddAssert($"point {controlPointIndex} is {type}", () => slider.Path.ControlPoints[controlPointIndex].Type.Value == type);
+ }
+
+ private void addContextMenuItemStep(string contextMenuText)
+ {
+ AddStep($"click context menu item \"{contextMenuText}\"", () =>
+ {
+ MenuItem item = visualiser.ContextMenuItems[1].Items.FirstOrDefault(menuItem => menuItem.Text.Value == contextMenuText);
+
+ item?.Action?.Value();
+ });
+ }
}
}
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderControlPointPiece.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderControlPointPiece.cs
new file mode 100644
index 0000000000..d7dfc3bd42
--- /dev/null
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderControlPointPiece.cs
@@ -0,0 +1,175 @@
+// 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.Utils;
+using osu.Game.Beatmaps;
+using osu.Game.Beatmaps.ControlPoints;
+using osu.Game.Rulesets.Objects;
+using osu.Game.Rulesets.Objects.Types;
+using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components;
+using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders;
+using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components;
+using osu.Game.Rulesets.Osu.Objects;
+using osu.Game.Rulesets.Osu.Objects.Drawables;
+using osu.Game.Tests.Visual;
+using osuTK;
+using osuTK.Input;
+
+namespace osu.Game.Rulesets.Osu.Tests.Editor
+{
+ public class TestSceneSliderControlPointPiece : SelectionBlueprintTestScene
+ {
+ private Slider slider;
+ private DrawableSlider drawableObject;
+
+ [SetUp]
+ public void Setup() => Schedule(() =>
+ {
+ Clear();
+
+ slider = new Slider
+ {
+ Position = new Vector2(256, 192),
+ Path = new SliderPath(new[]
+ {
+ new PathControlPoint(Vector2.Zero, PathType.PerfectCurve),
+ new PathControlPoint(new Vector2(150, 150)),
+ new PathControlPoint(new Vector2(300, 0), PathType.PerfectCurve),
+ new PathControlPoint(new Vector2(400, 0)),
+ new PathControlPoint(new Vector2(400, 150))
+ })
+ };
+
+ slider.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = 2 });
+
+ Add(drawableObject = new DrawableSlider(slider));
+ AddBlueprint(new TestSliderBlueprint(drawableObject));
+ });
+
+ [Test]
+ public void TestDragControlPoint()
+ {
+ moveMouseToControlPoint(1);
+ AddStep("hold", () => InputManager.PressButton(MouseButton.Left));
+
+ addMovementStep(new Vector2(150, 50));
+ AddStep("release", () => InputManager.ReleaseButton(MouseButton.Left));
+
+ assertControlPointPosition(1, new Vector2(150, 50));
+ assertControlPointType(0, PathType.PerfectCurve);
+ }
+
+ [Test]
+ public void TestDragControlPointAlmostLinearlyExterior()
+ {
+ moveMouseToControlPoint(1);
+ AddStep("hold", () => InputManager.PressButton(MouseButton.Left));
+
+ addMovementStep(new Vector2(400, 0.01f));
+ AddStep("release", () => InputManager.ReleaseButton(MouseButton.Left));
+
+ assertControlPointPosition(1, new Vector2(400, 0.01f));
+ assertControlPointType(0, PathType.Bezier);
+ }
+
+ [Test]
+ public void TestDragControlPointPathRecovery()
+ {
+ moveMouseToControlPoint(1);
+ AddStep("hold", () => InputManager.PressButton(MouseButton.Left));
+
+ addMovementStep(new Vector2(400, 0.01f));
+ assertControlPointType(0, PathType.Bezier);
+
+ addMovementStep(new Vector2(150, 50));
+ AddStep("release", () => InputManager.ReleaseButton(MouseButton.Left));
+
+ assertControlPointPosition(1, new Vector2(150, 50));
+ assertControlPointType(0, PathType.PerfectCurve);
+ }
+
+ [Test]
+ public void TestDragControlPointPathRecoveryOtherSegment()
+ {
+ moveMouseToControlPoint(4);
+ AddStep("hold", () => InputManager.PressButton(MouseButton.Left));
+
+ addMovementStep(new Vector2(350, 0.01f));
+ assertControlPointType(2, PathType.Bezier);
+
+ addMovementStep(new Vector2(150, 150));
+ AddStep("release", () => InputManager.ReleaseButton(MouseButton.Left));
+
+ assertControlPointPosition(4, new Vector2(150, 150));
+ assertControlPointType(2, PathType.PerfectCurve);
+ }
+
+ [Test]
+ public void TestDragControlPointPathAfterChangingType()
+ {
+ AddStep("change type to bezier", () => slider.Path.ControlPoints[2].Type.Value = PathType.Bezier);
+ AddStep("add point", () => slider.Path.ControlPoints.Add(new PathControlPoint(new Vector2(500, 10))));
+ AddStep("change type to perfect", () => slider.Path.ControlPoints[3].Type.Value = PathType.PerfectCurve);
+
+ moveMouseToControlPoint(4);
+ AddStep("hold", () => InputManager.PressButton(MouseButton.Left));
+
+ assertControlPointType(3, PathType.PerfectCurve);
+
+ addMovementStep(new Vector2(350, 0.01f));
+ AddStep("release", () => InputManager.ReleaseButton(MouseButton.Left));
+
+ assertControlPointPosition(4, new Vector2(350, 0.01f));
+ assertControlPointType(3, PathType.Bezier);
+ }
+
+ private void addMovementStep(Vector2 relativePosition)
+ {
+ AddStep($"move mouse to {relativePosition}", () =>
+ {
+ Vector2 position = slider.Position + relativePosition;
+ InputManager.MoveMouseTo(drawableObject.Parent.ToScreenSpace(position));
+ });
+ }
+
+ private void moveMouseToControlPoint(int index)
+ {
+ AddStep($"move mouse to control point {index}", () =>
+ {
+ Vector2 position = slider.Position + slider.Path.ControlPoints[index].Position.Value;
+ InputManager.MoveMouseTo(drawableObject.Parent.ToScreenSpace(position));
+ });
+ }
+
+ private void assertControlPointType(int index, PathType type) => AddAssert($"control point {index} is {type}", () => slider.Path.ControlPoints[index].Type.Value == type);
+
+ private void assertControlPointPosition(int index, Vector2 position) =>
+ AddAssert($"control point {index} at {position}", () => Precision.AlmostEquals(position, slider.Path.ControlPoints[index].Position.Value, 1));
+
+ private class TestSliderBlueprint : SliderSelectionBlueprint
+ {
+ public new SliderBodyPiece BodyPiece => base.BodyPiece;
+ public new TestSliderCircleBlueprint HeadBlueprint => (TestSliderCircleBlueprint)base.HeadBlueprint;
+ public new TestSliderCircleBlueprint TailBlueprint => (TestSliderCircleBlueprint)base.TailBlueprint;
+ public new PathControlPointVisualiser ControlPointVisualiser => base.ControlPointVisualiser;
+
+ public TestSliderBlueprint(DrawableSlider slider)
+ : base(slider)
+ {
+ }
+
+ protected override SliderCircleSelectionBlueprint CreateCircleSelectionBlueprint(DrawableSlider slider, SliderPosition position) => new TestSliderCircleBlueprint(slider, position);
+ }
+
+ private class TestSliderCircleBlueprint : SliderCircleSelectionBlueprint
+ {
+ public new HitCirclePiece CirclePiece => base.CirclePiece;
+
+ public TestSliderCircleBlueprint(DrawableSlider slider, SliderPosition position)
+ : base(slider, position)
+ {
+ }
+ }
+ }
+}
diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs
index 67a2e5a47c..d2c37061f0 100644
--- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs
+++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderPlacementBlueprint.cs
@@ -276,6 +276,104 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
assertControlPointType(0, PathType.Linear);
}
+ [Test]
+ public void TestPlacePerfectCurveSegmentAlmostLinearlyExterior()
+ {
+ Vector2 startPosition = new Vector2(200);
+
+ addMovementStep(startPosition);
+ addClickStep(MouseButton.Left);
+
+ addMovementStep(startPosition + new Vector2(300, 0));
+ addClickStep(MouseButton.Left);
+
+ addMovementStep(startPosition + new Vector2(150, 0.1f));
+ addClickStep(MouseButton.Right);
+
+ assertPlaced(true);
+ assertControlPointCount(3);
+ assertControlPointType(0, PathType.Bezier);
+ }
+
+ [Test]
+ public void TestPlacePerfectCurveSegmentRecovery()
+ {
+ Vector2 startPosition = new Vector2(200);
+
+ addMovementStep(startPosition);
+ addClickStep(MouseButton.Left);
+
+ addMovementStep(startPosition + new Vector2(300, 0));
+ addClickStep(MouseButton.Left);
+
+ addMovementStep(startPosition + new Vector2(150, 0.1f)); // Should convert to bezier
+ addMovementStep(startPosition + new Vector2(400.0f, 50.0f)); // Should convert back to perfect
+ addClickStep(MouseButton.Right);
+
+ assertPlaced(true);
+ assertControlPointCount(3);
+ assertControlPointType(0, PathType.PerfectCurve);
+ }
+
+ [Test]
+ public void TestPlacePerfectCurveSegmentLarge()
+ {
+ Vector2 startPosition = new Vector2(400);
+
+ addMovementStep(startPosition);
+ addClickStep(MouseButton.Left);
+
+ addMovementStep(startPosition + new Vector2(220, 220));
+ addClickStep(MouseButton.Left);
+
+ // Playfield dimensions are 640 x 480.
+ // So a 440 x 440 bounding box should be ok.
+ addMovementStep(startPosition + new Vector2(-220, 220));
+ addClickStep(MouseButton.Right);
+
+ assertPlaced(true);
+ assertControlPointCount(3);
+ assertControlPointType(0, PathType.PerfectCurve);
+ }
+
+ [Test]
+ public void TestPlacePerfectCurveSegmentTooLarge()
+ {
+ Vector2 startPosition = new Vector2(480, 200);
+
+ addMovementStep(startPosition);
+ addClickStep(MouseButton.Left);
+
+ addMovementStep(startPosition + new Vector2(400, 400));
+ addClickStep(MouseButton.Left);
+
+ // Playfield dimensions are 640 x 480.
+ // So an 800 * 800 bounding box area should not be ok.
+ addMovementStep(startPosition + new Vector2(-400, 400));
+ addClickStep(MouseButton.Right);
+
+ assertPlaced(true);
+ assertControlPointCount(3);
+ assertControlPointType(0, PathType.Bezier);
+ }
+
+ [Test]
+ public void TestPlacePerfectCurveSegmentCompleteArc()
+ {
+ addMovementStep(new Vector2(400));
+ addClickStep(MouseButton.Left);
+
+ addMovementStep(new Vector2(600, 400));
+ addClickStep(MouseButton.Left);
+
+ addMovementStep(new Vector2(400, 410));
+ addClickStep(MouseButton.Right);
+
+ assertPlaced(true);
+ assertControlPointCount(3);
+ assertControlPointType(0, PathType.PerfectCurve);
+ }
+
private void addMovementStep(Vector2 position) => AddStep($"move mouse to {position}", () => InputManager.MoveMouseTo(InputManager.ToScreenSpace(position)));
private void addClickStep(MouseButton button)
diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs
index 1390675a1a..6b78cff33e 100644
--- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs
+++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointPiece.cs
@@ -2,14 +2,18 @@
// See the LICENCE file in the repository root for full licence text.
using System;
+using System.Collections.Generic;
+using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor;
+using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
+using osu.Framework.Utils;
using osu.Game.Graphics;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
@@ -28,6 +32,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
public class PathControlPointPiece : BlueprintPiece, IHasTooltip
{
public Action RequestSelection;
+ public List PointsInSegment;
public readonly BindableBool IsSelected = new BindableBool();
public readonly PathControlPoint ControlPoint;
@@ -54,6 +59,12 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
this.slider = slider;
ControlPoint = controlPoint;
+ slider.Path.Version.BindValueChanged(_ =>
+ {
+ PointsInSegment = slider.Path.PointsInSegment(ControlPoint);
+ updatePathType();
+ }, runOnceImmediately: true);
+
controlPoint.Type.BindValueChanged(_ => updateMarkerDisplay());
Origin = Anchor.Centre;
@@ -150,6 +161,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
protected override bool OnClick(ClickEvent e) => RequestSelection != null;
private Vector2 dragStartPosition;
+ private PathType? dragPathType;
protected override bool OnDragStart(DragStartEvent e)
{
@@ -159,6 +171,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
if (e.Button == MouseButton.Left)
{
dragStartPosition = ControlPoint.Position.Value;
+ dragPathType = PointsInSegment[0].Type.Value;
+
changeHandler?.BeginChange();
return true;
}
@@ -184,10 +198,33 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
}
else
ControlPoint.Position.Value = dragStartPosition + (e.MousePosition - e.MouseDownPosition);
+
+ // Maintain the path type in case it got defaulted to bezier at some point during the drag.
+ PointsInSegment[0].Type.Value = dragPathType;
}
protected override void OnDragEnd(DragEndEvent e) => changeHandler?.EndChange();
+ ///
+ /// Handles correction of invalid path types.
+ ///
+ private void updatePathType()
+ {
+ if (ControlPoint.Type.Value != PathType.PerfectCurve)
+ return;
+
+ if (PointsInSegment.Count > 3)
+ ControlPoint.Type.Value = PathType.Bezier;
+
+ if (PointsInSegment.Count != 3)
+ return;
+
+ ReadOnlySpan points = PointsInSegment.Select(p => p.Position.Value).ToArray();
+ RectangleF boundingBox = PathApproximator.CircularArcBoundingBox(points);
+ if (boundingBox.Width >= 640 || boundingBox.Height >= 480)
+ ControlPoint.Type.Value = PathType.Bezier;
+ }
+
///
/// Updates the state of the circular control point marker.
///
diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs
index ce5dc4855e..44c3056910 100644
--- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs
+++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs
@@ -153,6 +153,34 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
}
}
+ ///
+ /// Attempts to set the given control point piece to the given path type.
+ /// If that would fail, try to change the path such that it instead succeeds
+ /// in a UX-friendly way.
+ ///
+ /// The control point piece that we want to change the path type of.
+ /// The path type we want to assign to the given control point piece.
+ private void updatePathType(PathControlPointPiece piece, PathType? type)
+ {
+ int indexInSegment = piece.PointsInSegment.IndexOf(piece.ControlPoint);
+
+ switch (type)
+ {
+ case PathType.PerfectCurve:
+ // Can't always create a circular arc out of 4 or more points,
+ // so we split the segment into one 3-point circular arc segment
+ // and one segment of the previous type.
+ int thirdPointIndex = indexInSegment + 2;
+
+ if (piece.PointsInSegment.Count > thirdPointIndex + 1)
+ piece.PointsInSegment[thirdPointIndex].Type.Value = piece.PointsInSegment[0].Type.Value;
+
+ break;
+ }
+
+ piece.ControlPoint.Type.Value = type;
+ }
+
[Resolved(CanBeNull = true)]
private IEditorChangeHandler changeHandler { get; set; }
@@ -218,7 +246,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
var item = new PathTypeMenuItem(type, () =>
{
foreach (var p in Pieces.Where(p => p.IsSelected.Value))
- p.ControlPoint.Type.Value = type;
+ updatePathType(p, type);
});
if (countOfState == totalCount)
diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs
index b71e1914f7..16e2a52279 100644
--- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs
+++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs
@@ -142,6 +142,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
{
base.Update();
updateSlider();
+
+ // Maintain the path type in case it got defaulted to bezier at some point during the drag.
+ updatePathType();
}
private void updatePathType()
diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs
index 82301bd37f..5a84bd6163 100644
--- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs
+++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs
@@ -33,6 +33,7 @@ namespace osu.Game.Rulesets.Osu.Edit
{
base.OnOperationEnded();
referenceOrigin = null;
+ referencePathTypes = null;
}
public override bool HandleMovement(MoveSelectionEvent moveEvent)
@@ -53,6 +54,12 @@ namespace osu.Game.Rulesets.Osu.Edit
///
private Vector2? referenceOrigin;
+ ///
+ /// During a transform, the initial path types of a single selected slider are stored so they
+ /// can be maintained throughout the operation.
+ ///
+ private List referencePathTypes;
+
public override bool HandleReverse()
{
var hitObjects = EditorBeatmap.SelectedHitObjects;
@@ -194,6 +201,8 @@ namespace osu.Game.Rulesets.Osu.Edit
private void scaleSlider(Slider slider, Vector2 scale)
{
+ referencePathTypes ??= slider.Path.ControlPoints.Select(p => p.Type.Value).ToList();
+
Quad sliderQuad = getSurroundingQuad(slider.Path.ControlPoints.Select(p => p.Position.Value));
// Limit minimum distance between control points after scaling to almost 0. Less than 0 causes the slider to flip, exactly 0 causes a crash through division by 0.
@@ -209,6 +218,10 @@ namespace osu.Game.Rulesets.Osu.Edit
point.Position.Value *= pathRelativeDeltaScale;
}
+ // Maintain the path types in case they were defaulted to bezier at some point during scaling
+ for (int i = 0; i < slider.Path.ControlPoints.Count; ++i)
+ slider.Path.ControlPoints[i].Type.Value = referencePathTypes[i];
+
//if sliderhead or sliderend end up outside playfield, revert scaling.
Quad scaledQuad = getSurroundingQuad(new OsuHitObject[] { slider });
(bool xInBounds, bool yInBounds) = isQuadInBounds(scaledQuad);
diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs
index 04708a5ece..82b5492de6 100644
--- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs
+++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs
@@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
+using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using osuTK;
@@ -108,16 +109,27 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
protected override void LoadSamples()
{
- base.LoadSamples();
+ // Note: base.LoadSamples() isn't called since the slider plays the tail's hitsounds for the time being.
- var firstSample = HitObject.Samples.FirstOrDefault();
-
- if (firstSample != null)
+ if (HitObject.SampleControlPoint == null)
{
- var clone = HitObject.SampleControlPoint.ApplyTo(firstSample).With("sliderslide");
-
- slidingSample.Samples = new ISampleInfo[] { clone };
+ throw new InvalidOperationException($"{nameof(HitObject)}s must always have an attached {nameof(HitObject.SampleControlPoint)}."
+ + $" This is an indication that {nameof(HitObject.ApplyDefaults)} has not been invoked on {this}.");
}
+
+ Samples.Samples = HitObject.TailSamples.Select(s => HitObject.SampleControlPoint.ApplyTo(s)).Cast().ToArray();
+
+ var slidingSamples = new List();
+
+ var normalSample = HitObject.Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL);
+ if (normalSample != null)
+ slidingSamples.Add(HitObject.SampleControlPoint.ApplyTo(normalSample).With("sliderslide"));
+
+ var whistleSample = HitObject.Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_WHISTLE);
+ if (whistleSample != null)
+ slidingSamples.Add(HitObject.SampleControlPoint.ApplyTo(whistleSample).With("sliderwhistle"));
+
+ slidingSample.Samples = slidingSamples.ToArray();
}
public override void StopAllSamples()
diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs
index e2b6c84896..8ba9597dc3 100644
--- a/osu.Game.Rulesets.Osu/Objects/Slider.cs
+++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs
@@ -81,6 +81,9 @@ namespace osu.Game.Rulesets.Osu.Objects
public List> NodeSamples { get; set; } = new List>();
+ [JsonIgnore]
+ public IList TailSamples { get; private set; }
+
private int repeatCount;
public int RepeatCount
@@ -143,11 +146,6 @@ namespace osu.Game.Rulesets.Osu.Objects
Velocity = scoringDistance / timingPoint.BeatLength;
TickDistance = scoringDistance / difficulty.SliderTickRate * TickDistanceMultiplier;
-
- // The samples should be attached to the slider tail, however this can only be done after LegacyLastTick is removed otherwise they would play earlier than they're intended to.
- // For now, the samples are attached to and played by the slider itself at the correct end time.
- // ToArray call is required as GetNodeSamples may fallback to Samples itself (without it it will get cleared due to the list reference being live).
- Samples = this.GetNodeSamples(repeatCount + 1).ToArray();
}
protected override void CreateNestedHitObjects(CancellationToken cancellationToken)
@@ -238,6 +236,10 @@ namespace osu.Game.Rulesets.Osu.Objects
if (HeadCircle != null)
HeadCircle.Samples = this.GetNodeSamples(0);
+
+ // The samples should be attached to the slider tail, however this can only be done after LegacyLastTick is removed otherwise they would play earlier than they're intended to.
+ // For now, the samples are played by the slider itself at the correct end time.
+ TailSamples = this.GetNodeSamples(repeatCount + 1);
}
public override Judgement CreateJudgement() => OnlyJudgeNestedObjects ? new OsuIgnoreJudgement() : new OsuJudgement();
diff --git a/osu.Game.Tests/NonVisual/LimitedCapacityStackTest.cs b/osu.Game.Tests/NonVisual/LimitedCapacityStackTest.cs
deleted file mode 100644
index d5ac38008e..0000000000
--- a/osu.Game.Tests/NonVisual/LimitedCapacityStackTest.cs
+++ /dev/null
@@ -1,115 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using System;
-using NUnit.Framework;
-using osu.Game.Rulesets.Difficulty.Utils;
-
-namespace osu.Game.Tests.NonVisual
-{
- [TestFixture]
- public class LimitedCapacityStackTest
- {
- private const int capacity = 3;
-
- private LimitedCapacityStack stack;
-
- [SetUp]
- public void Setup()
- {
- stack = new LimitedCapacityStack(capacity);
- }
-
- [Test]
- public void TestEmptyStack()
- {
- Assert.AreEqual(0, stack.Count);
-
- Assert.Throws(() =>
- {
- int unused = stack[0];
- });
-
- int count = 0;
- foreach (var unused in stack)
- count++;
-
- Assert.AreEqual(0, count);
- }
-
- [TestCase(1)]
- [TestCase(2)]
- [TestCase(3)]
- public void TestInRangeElements(int count)
- {
- // e.g. 0 -> 1 -> 2
- for (int i = 0; i < count; i++)
- stack.Push(i);
-
- Assert.AreEqual(count, stack.Count);
-
- // e.g. 2 -> 1 -> 0 (reverse order)
- for (int i = 0; i < stack.Count; i++)
- Assert.AreEqual(count - 1 - i, stack[i]);
-
- // e.g. indices 3, 4, 5, 6 (out of range)
- for (int i = stack.Count; i < stack.Count + capacity; i++)
- {
- Assert.Throws(() =>
- {
- int unused = stack[i];
- });
- }
- }
-
- [TestCase(4)]
- [TestCase(5)]
- [TestCase(6)]
- public void TestOverflowElements(int count)
- {
- // e.g. 0 -> 1 -> 2 -> 3
- for (int i = 0; i < count; i++)
- stack.Push(i);
-
- Assert.AreEqual(capacity, stack.Count);
-
- // e.g. 3 -> 2 -> 1 (reverse order)
- for (int i = 0; i < stack.Count; i++)
- Assert.AreEqual(count - 1 - i, stack[i]);
-
- // e.g. indices 3, 4, 5, 6 (out of range)
- for (int i = stack.Count; i < stack.Count + capacity; i++)
- {
- Assert.Throws(() =>
- {
- int unused = stack[i];
- });
- }
- }
-
- [TestCase(1)]
- [TestCase(2)]
- [TestCase(3)]
- [TestCase(4)]
- [TestCase(5)]
- [TestCase(6)]
- public void TestEnumerator(int count)
- {
- // e.g. 0 -> 1 -> 2 -> 3
- for (int i = 0; i < count; i++)
- stack.Push(i);
-
- int enumeratorCount = 0;
- int expectedValue = count - 1;
-
- foreach (var item in stack)
- {
- Assert.AreEqual(expectedValue, item);
- enumeratorCount++;
- expectedValue--;
- }
-
- Assert.AreEqual(stack.Count, enumeratorCount);
- }
- }
-}
diff --git a/osu.Game.Tests/NonVisual/ReverseQueueTest.cs b/osu.Game.Tests/NonVisual/ReverseQueueTest.cs
new file mode 100644
index 0000000000..93cd9403ce
--- /dev/null
+++ b/osu.Game.Tests/NonVisual/ReverseQueueTest.cs
@@ -0,0 +1,143 @@
+// 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 NUnit.Framework;
+using osu.Game.Rulesets.Difficulty.Utils;
+
+namespace osu.Game.Tests.NonVisual
+{
+ [TestFixture]
+ public class ReverseQueueTest
+ {
+ private ReverseQueue queue;
+
+ [SetUp]
+ public void Setup()
+ {
+ queue = new ReverseQueue(4);
+ }
+
+ [Test]
+ public void TestEmptyQueue()
+ {
+ Assert.AreEqual(0, queue.Count);
+
+ Assert.Throws(() =>
+ {
+ char unused = queue[0];
+ });
+
+ int count = 0;
+ foreach (var unused in queue)
+ count++;
+
+ Assert.AreEqual(0, count);
+ }
+
+ [Test]
+ public void TestEnqueue()
+ {
+ // Assert correct values and reverse index after enqueueing
+ queue.Enqueue('a');
+ queue.Enqueue('b');
+ queue.Enqueue('c');
+
+ Assert.AreEqual('c', queue[0]);
+ Assert.AreEqual('b', queue[1]);
+ Assert.AreEqual('a', queue[2]);
+
+ // Assert correct values and reverse index after enqueueing beyond initial capacity of 4
+ queue.Enqueue('d');
+ queue.Enqueue('e');
+ queue.Enqueue('f');
+
+ Assert.AreEqual('f', queue[0]);
+ Assert.AreEqual('e', queue[1]);
+ Assert.AreEqual('d', queue[2]);
+ Assert.AreEqual('c', queue[3]);
+ Assert.AreEqual('b', queue[4]);
+ Assert.AreEqual('a', queue[5]);
+ }
+
+ [Test]
+ public void TestDequeue()
+ {
+ queue.Enqueue('a');
+ queue.Enqueue('b');
+ queue.Enqueue('c');
+ queue.Enqueue('d');
+ queue.Enqueue('e');
+ queue.Enqueue('f');
+
+ // Assert correct item return and no longer in queue after dequeueing
+ Assert.AreEqual('a', queue[5]);
+ var dequeuedItem = queue.Dequeue();
+
+ Assert.AreEqual('a', dequeuedItem);
+ Assert.AreEqual(5, queue.Count);
+ Assert.AreEqual('f', queue[0]);
+ Assert.AreEqual('b', queue[4]);
+ Assert.Throws(() =>
+ {
+ char unused = queue[5];
+ });
+
+ // Assert correct state after enough enqueues and dequeues to wrap around array (queue.start = 0 again)
+ queue.Enqueue('g');
+ queue.Enqueue('h');
+ queue.Enqueue('i');
+ queue.Dequeue();
+ queue.Dequeue();
+ queue.Dequeue();
+ queue.Dequeue();
+ queue.Dequeue();
+ queue.Dequeue();
+ queue.Dequeue();
+
+ Assert.AreEqual(1, queue.Count);
+ Assert.AreEqual('i', queue[0]);
+ }
+
+ [Test]
+ public void TestClear()
+ {
+ queue.Enqueue('a');
+ queue.Enqueue('b');
+ queue.Enqueue('c');
+ queue.Enqueue('d');
+ queue.Enqueue('e');
+ queue.Enqueue('f');
+
+ // Assert queue is empty after clearing
+ queue.Clear();
+
+ Assert.AreEqual(0, queue.Count);
+ Assert.Throws(() =>
+ {
+ char unused = queue[0];
+ });
+ }
+
+ [Test]
+ public void TestEnumerator()
+ {
+ queue.Enqueue('a');
+ queue.Enqueue('b');
+ queue.Enqueue('c');
+ queue.Enqueue('d');
+ queue.Enqueue('e');
+ queue.Enqueue('f');
+
+ char[] expectedValues = { 'f', 'e', 'd', 'c', 'b', 'a' };
+ int expectedValueIndex = 0;
+
+ // Assert items are enumerated in correct order
+ foreach (var item in queue)
+ {
+ Assert.AreEqual(expectedValues[expectedValueIndex], item);
+ expectedValueIndex++;
+ }
+ }
+ }
+}
diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayMenuOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayMenuOverlay.cs
index 75e8194708..d69ac665cc 100644
--- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayMenuOverlay.cs
+++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplayMenuOverlay.cs
@@ -27,7 +27,7 @@ namespace osu.Game.Tests.Visual.Gameplay
[BackgroundDependencyLoader]
private void load(OsuGameBase game)
{
- Child = globalActionContainer = new GlobalActionContainer(game, null);
+ Child = globalActionContainer = new GlobalActionContainer(game);
}
[SetUp]
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs
new file mode 100644
index 0000000000..6b03b53b4b
--- /dev/null
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchFooter.cs
@@ -0,0 +1,27 @@
+// 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.Game.Online.Rooms;
+using osu.Game.Screens.OnlinePlay.Multiplayer.Match;
+
+namespace osu.Game.Tests.Visual.Multiplayer
+{
+ public class TestSceneMultiplayerMatchFooter : MultiplayerTestScene
+ {
+ [Cached]
+ private readonly OnlinePlayBeatmapAvailabilityTracker availablilityTracker = new OnlinePlayBeatmapAvailabilityTracker();
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ Child = new MultiplayerMatchFooter
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Height = 50
+ };
+ }
+ }
+}
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs
index 8869718fd1..839118de2f 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs
@@ -3,13 +3,21 @@
using System.Linq;
using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Audio;
+using osu.Framework.Platform;
using osu.Framework.Screens;
using osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
+using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.OnlinePlay.Multiplayer;
using osu.Game.Screens.OnlinePlay.Multiplayer.Match;
using osu.Game.Tests.Beatmaps;
+using osu.Game.Tests.Resources;
+using osu.Game.Users;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Multiplayer
@@ -18,11 +26,25 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
private MultiplayerMatchSubScreen screen;
+ private BeatmapManager beatmaps;
+ private RulesetStore rulesets;
+ private BeatmapSetInfo importedSet;
+
public TestSceneMultiplayerMatchSubScreen()
: base(false)
{
}
+ [BackgroundDependencyLoader]
+ private void load(GameHost host, AudioManager audio)
+ {
+ Dependencies.Cache(rulesets = new RulesetStore(ContextFactory));
+ Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, host, Beatmap.Default));
+ beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).Wait();
+
+ importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First();
+ }
+
[SetUp]
public new void Setup() => Schedule(() =>
{
@@ -71,7 +93,48 @@ namespace osu.Game.Tests.Visual.Multiplayer
InputManager.Click(MouseButton.Left);
});
- AddWaitStep("wait", 10);
+ AddUntilStep("wait for join", () => Client.Room != null);
+ }
+
+ [Test]
+ public void TestStartMatchWhileSpectating()
+ {
+ AddStep("set playlist", () =>
+ {
+ Room.Playlist.Add(new PlaylistItem
+ {
+ Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()).BeatmapInfo },
+ Ruleset = { Value = new OsuRuleset().RulesetInfo },
+ });
+ });
+
+ AddStep("click create button", () =>
+ {
+ InputManager.MoveMouseTo(this.ChildrenOfType().Single());
+ InputManager.Click(MouseButton.Left);
+ });
+
+ AddUntilStep("wait for room join", () => Client.Room != null);
+
+ AddStep("join other user (ready)", () =>
+ {
+ Client.AddUser(new User { Id = 55 });
+ Client.ChangeUserState(55, MultiplayerUserState.Ready);
+ });
+
+ AddStep("click spectate button", () =>
+ {
+ InputManager.MoveMouseTo(this.ChildrenOfType().Single());
+ InputManager.Click(MouseButton.Left);
+ });
+
+ AddStep("click ready button", () =>
+ {
+ InputManager.MoveMouseTo(this.ChildrenOfType().Single());
+ InputManager.Click(MouseButton.Left);
+ });
+
+ AddAssert("match started", () => Client.Room?.State == MultiplayerRoomState.WaitingForLoad);
}
}
}
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs
index e713cff233..7f8f04b718 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs
@@ -126,6 +126,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("ready mark invisible", () => !this.ChildrenOfType().Single().IsPresent);
}
+ [Test]
+ public void TestToggleSpectateState()
+ {
+ AddStep("make user spectating", () => Client.ChangeState(MultiplayerUserState.Spectating));
+ AddStep("make user idle", () => Client.ChangeState(MultiplayerUserState.Idle));
+ }
+
[Test]
public void TestCrownChangesStateWhenHostTransferred()
{
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs
index dad1237991..dfb4306e67 100644
--- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs
@@ -209,9 +209,16 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
addClickButtonStep();
AddAssert("user waiting for load", () => Client.Room?.Users[0].State == MultiplayerUserState.WaitingForLoad);
- AddAssert("ready button disabled", () => !button.ChildrenOfType().Single().Enabled.Value);
+ AddAssert("ready button disabled", () => !button.ChildrenOfType().Single().Enabled.Value);
AddStep("transitioned to gameplay", () => readyClickOperation.Dispose());
+
+ AddStep("finish gameplay", () =>
+ {
+ Client.ChangeUserState(Client.Room?.Users[0].UserID ?? 0, MultiplayerUserState.Loaded);
+ Client.ChangeUserState(Client.Room?.Users[0].UserID ?? 0, MultiplayerUserState.FinishedPlay);
+ });
+
AddAssert("ready button enabled", () => button.ChildrenOfType().Single().Enabled.Value);
}
}
diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs
new file mode 100644
index 0000000000..e65e4a68a7
--- /dev/null
+++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerSpectateButton.cs
@@ -0,0 +1,193 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Linq;
+using NUnit.Framework;
+using osu.Framework.Allocation;
+using osu.Framework.Audio;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Framework.Graphics.Containers;
+using osu.Framework.Platform;
+using osu.Framework.Testing;
+using osu.Game.Beatmaps;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Online.Multiplayer;
+using osu.Game.Online.Rooms;
+using osu.Game.Rulesets;
+using osu.Game.Screens.OnlinePlay.Multiplayer.Match;
+using osu.Game.Tests.Resources;
+using osu.Game.Users;
+using osuTK;
+using osuTK.Input;
+
+namespace osu.Game.Tests.Visual.Multiplayer
+{
+ public class TestSceneMultiplayerSpectateButton : MultiplayerTestScene
+ {
+ private MultiplayerSpectateButton spectateButton;
+ private MultiplayerReadyButton readyButton;
+
+ private readonly Bindable selectedItem = new Bindable();
+
+ private BeatmapSetInfo importedSet;
+ private BeatmapManager beatmaps;
+ private RulesetStore rulesets;
+
+ private IDisposable readyClickOperation;
+
+ protected override Container Content => content;
+ private readonly Container content;
+
+ public TestSceneMultiplayerSpectateButton()
+ {
+ base.Content.Add(content = new Container
+ {
+ RelativeSizeAxes = Axes.Both
+ });
+ }
+
+ protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
+ {
+ var dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
+
+ return dependencies;
+ }
+
+ [BackgroundDependencyLoader]
+ private void load(GameHost host, AudioManager audio)
+ {
+ Dependencies.Cache(rulesets = new RulesetStore(ContextFactory));
+ Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, host, Beatmap.Default));
+
+ var beatmapTracker = new OnlinePlayBeatmapAvailabilityTracker { SelectedItem = { BindTarget = selectedItem } };
+ base.Content.Add(beatmapTracker);
+ Dependencies.Cache(beatmapTracker);
+
+ beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).Wait();
+ }
+
+ [SetUp]
+ public new void Setup() => Schedule(() =>
+ {
+ importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First();
+ Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First());
+ selectedItem.Value = new PlaylistItem
+ {
+ Beatmap = { Value = Beatmap.Value.BeatmapInfo },
+ Ruleset = { Value = Beatmap.Value.BeatmapInfo.Ruleset },
+ };
+
+ Child = new FillFlowContainer
+ {
+ AutoSizeAxes = Axes.Both,
+ Direction = FillDirection.Vertical,
+ Children = new Drawable[]
+ {
+ spectateButton = new MultiplayerSpectateButton
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Size = new Vector2(200, 50),
+ OnSpectateClick = async () =>
+ {
+ readyClickOperation = OngoingOperationTracker.BeginOperation();
+ await Client.ToggleSpectate();
+ readyClickOperation.Dispose();
+ }
+ },
+ readyButton = new MultiplayerReadyButton
+ {
+ Anchor = Anchor.Centre,
+ Origin = Anchor.Centre,
+ Size = new Vector2(200, 50),
+ OnReadyClick = async () =>
+ {
+ readyClickOperation = OngoingOperationTracker.BeginOperation();
+
+ if (Client.IsHost && Client.LocalUser?.State == MultiplayerUserState.Ready)
+ {
+ await Client.StartMatch();
+ return;
+ }
+
+ await Client.ToggleReady();
+ readyClickOperation.Dispose();
+ }
+ }
+ }
+ };
+ });
+
+ [Test]
+ public void TestEnabledWhenRoomOpen()
+ {
+ assertSpectateButtonEnablement(true);
+ }
+
+ [TestCase(MultiplayerUserState.Idle)]
+ [TestCase(MultiplayerUserState.Ready)]
+ public void TestToggleWhenIdle(MultiplayerUserState initialState)
+ {
+ addClickSpectateButtonStep();
+ AddAssert("user is spectating", () => Client.Room?.Users[0].State == MultiplayerUserState.Spectating);
+
+ addClickSpectateButtonStep();
+ AddAssert("user is idle", () => Client.Room?.Users[0].State == MultiplayerUserState.Idle);
+ }
+
+ [TestCase(MultiplayerRoomState.WaitingForLoad)]
+ [TestCase(MultiplayerRoomState.Playing)]
+ [TestCase(MultiplayerRoomState.Closed)]
+ public void TestDisabledDuringGameplayOrClosed(MultiplayerRoomState roomState)
+ {
+ AddStep($"change user to {roomState}", () => Client.ChangeRoomState(roomState));
+ assertSpectateButtonEnablement(false);
+ }
+
+ [Test]
+ public void TestReadyButtonDisabledWhenHostAndNoReadyUsers()
+ {
+ addClickSpectateButtonStep();
+ assertReadyButtonEnablement(false);
+ }
+
+ [Test]
+ public void TestReadyButtonEnabledWhenHostAndUsersReady()
+ {
+ AddStep("add user", () => Client.AddUser(new User { Id = 55 }));
+ AddStep("set user ready", () => Client.ChangeUserState(55, MultiplayerUserState.Ready));
+
+ addClickSpectateButtonStep();
+ assertReadyButtonEnablement(true);
+ }
+
+ [Test]
+ public void TestReadyButtonDisabledWhenNotHostAndUsersReady()
+ {
+ AddStep("add user and transfer host", () =>
+ {
+ Client.AddUser(new User { Id = 55 });
+ Client.TransferHost(55);
+ });
+
+ AddStep("set user ready", () => Client.ChangeUserState(55, MultiplayerUserState.Ready));
+
+ addClickSpectateButtonStep();
+ assertReadyButtonEnablement(false);
+ }
+
+ private void addClickSpectateButtonStep() => AddStep("click spectate button", () =>
+ {
+ InputManager.MoveMouseTo(spectateButton);
+ InputManager.Click(MouseButton.Left);
+ });
+
+ private void assertSpectateButtonEnablement(bool shouldBeEnabled)
+ => AddAssert($"spectate button {(shouldBeEnabled ? "is" : "is not")} enabled", () => spectateButton.ChildrenOfType().Single().Enabled.Value == shouldBeEnabled);
+
+ private void assertReadyButtonEnablement(bool shouldBeEnabled)
+ => AddAssert($"ready button {(shouldBeEnabled ? "is" : "is not")} enabled", () => readyButton.ChildrenOfType().Single().Enabled.Value == shouldBeEnabled);
+ }
+}
diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs
index 00f9bf3432..859cefe3a9 100644
--- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs
+++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs
@@ -44,6 +44,20 @@ namespace osu.Game.Tests.Visual.Navigation
exitViaEscapeAndConfirm();
}
+ ///
+ /// This tests that the F1 key will open the mod select overlay, and not be handled / blocked by the music controller (which has the same default binding
+ /// but should be handled *after* song select).
+ ///
+ [Test]
+ public void TestOpenModSelectOverlayUsingAction()
+ {
+ TestSongSelect songSelect = null;
+
+ PushAndConfirm(() => songSelect = new TestSongSelect());
+ AddStep("Show mods overlay", () => InputManager.Key(Key.F1));
+ AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible);
+ }
+
[Test]
public void TestRetryCountIncrements()
{
diff --git a/osu.Game/Beatmaps/BeatmapConverter.cs b/osu.Game/Beatmaps/BeatmapConverter.cs
index b291edd19d..f3434c5153 100644
--- a/osu.Game/Beatmaps/BeatmapConverter.cs
+++ b/osu.Game/Beatmaps/BeatmapConverter.cs
@@ -27,8 +27,6 @@ namespace osu.Game.Beatmaps
public IBeatmap Beatmap { get; }
- private CancellationToken cancellationToken;
-
protected BeatmapConverter(IBeatmap beatmap, Ruleset ruleset)
{
Beatmap = beatmap;
@@ -41,8 +39,6 @@ namespace osu.Game.Beatmaps
public IBeatmap Convert(CancellationToken cancellationToken = default)
{
- this.cancellationToken = cancellationToken;
-
// We always operate on a clone of the original beatmap, to not modify it game-wide
return ConvertBeatmap(Beatmap.Clone(), cancellationToken);
}
@@ -55,19 +51,6 @@ namespace osu.Game.Beatmaps
/// The converted Beatmap.
protected virtual Beatmap ConvertBeatmap(IBeatmap original, CancellationToken cancellationToken)
{
-#pragma warning disable 618
- return ConvertBeatmap(original);
-#pragma warning restore 618
- }
-
- ///
- /// Performs the conversion of a Beatmap using this Beatmap Converter.
- ///
- /// The un-converted Beatmap.
- /// The converted Beatmap.
- [Obsolete("Use the cancellation-supporting override")] // Can be removed 20210318
- protected virtual Beatmap ConvertBeatmap(IBeatmap original)
- {
var beatmap = CreateBeatmap();
beatmap.BeatmapInfo = original.BeatmapInfo;
@@ -121,21 +104,6 @@ namespace osu.Game.Beatmaps
/// The un-converted Beatmap.
/// The cancellation token.
/// The converted hit object.
- protected virtual IEnumerable ConvertHitObject(HitObject original, IBeatmap beatmap, CancellationToken cancellationToken)
- {
-#pragma warning disable 618
- return ConvertHitObject(original, beatmap);
-#pragma warning restore 618
- }
-
- ///
- /// Performs the conversion of a hit object.
- /// This method is generally executed sequentially for all objects in a beatmap.
- ///
- /// The hit object to convert.
- /// The un-converted Beatmap.
- /// The converted hit object.
- [Obsolete("Use the cancellation-supporting override")] // Can be removed 20210318
- protected virtual IEnumerable ConvertHitObject(HitObject original, IBeatmap beatmap) => Enumerable.Empty();
+ protected virtual IEnumerable ConvertHitObject(HitObject original, IBeatmap beatmap, CancellationToken cancellationToken) => Enumerable.Empty();
}
}
diff --git a/osu.Game/Beatmaps/BeatmapStatistic.cs b/osu.Game/Beatmaps/BeatmapStatistic.cs
index 9d87a20d60..7d7ba09fcf 100644
--- a/osu.Game/Beatmaps/BeatmapStatistic.cs
+++ b/osu.Game/Beatmaps/BeatmapStatistic.cs
@@ -3,16 +3,11 @@
using System;
using osu.Framework.Graphics;
-using osu.Framework.Graphics.Sprites;
-using osuTK;
namespace osu.Game.Beatmaps
{
public class BeatmapStatistic
{
- [Obsolete("Use CreateIcon instead")] // can be removed 20210203
- public IconUsage Icon = FontAwesome.Regular.QuestionCircle;
-
///
/// A function to create the icon for display purposes. Use default icons available via whenever possible for conformity.
///
@@ -20,12 +15,5 @@ namespace osu.Game.Beatmaps
public string Content;
public string Name;
-
- public BeatmapStatistic()
- {
-#pragma warning disable 618
- CreateIcon = () => new SpriteIcon { Icon = Icon, Scale = new Vector2(0.7f) };
-#pragma warning restore 618
- }
}
}
diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs
index da44b96ed3..acbf57d25f 100644
--- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs
+++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs
@@ -273,7 +273,7 @@ namespace osu.Game.Beatmaps.Formats
if (hitObject is IHasPath path)
{
addPathData(writer, path, position);
- writer.Write(getSampleBank(hitObject.Samples, zeroBanks: true));
+ writer.Write(getSampleBank(hitObject.Samples));
}
else
{
@@ -420,15 +420,15 @@ namespace osu.Game.Beatmaps.Formats
writer.Write(FormattableString.Invariant($"{endTimeData.EndTime}{suffix}"));
}
- private string getSampleBank(IList samples, bool banksOnly = false, bool zeroBanks = false)
+ private string getSampleBank(IList samples, bool banksOnly = false)
{
LegacySampleBank normalBank = toLegacySampleBank(samples.SingleOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL)?.Bank);
LegacySampleBank addBank = toLegacySampleBank(samples.FirstOrDefault(s => !string.IsNullOrEmpty(s.Name) && s.Name != HitSampleInfo.HIT_NORMAL)?.Bank);
StringBuilder sb = new StringBuilder();
- sb.Append(FormattableString.Invariant($"{(zeroBanks ? 0 : (int)normalBank)}:"));
- sb.Append(FormattableString.Invariant($"{(zeroBanks ? 0 : (int)addBank)}"));
+ sb.Append(FormattableString.Invariant($"{(int)normalBank}:"));
+ sb.Append(FormattableString.Invariant($"{(int)addBank}"));
if (!banksOnly)
{
diff --git a/osu.Game/Graphics/UserInterface/HoverSounds.cs b/osu.Game/Graphics/UserInterface/HoverSounds.cs
index 00cf5798e7..f2e4c6d013 100644
--- a/osu.Game/Graphics/UserInterface/HoverSounds.cs
+++ b/osu.Game/Graphics/UserInterface/HoverSounds.cs
@@ -36,7 +36,7 @@ namespace osu.Game.Graphics.UserInterface
public override void PlayHoverSample()
{
- sampleHover.Frequency.Value = 0.96 + RNG.NextDouble(0.08);
+ sampleHover.Frequency.Value = 0.98 + RNG.NextDouble(0.04);
sampleHover.Play();
}
}
@@ -53,6 +53,9 @@ namespace osu.Game.Graphics.UserInterface
Soft,
[Description("-toolbar")]
- Toolbar
+ Toolbar,
+
+ [Description("-songselect")]
+ SongSelect
}
}
diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs
index 042960d54c..671c3bc8bc 100644
--- a/osu.Game/Input/Bindings/GlobalActionContainer.cs
+++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs
@@ -4,7 +4,6 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
-using JetBrains.Annotations;
using osu.Framework.Graphics;
using osu.Framework.Input;
using osu.Framework.Input.Bindings;
@@ -13,20 +12,23 @@ namespace osu.Game.Input.Bindings
{
public class GlobalActionContainer : DatabasedKeyBindingContainer, IHandleGlobalKeyboardInput
{
- [CanBeNull]
- private readonly GlobalInputManager globalInputManager;
-
private readonly Drawable handler;
+ private InputManager parentInputManager;
- public GlobalActionContainer(OsuGameBase game, [CanBeNull] GlobalInputManager globalInputManager)
+ public GlobalActionContainer(OsuGameBase game)
: base(matchingMode: KeyCombinationMatchingMode.Modifiers)
{
- this.globalInputManager = globalInputManager;
-
if (game is IKeyBindingHandler)
handler = game;
}
+ protected override void LoadComplete()
+ {
+ base.LoadComplete();
+
+ parentInputManager = GetContainingInputManager();
+ }
+
public override IEnumerable DefaultKeyBindings => GlobalKeyBindings
.Concat(EditorKeyBindings)
.Concat(InGameKeyBindings)
@@ -113,7 +115,12 @@ namespace osu.Game.Input.Bindings
{
get
{
- var inputQueue = globalInputManager?.NonPositionalInputQueue ?? base.KeyBindingInputQueue;
+ // To ensure the global actions are handled with priority, this GlobalActionContainer is actually placed after game content.
+ // It does not contain children as expected, so we need to forward the NonPositionalInputQueue from the parent input manager to correctly
+ // allow the whole game to handle these actions.
+
+ // An eventual solution to this hack is to create localised action containers for individual components like SongSelect, but this will take some rearranging.
+ var inputQueue = parentInputManager?.NonPositionalInputQueue ?? base.KeyBindingInputQueue;
return handler != null ? inputQueue.Prepend(handler) : inputQueue;
}
diff --git a/osu.Game/Input/Bindings/GlobalInputManager.cs b/osu.Game/Input/Bindings/GlobalInputManager.cs
deleted file mode 100644
index 91373712fb..0000000000
--- a/osu.Game/Input/Bindings/GlobalInputManager.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using osu.Framework.Graphics;
-using osu.Framework.Graphics.Containers;
-using osu.Framework.Input;
-
-namespace osu.Game.Input.Bindings
-{
- public class GlobalInputManager : PassThroughInputManager
- {
- public readonly GlobalActionContainer GlobalBindings;
-
- protected override Container Content { get; }
-
- public GlobalInputManager(OsuGameBase game)
- {
- InternalChildren = new Drawable[]
- {
- Content = new Container
- {
- RelativeSizeAxes = Axes.Both,
- },
- // to avoid positional input being blocked by children, ensure the GlobalActionContainer is above everything.
- GlobalBindings = new GlobalActionContainer(game, this)
- };
- }
- }
-}
diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs
index 4529dfd0a7..37e11cc576 100644
--- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs
+++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs
@@ -96,6 +96,9 @@ namespace osu.Game.Online.Multiplayer
if (!IsConnected.Value)
return Task.CompletedTask;
+ if (newState == MultiplayerUserState.Spectating)
+ return Task.CompletedTask; // Not supported yet.
+
return connection.InvokeAsync(nameof(IMultiplayerServer.ChangeState), newState);
}
diff --git a/osu.Game/Online/Multiplayer/MultiplayerUserState.cs b/osu.Game/Online/Multiplayer/MultiplayerUserState.cs
index e54c71cd85..c467ff84bb 100644
--- a/osu.Game/Online/Multiplayer/MultiplayerUserState.cs
+++ b/osu.Game/Online/Multiplayer/MultiplayerUserState.cs
@@ -55,5 +55,10 @@ namespace osu.Game.Online.Multiplayer
/// The user is currently viewing results. This is a reserved state, and is set by the server.
///
Results,
+
+ ///
+ /// The user is currently spectating this room.
+ ///
+ Spectating
}
}
diff --git a/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs b/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs
index 0f7050596f..2ddc10db0f 100644
--- a/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs
+++ b/osu.Game/Online/Multiplayer/StatefulMultiplayerClient.cs
@@ -249,6 +249,33 @@ namespace osu.Game.Online.Multiplayer
}
}
+ ///
+ /// Toggles the 's spectating state.
+ ///
+ /// If a toggle of the spectating state is not valid at this time.
+ public async Task ToggleSpectate()
+ {
+ var localUser = LocalUser;
+
+ if (localUser == null)
+ return;
+
+ switch (localUser.State)
+ {
+ case MultiplayerUserState.Idle:
+ case MultiplayerUserState.Ready:
+ await ChangeState(MultiplayerUserState.Spectating).ConfigureAwait(false);
+ return;
+
+ case MultiplayerUserState.Spectating:
+ await ChangeState(MultiplayerUserState.Idle).ConfigureAwait(false);
+ return;
+
+ default:
+ throw new InvalidOperationException($"Cannot toggle spectate when in {localUser.State}");
+ }
+ }
+
public abstract Task TransferHost(int userId);
public abstract Task ChangeSettings(MultiplayerRoomSettings settings);
diff --git a/osu.Game/Online/Solo/CreateSoloScoreRequest.cs b/osu.Game/Online/Solo/CreateSoloScoreRequest.cs
index ae5ac5e26c..0d2bea1f2a 100644
--- a/osu.Game/Online/Solo/CreateSoloScoreRequest.cs
+++ b/osu.Game/Online/Solo/CreateSoloScoreRequest.cs
@@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
+using System.Globalization;
using System.Net.Http;
using osu.Framework.IO.Network;
using osu.Game.Online.API;
@@ -11,11 +12,13 @@ namespace osu.Game.Online.Solo
public class CreateSoloScoreRequest : APIRequest
{
private readonly int beatmapId;
+ private readonly int rulesetId;
private readonly string versionHash;
- public CreateSoloScoreRequest(int beatmapId, string versionHash)
+ public CreateSoloScoreRequest(int beatmapId, int rulesetId, string versionHash)
{
this.beatmapId = beatmapId;
+ this.rulesetId = rulesetId;
this.versionHash = versionHash;
}
@@ -24,9 +27,10 @@ namespace osu.Game.Online.Solo
var req = base.CreateWebRequest();
req.Method = HttpMethod.Post;
req.AddParameter("version_hash", versionHash);
+ req.AddParameter("ruleset_id", rulesetId.ToString(CultureInfo.InvariantCulture));
return req;
}
- protected override string Target => $@"solo/{beatmapId}/scores";
+ protected override string Target => $@"beatmaps/{beatmapId}/solo/scores";
}
}
diff --git a/osu.Game/Online/Solo/SubmitSoloScoreRequest.cs b/osu.Game/Online/Solo/SubmitSoloScoreRequest.cs
index 98ba4fa052..85fa3eeb34 100644
--- a/osu.Game/Online/Solo/SubmitSoloScoreRequest.cs
+++ b/osu.Game/Online/Solo/SubmitSoloScoreRequest.cs
@@ -40,6 +40,6 @@ namespace osu.Game.Online.Solo
return req;
}
- protected override string Target => $@"solo/{beatmapId}/scores/{scoreId}";
+ protected override string Target => $@"beatmaps/{beatmapId}/solo/scores/{scoreId}";
}
}
diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs
index 1b10de614a..41d790ea4a 100644
--- a/osu.Game/OsuGameBase.cs
+++ b/osu.Game/OsuGameBase.cs
@@ -311,18 +311,21 @@ namespace osu.Game
AddInternal(RulesetConfigCache);
- var globalInput = new GlobalInputManager(this)
+ GlobalActionContainer globalBindings;
+
+ var mainContent = new Drawable[]
{
- RelativeSizeAxes = Axes.Both,
- Child = MenuCursorContainer = new MenuCursorContainer { RelativeSizeAxes = Axes.Both }
+ MenuCursorContainer = new MenuCursorContainer { RelativeSizeAxes = Axes.Both },
+ // to avoid positional input being blocked by children, ensure the GlobalActionContainer is above everything.
+ globalBindings = new GlobalActionContainer(this)
};
MenuCursorContainer.Child = content = new OsuTooltipContainer(MenuCursorContainer.Cursor) { RelativeSizeAxes = Axes.Both };
- base.Content.Add(CreateScalingContainer().WithChild(globalInput));
+ base.Content.Add(CreateScalingContainer().WithChildren(mainContent));
- KeyBindingStore.Register(globalInput.GlobalBindings);
- dependencies.Cache(globalInput.GlobalBindings);
+ KeyBindingStore.Register(globalBindings);
+ dependencies.Cache(globalBindings);
PreviewTrackManager previewTrackManager;
dependencies.Cache(previewTrackManager = new PreviewTrackManager());
diff --git a/osu.Game/Overlays/Settings/SettingsItem.cs b/osu.Game/Overlays/Settings/SettingsItem.cs
index 85765bf991..0bd9750b0b 100644
--- a/osu.Game/Overlays/Settings/SettingsItem.cs
+++ b/osu.Game/Overlays/Settings/SettingsItem.cs
@@ -57,13 +57,6 @@ namespace osu.Game.Overlays.Settings
}
}
- [Obsolete("Use Current instead")] // Can be removed 20210406
- public Bindable Bindable
- {
- get => Current;
- set => Current = value;
- }
-
public virtual Bindable Current
{
get => controlWithCurrent.Current;
diff --git a/osu.Game/Rulesets/Difficulty/Skills/Skill.cs b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs
index 534dee3ba8..9f0fb987a7 100644
--- a/osu.Game/Rulesets/Difficulty/Skills/Skill.cs
+++ b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs
@@ -16,7 +16,12 @@ namespace osu.Game.Rulesets.Difficulty.Skills
///
/// s that were processed previously. They can affect the strain values of the following objects.
///
- protected readonly LimitedCapacityStack Previous = new LimitedCapacityStack(2); // Contained objects not used yet
+ protected readonly ReverseQueue Previous;
+
+ ///
+ /// Number of previous s to keep inside the queue.
+ ///
+ protected virtual int HistoryLength => 1;
///
/// Mods for use in skill calculations.
@@ -28,12 +33,17 @@ namespace osu.Game.Rulesets.Difficulty.Skills
protected Skill(Mod[] mods)
{
this.mods = mods;
+ Previous = new ReverseQueue(HistoryLength + 1);
}
internal void ProcessInternal(DifficultyHitObject current)
{
+ while (Previous.Count > HistoryLength)
+ Previous.Dequeue();
+
Process(current);
- Previous.Push(current);
+
+ Previous.Enqueue(current);
}
///
diff --git a/osu.Game/Rulesets/Difficulty/Utils/LimitedCapacityStack.cs b/osu.Game/Rulesets/Difficulty/Utils/LimitedCapacityStack.cs
deleted file mode 100644
index 1fc5abce90..0000000000
--- a/osu.Game/Rulesets/Difficulty/Utils/LimitedCapacityStack.cs
+++ /dev/null
@@ -1,92 +0,0 @@
-// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
-// See the LICENCE file in the repository root for full licence text.
-
-using System;
-using System.Collections;
-using System.Collections.Generic;
-
-namespace osu.Game.Rulesets.Difficulty.Utils
-{
- ///
- /// An indexed stack with limited depth. Indexing starts at the top of the stack.
- ///
- public class LimitedCapacityStack : IEnumerable
- {
- ///
- /// The number of elements in the stack.
- ///
- public int Count { get; private set; }
-
- private readonly T[] array;
- private readonly int capacity;
- private int marker; // Marks the position of the most recently added item.
-
- ///
- /// Constructs a new .
- ///
- /// The number of items the stack can hold.
- public LimitedCapacityStack(int capacity)
- {
- if (capacity < 0)
- throw new ArgumentOutOfRangeException(nameof(capacity));
-
- this.capacity = capacity;
- array = new T[capacity];
- marker = capacity; // Set marker to the end of the array, outside of the indexed range by one.
- }
-
- ///
- /// Retrieves the item at an index in the stack.
- ///
- /// The index of the item to retrieve. The top of the stack is returned at index 0.
- public T this[int i]
- {
- get
- {
- if (i < 0 || i > Count - 1)
- throw new ArgumentOutOfRangeException(nameof(i));
-
- i += marker;
- if (i > capacity - 1)
- i -= capacity;
-
- return array[i];
- }
- }
-
- ///
- /// Pushes an item to this .
- ///
- /// The item to push.
- public void Push(T item)
- {
- // Overwrite the oldest item instead of shifting every item by one with every addition.
- if (marker == 0)
- marker = capacity - 1;
- else
- --marker;
-
- array[marker] = item;
-
- if (Count < capacity)
- ++Count;
- }
-
- ///
- /// Returns an enumerator which enumerates items in the history starting from the most recently added one.
- ///
- public IEnumerator GetEnumerator()
- {
- for (int i = marker; i < capacity; ++i)
- yield return array[i];
-
- if (Count == capacity)
- {
- for (int i = 0; i < marker; ++i)
- yield return array[i];
- }
- }
-
- IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
- }
-}
diff --git a/osu.Game/Rulesets/Difficulty/Utils/ReverseQueue.cs b/osu.Game/Rulesets/Difficulty/Utils/ReverseQueue.cs
new file mode 100644
index 0000000000..57db9df3ca
--- /dev/null
+++ b/osu.Game/Rulesets/Difficulty/Utils/ReverseQueue.cs
@@ -0,0 +1,133 @@
+// 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;
+using System.Collections.Generic;
+
+namespace osu.Game.Rulesets.Difficulty.Utils
+{
+ ///
+ /// An indexed queue where items are indexed beginning from the most recently enqueued item.
+ /// Enqueuing an item pushes all existing indexes up by one and inserts the item at index 0.
+ /// Dequeuing an item removes the item from the highest index and returns it.
+ ///
+ public class ReverseQueue : IEnumerable
+ {
+ ///
+ /// The number of elements in the .
+ ///
+ public int Count { get; private set; }
+
+ private T[] items;
+ private int capacity;
+ private int start;
+
+ public ReverseQueue(int initialCapacity)
+ {
+ if (initialCapacity <= 0)
+ throw new ArgumentOutOfRangeException(nameof(initialCapacity));
+
+ items = new T[initialCapacity];
+ capacity = initialCapacity;
+ start = 0;
+ Count = 0;
+ }
+
+ ///
+ /// Retrieves the item at an index in the .
+ ///
+ /// The index of the item to retrieve. The most recently enqueued item is at index 0.
+ public T this[int index]
+ {
+ get
+ {
+ if (index < 0 || index > Count - 1)
+ throw new ArgumentOutOfRangeException(nameof(index));
+
+ int reverseIndex = Count - 1 - index;
+ return items[(start + reverseIndex) % capacity];
+ }
+ }
+
+ ///
+ /// Enqueues an item to this .
+ ///
+ /// The item to enqueue.
+ public void Enqueue(T item)
+ {
+ if (Count == capacity)
+ {
+ // Double the buffer size
+ var buffer = new T[capacity * 2];
+
+ // Copy items to new queue
+ for (int i = 0; i < Count; i++)
+ {
+ buffer[i] = items[(start + i) % capacity];
+ }
+
+ // Replace array with new buffer
+ items = buffer;
+ capacity *= 2;
+ start = 0;
+ }
+
+ items[(start + Count) % capacity] = item;
+ Count++;
+ }
+
+ ///
+ /// Dequeues the least recently enqueued item from the and returns it.
+ ///
+ /// The item dequeued from the .
+ public T Dequeue()
+ {
+ var item = items[start];
+ start = (start + 1) % capacity;
+ Count--;
+ return item;
+ }
+
+ ///
+ /// Clears the of all items.
+ ///
+ public void Clear()
+ {
+ start = 0;
+ Count = 0;
+ }
+
+ ///
+ /// Returns an enumerator which enumerates items in the starting from the most recently enqueued item.
+ ///
+ public IEnumerator GetEnumerator() => new Enumerator(this);
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+
+ public struct Enumerator : IEnumerator
+ {
+ private ReverseQueue reverseQueue;
+ private int currentIndex;
+
+ internal Enumerator(ReverseQueue reverseQueue)
+ {
+ this.reverseQueue = reverseQueue;
+ currentIndex = -1; // The first MoveNext() should bring the iterator to 0
+ }
+
+ public bool MoveNext() => ++currentIndex < reverseQueue.Count;
+
+ public void Reset() => currentIndex = -1;
+
+ public readonly T Current => reverseQueue[currentIndex];
+
+ readonly object IEnumerator.Current => Current;
+
+ public void Dispose()
+ {
+ reverseQueue = null;
+ }
+ }
+ }
+}
diff --git a/osu.Game/Rulesets/Judgements/Judgement.cs b/osu.Game/Rulesets/Judgements/Judgement.cs
index b1ca72b1c0..be69db5ca8 100644
--- a/osu.Game/Rulesets/Judgements/Judgement.cs
+++ b/osu.Game/Rulesets/Judgements/Judgement.cs
@@ -28,18 +28,6 @@ namespace osu.Game.Rulesets.Judgements
///
protected const double DEFAULT_MAX_HEALTH_INCREASE = 0.05;
- ///
- /// Whether this should affect the current combo.
- ///
- [Obsolete("Has no effect. Use HitResult members instead (e.g. use small-tick or bonus to not affect combo).")] // Can be removed 20210328
- public virtual bool AffectsCombo => true;
-
- ///
- /// Whether this should be counted as base (combo) or bonus score.
- ///
- [Obsolete("Has no effect. Use HitResult members instead (e.g. use small-tick or bonus to not affect combo).")] // Can be removed 20210328
- public virtual bool IsBonus => !AffectsCombo;
-
///
/// The maximum that can be achieved.
///
diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs
index e5eaf5db88..6da9f12b50 100644
--- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs
+++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs
@@ -11,7 +11,6 @@ using osu.Framework.Bindables;
using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Primitives;
-using osu.Framework.Logging;
using osu.Framework.Threading;
using osu.Game.Audio;
using osu.Game.Rulesets.Judgements;
@@ -736,24 +735,6 @@ namespace osu.Game.Rulesets.Objects.Drawables
if (!Result.HasResult)
throw new InvalidOperationException($"{GetType().ReadableName()} applied a {nameof(JudgementResult)} but did not update {nameof(JudgementResult.Type)}.");
- // Some (especially older) rulesets use scorable judgements instead of the newer ignorehit/ignoremiss judgements.
- // Can be removed 20210328
- if (Result.Judgement.MaxResult == HitResult.IgnoreHit)
- {
- HitResult originalType = Result.Type;
-
- if (Result.Type == HitResult.Miss)
- Result.Type = HitResult.IgnoreMiss;
- else if (Result.Type >= HitResult.Meh && Result.Type <= HitResult.Perfect)
- Result.Type = HitResult.IgnoreHit;
-
- if (Result.Type != originalType)
- {
- Logger.Log($"{GetType().ReadableName()} applied an invalid hit result ({originalType}) when {nameof(HitResult.IgnoreMiss)} or {nameof(HitResult.IgnoreHit)} is expected.\n"
- + $"This has been automatically adjusted to {Result.Type}, and support will be removed from 2021-03-28 onwards.", level: LogLevel.Important);
- }
- }
-
if (!Result.Type.IsValidHitResult(Result.Judgement.MinResult, Result.Judgement.MaxResult))
{
throw new InvalidOperationException(
diff --git a/osu.Game/Rulesets/Objects/HitObject.cs b/osu.Game/Rulesets/Objects/HitObject.cs
index 826d411822..db02eafa92 100644
--- a/osu.Game/Rulesets/Objects/HitObject.cs
+++ b/osu.Game/Rulesets/Objects/HitObject.cs
@@ -139,15 +139,6 @@ namespace osu.Game.Rulesets.Objects
}
protected virtual void CreateNestedHitObjects(CancellationToken cancellationToken)
- {
- // ReSharper disable once MethodSupportsCancellation (https://youtrack.jetbrains.com/issue/RIDER-44520)
-#pragma warning disable 618
- CreateNestedHitObjects();
-#pragma warning restore 618
- }
-
- [Obsolete("Use the cancellation-supporting override")] // Can be removed 20210318
- protected virtual void CreateNestedHitObjects()
{
}
diff --git a/osu.Game/Rulesets/Objects/SliderPath.cs b/osu.Game/Rulesets/Objects/SliderPath.cs
index 3083fcfccb..61f5f94142 100644
--- a/osu.Game/Rulesets/Objects/SliderPath.cs
+++ b/osu.Game/Rulesets/Objects/SliderPath.cs
@@ -156,6 +156,39 @@ namespace osu.Game.Rulesets.Objects
return interpolateVertices(indexOfDistance(d), d);
}
+ ///
+ /// Returns the control points belonging to the same segment as the one given.
+ /// The first point has a PathType which all other points inherit.
+ ///
+ /// One of the control points in the segment.
+ ///
+ public List PointsInSegment(PathControlPoint controlPoint)
+ {
+ bool found = false;
+ List pointsInCurrentSegment = new List();
+
+ foreach (PathControlPoint point in ControlPoints)
+ {
+ if (point.Type.Value != null)
+ {
+ if (!found)
+ pointsInCurrentSegment.Clear();
+ else
+ {
+ pointsInCurrentSegment.Add(point);
+ break;
+ }
+ }
+
+ pointsInCurrentSegment.Add(point);
+
+ if (point == controlPoint)
+ found = true;
+ }
+
+ return pointsInCurrentSegment;
+ }
+
private void invalidate()
{
pathCache.Invalidate();
diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs
index b81fa79345..0fb5c2f4b5 100644
--- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs
+++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs
@@ -346,12 +346,6 @@ namespace osu.Game.Rulesets.Scoring
score.HitEvents = hitEvents;
}
-
- ///
- /// Create a for this processor.
- ///
- [Obsolete("Method is now unused.")] // Can be removed 20210328
- public virtual HitWindows CreateHitWindows() => new HitWindows();
}
public enum ScoringMode
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs
index fdc1ae9d3c..d4f5428bfb 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchFooter.cs
@@ -8,21 +8,28 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
-using osuTK;
namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
{
public class MultiplayerMatchFooter : CompositeDrawable
{
public const float HEIGHT = 50;
+ private const float ready_button_width = 600;
+ private const float spectate_button_width = 200;
public Action OnReadyClick
{
set => readyButton.OnReadyClick = value;
}
+ public Action OnSpectateClick
+ {
+ set => spectateButton.OnSpectateClick = value;
+ }
+
private readonly Drawable background;
private readonly MultiplayerReadyButton readyButton;
+ private readonly MultiplayerSpectateButton spectateButton;
public MultiplayerMatchFooter()
{
@@ -32,11 +39,34 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
InternalChildren = new[]
{
background = new Box { RelativeSizeAxes = Axes.Both },
- readyButton = new MultiplayerReadyButton
+ new GridContainer
{
- Anchor = Anchor.Centre,
- Origin = Anchor.Centre,
- Size = new Vector2(600, 50),
+ RelativeSizeAxes = Axes.Both,
+ Content = new[]
+ {
+ new Drawable[]
+ {
+ null,
+ spectateButton = new MultiplayerSpectateButton
+ {
+ RelativeSizeAxes = Axes.Both,
+ },
+ null,
+ readyButton = new MultiplayerReadyButton
+ {
+ RelativeSizeAxes = Axes.Both,
+ },
+ null
+ }
+ },
+ ColumnDimensions = new[]
+ {
+ new Dimension(),
+ new Dimension(maxSize: spectate_button_width),
+ new Dimension(GridSizeMode.Absolute, 10),
+ new Dimension(maxSize: ready_button_width),
+ new Dimension()
+ }
}
};
}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
index c9fb234ccc..f2dd9a6f25 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs
@@ -78,8 +78,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
Debug.Assert(Room != null);
int newCountReady = Room.Users.Count(u => u.State == MultiplayerUserState.Ready);
+ int newCountTotal = Room.Users.Count(u => u.State != MultiplayerUserState.Spectating);
- string countText = $"({newCountReady} / {Room.Users.Count} ready)";
+ string countText = $"({newCountReady} / {newCountTotal} ready)";
switch (localUser.State)
{
@@ -88,6 +89,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
updateButtonColour(true);
break;
+ case MultiplayerUserState.Spectating:
case MultiplayerUserState.Ready:
if (Room?.Host?.Equals(localUser) == true)
{
@@ -103,7 +105,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
break;
}
- button.Enabled.Value = Client.Room?.State == MultiplayerRoomState.Open && !operationInProgress.Value;
+ bool enableButton = Client.Room?.State == MultiplayerRoomState.Open && !operationInProgress.Value;
+
+ // When the local user is the host and spectating the match, the "start match" state should be enabled if any users are ready.
+ if (localUser.State == MultiplayerUserState.Spectating)
+ enableButton &= Room?.Host?.Equals(localUser) == true && newCountReady > 0;
+
+ button.Enabled.Value = enableButton;
if (newCountReady != countReady)
{
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs
new file mode 100644
index 0000000000..4b3fb5d00f
--- /dev/null
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerSpectateButton.cs
@@ -0,0 +1,92 @@
+// 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.Diagnostics;
+using osu.Framework.Allocation;
+using osu.Framework.Bindables;
+using osu.Framework.Graphics;
+using osu.Game.Graphics;
+using osu.Game.Graphics.Backgrounds;
+using osu.Game.Graphics.UserInterface;
+using osu.Game.Online.Multiplayer;
+using osuTK;
+
+namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match
+{
+ public class MultiplayerSpectateButton : MultiplayerRoomComposite
+ {
+ public Action OnSpectateClick
+ {
+ set => button.Action = value;
+ }
+
+ [Resolved]
+ private OngoingOperationTracker ongoingOperationTracker { get; set; }
+
+ [Resolved]
+ private OsuColour colours { get; set; }
+
+ private IBindable operationInProgress;
+
+ private readonly ButtonWithTrianglesExposed button;
+
+ public MultiplayerSpectateButton()
+ {
+ InternalChild = button = new ButtonWithTrianglesExposed
+ {
+ RelativeSizeAxes = Axes.Both,
+ Size = Vector2.One,
+ Enabled = { Value = true },
+ };
+ }
+
+ [BackgroundDependencyLoader]
+ private void load()
+ {
+ operationInProgress = ongoingOperationTracker.InProgress.GetBoundCopy();
+ operationInProgress.BindValueChanged(_ => updateState());
+ }
+
+ protected override void OnRoomUpdated()
+ {
+ base.OnRoomUpdated();
+
+ updateState();
+ }
+
+ private void updateState()
+ {
+ var localUser = Client.LocalUser;
+
+ if (localUser == null)
+ return;
+
+ Debug.Assert(Room != null);
+
+ switch (localUser.State)
+ {
+ default:
+ button.Text = "Spectate";
+ button.BackgroundColour = colours.BlueDark;
+ button.Triangles.ColourDark = colours.BlueDarker;
+ button.Triangles.ColourLight = colours.Blue;
+ break;
+
+ case MultiplayerUserState.Spectating:
+ button.Text = "Stop spectating";
+ button.BackgroundColour = colours.Gray4;
+ button.Triangles.ColourDark = colours.Gray5;
+ button.Triangles.ColourLight = colours.Gray6;
+ break;
+ }
+
+ button.Enabled.Value = Client.Room?.State == MultiplayerRoomState.Open && !operationInProgress.Value;
+ }
+
+ private class ButtonWithTrianglesExposed : TriangleButton
+ {
+ public new Triangles Triangles => base.Triangles;
+ }
+ }
+}
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs
index ceeee67806..90cef0107c 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs
@@ -221,7 +221,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
{
new MultiplayerMatchFooter
{
- OnReadyClick = onReadyClick
+ OnReadyClick = onReadyClick,
+ OnSpectateClick = onSpectateClick
}
}
},
@@ -363,7 +364,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
Debug.Assert(readyClickOperation == null);
readyClickOperation = ongoingOperationTracker.BeginOperation();
- if (client.IsHost && client.LocalUser?.State == MultiplayerUserState.Ready)
+ if (client.IsHost && (client.LocalUser?.State == MultiplayerUserState.Ready || client.LocalUser?.State == MultiplayerUserState.Spectating))
{
client.StartMatch()
.ContinueWith(t =>
@@ -390,6 +391,20 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
}
}
+ private void onSpectateClick()
+ {
+ Debug.Assert(readyClickOperation == null);
+ readyClickOperation = ongoingOperationTracker.BeginOperation();
+
+ client.ToggleSpectate().ContinueWith(t => endOperation());
+
+ void endOperation()
+ {
+ readyClickOperation?.Dispose();
+ readyClickOperation = null;
+ }
+ }
+
private void onRoomUpdated()
{
// user mods may have changed.
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs
index c571b51c83..2616b07c1f 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs
@@ -135,6 +135,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants
icon.Colour = colours.BlueLighter;
break;
+ case MultiplayerUserState.Spectating:
+ text.Text = "spectating";
+ icon.Icon = FontAwesome.Solid.Binoculars;
+ icon.Colour = colours.BlueLight;
+ break;
+
default:
throw new ArgumentOutOfRangeException(nameof(state), state, null);
}
diff --git a/osu.Game/Screens/Play/SoloPlayer.cs b/osu.Game/Screens/Play/SoloPlayer.cs
index ee1ccdc5b3..d0ef4131dc 100644
--- a/osu.Game/Screens/Play/SoloPlayer.cs
+++ b/osu.Game/Screens/Play/SoloPlayer.cs
@@ -17,7 +17,10 @@ namespace osu.Game.Screens.Play
if (!(Beatmap.Value.BeatmapInfo.OnlineBeatmapID is int beatmapId))
return null;
- return new CreateSoloScoreRequest(beatmapId, Game.VersionHash);
+ if (!(Ruleset.Value.ID is int rulesetId))
+ return null;
+
+ return new CreateSoloScoreRequest(beatmapId, rulesetId, Game.VersionHash);
}
protected override bool HandleTokenRetrievalFailure(Exception exception) => false;
diff --git a/osu.Game/Screens/Select/Carousel/CarouselHeader.cs b/osu.Game/Screens/Select/Carousel/CarouselHeader.cs
index 2fbf64de29..40ca3e0764 100644
--- a/osu.Game/Screens/Select/Carousel/CarouselHeader.cs
+++ b/osu.Game/Screens/Select/Carousel/CarouselHeader.cs
@@ -152,7 +152,7 @@ namespace osu.Game.Screens.Select.Carousel
{
if (sampleHover == null) return;
- sampleHover.Frequency.Value = 0.90 + RNG.NextDouble(0.2);
+ sampleHover.Frequency.Value = 0.99 + RNG.NextDouble(0.02);
sampleHover.Play();
}
}
diff --git a/osu.Game/Screens/Select/FooterButton.cs b/osu.Game/Screens/Select/FooterButton.cs
index 7528651fd9..afb3943a09 100644
--- a/osu.Game/Screens/Select/FooterButton.cs
+++ b/osu.Game/Screens/Select/FooterButton.cs
@@ -14,6 +14,7 @@ using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.Containers;
using osu.Game.Input.Bindings;
using osu.Framework.Input.Bindings;
+using osu.Game.Graphics.UserInterface;
namespace osu.Game.Screens.Select
{
@@ -65,6 +66,7 @@ namespace osu.Game.Screens.Select
private readonly Box light;
public FooterButton()
+ : base(HoverSampleSet.SongSelect)
{
AutoSizeAxes = Axes.Both;
Shear = SHEAR;
diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
index 09fcc1ff47..b5cd3dad02 100644
--- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
+++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs
@@ -58,6 +58,12 @@ namespace osu.Game.Tests.Visual.Multiplayer
});
}
+ public void ChangeRoomState(MultiplayerRoomState newState)
+ {
+ Debug.Assert(Room != null);
+ ((IMultiplayerClient)this).RoomStateChanged(newState);
+ }
+
public void ChangeUserState(int userId, MultiplayerUserState newState)
{
Debug.Assert(Room != null);
@@ -71,6 +77,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
case MultiplayerUserState.Loaded:
if (Room.Users.All(u => u.State != MultiplayerUserState.WaitingForLoad))
{
+ ChangeRoomState(MultiplayerRoomState.Playing);
foreach (var u in Room.Users.Where(u => u.State == MultiplayerUserState.Loaded))
ChangeUserState(u.UserID, MultiplayerUserState.Playing);
@@ -82,6 +89,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
case MultiplayerUserState.FinishedPlay:
if (Room.Users.All(u => u.State != MultiplayerUserState.Playing))
{
+ ChangeRoomState(MultiplayerRoomState.Open);
foreach (var u in Room.Users.Where(u => u.State == MultiplayerUserState.FinishedPlay))
ChangeUserState(u.UserID, MultiplayerUserState.Results);
@@ -173,6 +181,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{
Debug.Assert(Room != null);
+ ChangeRoomState(MultiplayerRoomState.WaitingForLoad);
foreach (var user in Room.Users.Where(u => u.State == MultiplayerUserState.Ready))
ChangeUserState(user.UserID, MultiplayerUserState.WaitingForLoad);
diff --git a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs
index 7dad636da7..01dd7a25c8 100644
--- a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs
+++ b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs
@@ -40,7 +40,7 @@ namespace osu.Game.Tests.Visual
if (CreateNestedActionContainer)
{
- mainContent = new GlobalActionContainer(null, null).WithChild(mainContent);
+ mainContent = new GlobalActionContainer(null).WithChild(mainContent);
}
base.Content.AddRange(new Drawable[]
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index c68be5313d..d0a918a8f5 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -29,8 +29,8 @@
-
-
+
+
diff --git a/osu.iOS.props b/osu.iOS.props
index 36e581a80c..a389cc13dd 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -70,8 +70,8 @@
-
-
+
+
@@ -93,7 +93,7 @@
-
+