diff --git a/.run/Dual client test.run.xml b/.run/Dual client test.run.xml new file mode 100644 index 0000000000..e112aa3d5d --- /dev/null +++ b/.run/Dual client test.run.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.run/osu! (Second Client).run.xml b/.run/osu! (Second Client).run.xml new file mode 100644 index 0000000000..599b4b986b --- /dev/null +++ b/.run/osu! (Second Client).run.xml @@ -0,0 +1,20 @@ + + + + \ No newline at end of file diff --git a/osu.Android.props b/osu.Android.props index 171a0862a1..69a89c3cd0 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,8 +51,8 @@ - - + + diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index 5fb09c0cef..cbee1694ba 100644 --- a/osu.Desktop/Program.cs +++ b/osu.Desktop/Program.cs @@ -3,7 +3,6 @@ using System; using System.IO; -using System.Linq; using System.Threading; using System.Threading.Tasks; using osu.Framework; @@ -17,13 +16,43 @@ namespace osu.Desktop { public static class Program { + private const string base_game_name = @"osu"; + [STAThread] public static int Main(string[] args) { // Back up the cwd before DesktopGameHost changes it var cwd = Environment.CurrentDirectory; - using (DesktopGameHost host = Host.GetSuitableHost(@"osu", true)) + string gameName = base_game_name; + bool tournamentClient = false; + + foreach (var arg in args) + { + var split = arg.Split('='); + + var key = split[0]; + var val = split[1]; + + switch (key) + { + case "--tournament": + tournamentClient = true; + break; + + case "--debug-client-id": + if (!DebugUtils.IsDebugBuild) + throw new InvalidOperationException("Cannot use this argument in a non-debug build."); + + if (!int.TryParse(val, out int clientID)) + throw new ArgumentException("Provided client ID must be an integer."); + + gameName = $"{base_game_name}-{clientID}"; + break; + } + } + + using (DesktopGameHost host = Host.GetSuitableHost(gameName, true)) { host.ExceptionThrown += handleException; @@ -48,16 +77,10 @@ namespace osu.Desktop return 0; } - switch (args.FirstOrDefault() ?? string.Empty) - { - default: - host.Run(new OsuGameDesktop(args)); - break; - - case "--tournament": - host.Run(new TournamentGame()); - break; - } + if (tournamentClient) + host.Run(new TournamentGame()); + else + host.Run(new OsuGameDesktop(args)); return 0; } diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/CatchSelectionBlueprintTestScene.cs b/osu.Game.Rulesets.Catch.Tests/Editor/CatchSelectionBlueprintTestScene.cs index dcdc32145b..a458771550 100644 --- a/osu.Game.Rulesets.Catch.Tests/Editor/CatchSelectionBlueprintTestScene.cs +++ b/osu.Game.Rulesets.Catch.Tests/Editor/CatchSelectionBlueprintTestScene.cs @@ -1,10 +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 osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Catch.Beatmaps; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens.Edit; using osu.Game.Tests.Visual; +using osuTK; namespace osu.Game.Rulesets.Catch.Tests.Editor { @@ -14,11 +21,52 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor protected override Container Content => contentContainer; + [Cached(typeof(EditorBeatmap))] + [Cached(typeof(IBeatSnapProvider))] + protected readonly EditorBeatmap EditorBeatmap; + private readonly CatchEditorTestSceneContainer contentContainer; protected CatchSelectionBlueprintTestScene() { - base.Content.Add(contentContainer = new CatchEditorTestSceneContainer()); + EditorBeatmap = new EditorBeatmap(new CatchBeatmap()); + EditorBeatmap.BeatmapInfo.BaseDifficulty.CircleSize = 0; + EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint + { + BeatLength = 100 + }); + + base.Content.Add(new EditorBeatmapDependencyContainer(EditorBeatmap, new BindableBeatDivisor()) + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + EditorBeatmap, + contentContainer = new CatchEditorTestSceneContainer() + }, + }); + } + + protected void AddMouseMoveStep(double time, float x) => AddStep($"move to time={time}, x={x}", () => + { + float y = HitObjectContainer.PositionAtTime(time); + Vector2 pos = HitObjectContainer.ToScreenSpace(new Vector2(x, y + HitObjectContainer.DrawHeight)); + InputManager.MoveMouseTo(pos); + }); + + private class EditorBeatmapDependencyContainer : Container + { + [Cached] + private readonly EditorClock editorClock; + + [Cached] + private readonly BindableBeatDivisor beatDivisor; + + public EditorBeatmapDependencyContainer(IBeatmap beatmap, BindableBeatDivisor beatDivisor) + { + editorClock = new EditorClock(beatmap, beatDivisor); + this.beatDivisor = beatDivisor; + } } } } diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs index 1b96175020..f5ef5c5e18 100644 --- a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamSelectionBlueprint.cs @@ -1,38 +1,286 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Game.Beatmaps; -using osu.Game.Beatmaps.ControlPoints; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Framework.Utils; using osu.Game.Rulesets.Catch.Edit.Blueprints; +using osu.Game.Rulesets.Catch.Edit.Blueprints.Components; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osuTK; +using osuTK.Input; namespace osu.Game.Rulesets.Catch.Tests.Editor { public class TestSceneJuiceStreamSelectionBlueprint : CatchSelectionBlueprintTestScene { - public TestSceneJuiceStreamSelectionBlueprint() + private JuiceStream hitObject; + + private readonly ManualClock manualClock = new ManualClock(); + + [SetUp] + public void SetUp() => Schedule(() => { - var hitObject = new JuiceStream + EditorBeatmap.Clear(); + Content.Clear(); + + manualClock.CurrentTime = 0; + Content.Clock = new FramedClock(manualClock); + + InputManager.ReleaseButton(MouseButton.Left); + InputManager.ReleaseKey(Key.ShiftLeft); + InputManager.ReleaseKey(Key.ControlLeft); + }); + + [Test] + public void TestBasicComponentLayout() + { + double[] times = { 100, 300, 500 }; + float[] positions = { 100, 200, 100 }; + addBlueprintStep(times, positions); + + for (int i = 0; i < times.Length; i++) + addVertexCheckStep(times.Length, i, times[i], positions[i]); + + AddAssert("correct outline count", () => { - OriginalX = 100, - StartTime = 100, - Path = new SliderPath(PathType.PerfectCurve, new[] + var expected = hitObject.NestedHitObjects.Count(h => !(h is TinyDroplet)); + return this.ChildrenOfType().Count() == expected; + }); + AddAssert("correct vertex piece count", () => + this.ChildrenOfType().Count() == times.Length); + + AddAssert("first vertex is semitransparent", () => + Precision.DefinitelyBigger(1, this.ChildrenOfType().First().Alpha)); + } + + [Test] + public void TestVertexDrag() + { + double[] times = { 100, 400, 700 }; + float[] positions = { 100, 100, 100 }; + addBlueprintStep(times, positions); + + addDragStartStep(times[1], positions[1]); + + AddMouseMoveStep(500, 150); + addVertexCheckStep(3, 1, 500, 150); + + addDragEndStep(); + addDragStartStep(times[2], positions[2]); + + AddMouseMoveStep(300, 50); + addVertexCheckStep(3, 1, 300, 50); + addVertexCheckStep(3, 2, 500, 150); + + AddMouseMoveStep(-100, 100); + addVertexCheckStep(3, 1, times[0], positions[0]); + } + + [Test] + public void TestMultipleDrag() + { + double[] times = { 100, 300, 500, 700 }; + float[] positions = { 100, 100, 100, 100 }; + addBlueprintStep(times, positions); + + AddMouseMoveStep(times[1], positions[1]); + AddStep("press left", () => InputManager.PressButton(MouseButton.Left)); + AddStep("release left", () => InputManager.ReleaseButton(MouseButton.Left)); + AddStep("hold control", () => InputManager.PressKey(Key.ControlLeft)); + addDragStartStep(times[2], positions[2]); + + AddMouseMoveStep(times[2] - 50, positions[2] - 50); + addVertexCheckStep(4, 1, times[1] - 50, positions[1] - 50); + addVertexCheckStep(4, 2, times[2] - 50, positions[2] - 50); + } + + [Test] + public void TestClampedPositionIsRestored() + { + const double velocity = 0.25; + double[] times = { 100, 500, 700 }; + float[] positions = { 100, 100, 100 }; + addBlueprintStep(times, positions, velocity); + + addDragStartStep(times[1], positions[1]); + + AddMouseMoveStep(times[1], 200); + addVertexCheckStep(3, 1, times[1], 200); + addVertexCheckStep(3, 2, times[2], 150); + + AddMouseMoveStep(times[1], 100); + addVertexCheckStep(3, 1, times[1], 100); + // Stored position is restored. + addVertexCheckStep(3, 2, times[2], positions[2]); + + AddMouseMoveStep(times[1], 300); + addDragEndStep(); + addDragStartStep(times[1], 300); + + AddMouseMoveStep(times[1], 100); + // Position is different because a changed position is committed when the previous drag is ended. + addVertexCheckStep(3, 2, times[2], 250); + } + + [Test] + public void TestScrollWhileDrag() + { + double[] times = { 300, 500 }; + float[] positions = { 100, 100 }; + addBlueprintStep(times, positions); + + addDragStartStep(times[1], positions[1]); + // This mouse move is necessary to start drag and capture the input. + AddMouseMoveStep(times[1], positions[1] + 50); + + AddStep("scroll playfield", () => manualClock.CurrentTime += 200); + AddMouseMoveStep(times[1] + 200, positions[1] + 100); + addVertexCheckStep(2, 1, times[1] + 200, positions[1] + 100); + } + + [Test] + public void TestUpdateFromHitObject() + { + double[] times = { 100, 300 }; + float[] positions = { 200, 200 }; + addBlueprintStep(times, positions); + + AddStep("update hit object path", () => + { + hitObject.Path = new SliderPath(PathType.PerfectCurve, new[] { Vector2.Zero, - new Vector2(200, 100), + new Vector2(100, 100), new Vector2(0, 200), - }), - }; - var controlPoint = new ControlPointInfo(); - controlPoint.Add(0, new TimingControlPoint - { - BeatLength = 100 + }); + EditorBeatmap.Update(hitObject); }); - hitObject.ApplyDefaults(controlPoint, new BeatmapDifficulty { CircleSize = 0 }); + AddAssert("path is updated", () => getVertices().Count > 2); + } + + [Test] + public void TestAddVertex() + { + double[] times = { 100, 700 }; + float[] positions = { 200, 200 }; + addBlueprintStep(times, positions, 0.2); + + addAddVertexSteps(500, 150); + addVertexCheckStep(3, 1, 500, 150); + + addAddVertexSteps(90, 220); + addVertexCheckStep(4, 1, times[0], positions[0]); + + addAddVertexSteps(750, 180); + addVertexCheckStep(5, 4, 750, 180); + AddAssert("duration is changed", () => Precision.AlmostEquals(hitObject.Duration, 800 - times[0], 1e-3)); + } + + [Test] + public void TestDeleteVertex() + { + double[] times = { 100, 300, 500 }; + float[] positions = { 100, 200, 150 }; + addBlueprintStep(times, positions); + + addDeleteVertexSteps(times[1], positions[1]); + addVertexCheckStep(2, 1, times[2], positions[2]); + + // The first vertex cannot be deleted. + addDeleteVertexSteps(times[0], positions[0]); + addVertexCheckStep(2, 0, times[0], positions[0]); + + addDeleteVertexSteps(times[2], positions[2]); + addVertexCheckStep(1, 0, times[0], positions[0]); + } + + [Test] + public void TestVertexResampling() + { + addBlueprintStep(100, 100, new SliderPath(PathType.PerfectCurve, new[] + { + Vector2.Zero, + new Vector2(100, 100), + new Vector2(50, 200), + }), 0.5); + AddAssert("1 vertex per 1 nested HO", () => getVertices().Count == hitObject.NestedHitObjects.Count); + AddAssert("slider path not yet changed", () => hitObject.Path.ControlPoints[0].Type.Value == PathType.PerfectCurve); + addAddVertexSteps(150, 150); + AddAssert("slider path change to linear", () => hitObject.Path.ControlPoints[0].Type.Value == PathType.Linear); + } + + private void addBlueprintStep(double time, float x, SliderPath sliderPath, double velocity) => AddStep("add selection blueprint", () => + { + hitObject = new JuiceStream + { + StartTime = time, + X = x, + Path = sliderPath, + }; + EditorBeatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier = velocity; + EditorBeatmap.Add(hitObject); + EditorBeatmap.Update(hitObject); + Assert.That(hitObject.Velocity, Is.EqualTo(velocity)); AddBlueprint(new JuiceStreamSelectionBlueprint(hitObject)); + }); + + private void addBlueprintStep(double[] times, float[] positions, double velocity = 0.5) + { + var path = new JuiceStreamPath(); + for (int i = 1; i < times.Length; i++) + path.Add((times[i] - times[0]) * velocity, positions[i] - positions[0]); + + var sliderPath = new SliderPath(); + path.ConvertToSliderPath(sliderPath, 0); + addBlueprintStep(times[0], positions[0], sliderPath, velocity); + } + + private IReadOnlyList getVertices() => this.ChildrenOfType().Single().Vertices; + + private void addVertexCheckStep(int count, int index, double time, float x) => AddAssert($"vertex {index} of {count} at {time}, {x}", () => + { + double expectedDistance = (time - hitObject.StartTime) * hitObject.Velocity; + float expectedX = x - hitObject.OriginalX; + var vertices = getVertices(); + return vertices.Count == count && + Precision.AlmostEquals(vertices[index].Distance, expectedDistance, 1e-3) && + Precision.AlmostEquals(vertices[index].X, expectedX); + }); + + private void addDragStartStep(double time, float x) + { + AddMouseMoveStep(time, x); + AddStep("start dragging", () => InputManager.PressButton(MouseButton.Left)); + } + + private void addDragEndStep() => AddStep("end dragging", () => InputManager.ReleaseButton(MouseButton.Left)); + + private void addAddVertexSteps(double time, float x) + { + AddMouseMoveStep(time, x); + AddStep("add vertex", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Click(MouseButton.Left); + InputManager.ReleaseKey(Key.ControlLeft); + }); + } + + private void addDeleteVertexSteps(double time, float x) + { + AddMouseMoveStep(time, x); + AddStep("delete vertex", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Click(MouseButton.Left); + InputManager.ReleaseKey(Key.ShiftLeft); + }); } } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchSkinConfiguration.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchSkinConfiguration.cs index ec186bcfb2..83f28086e6 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchSkinConfiguration.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchSkinConfiguration.cs @@ -3,7 +3,6 @@ using System.Linq; using NUnit.Framework; -using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -24,16 +23,12 @@ namespace osu.Game.Rulesets.Catch.Tests { public class TestSceneCatchSkinConfiguration : OsuTestScene { - [Cached] - private readonly DroppedObjectContainer droppedObjectContainer; - private Catcher catcher; private readonly Container container; public TestSceneCatchSkinConfiguration() { - Add(droppedObjectContainer = new DroppedObjectContainer()); Add(container = new Container { RelativeSizeAxes = Axes.Both }); } @@ -46,7 +41,7 @@ namespace osu.Game.Rulesets.Catch.Tests var skin = new TestSkin { FlipCatcherPlate = flip }; container.Child = new SkinProvidingContainer(skin) { - Child = catcher = new Catcher(new Container()) + Child = catcher = new Catcher(new Container(), new DroppedObjectContainer()) { Anchor = Anchor.Centre } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs index 0a2dff6a21..b4282e6784 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs @@ -31,10 +31,12 @@ namespace osu.Game.Rulesets.Catch.Tests [Resolved] private OsuConfigManager config { get; set; } - private TestCatcher catcher; + private Container trailContainer; private DroppedObjectContainer droppedObjectContainer; + private TestCatcher catcher; + [SetUp] public void SetUp() => Schedule(() => { @@ -43,24 +45,18 @@ namespace osu.Game.Rulesets.Catch.Tests CircleSize = 0, }; - var trailContainer = new Container + trailContainer = new Container(); + droppedObjectContainer = new DroppedObjectContainer(); + + Child = new Container { Anchor = Anchor.Centre, - }; - droppedObjectContainer = new DroppedObjectContainer(); - Child = new DependencyProvidingContainer - { - CachedDependencies = new (Type, object)[] - { - (typeof(DroppedObjectContainer), droppedObjectContainer), - }, Children = new Drawable[] { droppedObjectContainer, - catcher = new TestCatcher(trailContainer, difficulty), - trailContainer - }, - Anchor = Anchor.Centre + catcher = new TestCatcher(trailContainer, droppedObjectContainer, difficulty), + trailContainer, + } }; }); @@ -298,8 +294,8 @@ namespace osu.Game.Rulesets.Catch.Tests { public IEnumerable CaughtObjects => this.ChildrenOfType(); - public TestCatcher(Container trailsTarget, BeatmapDifficulty difficulty) - : base(trailsTarget, difficulty) + public TestCatcher(Container trailsTarget, DroppedObjectContainer droppedObjectTarget, BeatmapDifficulty difficulty) + : base(trailsTarget, droppedObjectTarget, difficulty) { } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs index 877e115e2f..6a518cf0ef 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs @@ -77,7 +77,7 @@ namespace osu.Game.Rulesets.Catch.Tests { area.OnNewResult(drawable, new CatchJudgementResult(fruit, new CatchJudgement()) { - Type = area.MovableCatcher.CanCatch(fruit) ? HitResult.Great : HitResult.Miss + Type = area.Catcher.CanCatch(fruit) ? HitResult.Great : HitResult.Miss }); drawable.Expire(); @@ -119,16 +119,19 @@ namespace osu.Game.Rulesets.Catch.Tests private class TestCatcherArea : CatcherArea { - [Cached] - private readonly DroppedObjectContainer droppedObjectContainer; - public TestCatcherArea(BeatmapDifficulty beatmapDifficulty) - : base(beatmapDifficulty) { - AddInternal(droppedObjectContainer = new DroppedObjectContainer()); + var droppedObjectContainer = new DroppedObjectContainer(); + + Add(droppedObjectContainer); + + Catcher = new Catcher(this, droppedObjectContainer, beatmapDifficulty) + { + X = CatchPlayfield.CENTER_X + }; } - public void ToggleHyperDash(bool status) => MovableCatcher.SetHyperDashState(status ? 2 : 1); + public void ToggleHyperDash(bool status) => Catcher.SetHyperDashState(status ? 2 : 1); } } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs index fd6a9c7b7b..e7c7dc3c98 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneDrawableHitObjects.cs @@ -97,7 +97,7 @@ namespace osu.Game.Rulesets.Catch.Tests private bool playfieldIsEmpty => !((CatchPlayfield)drawableRuleset.Playfield).AllHitObjects.Any(h => h.IsAlive); - private CatcherAnimationState catcherState => ((CatchPlayfield)drawableRuleset.Playfield).CatcherArea.MovableCatcher.CurrentState; + private CatcherAnimationState catcherState => ((CatchPlayfield)drawableRuleset.Playfield).Catcher.CurrentState; private void spawnFruits(bool hit = false) { @@ -162,7 +162,7 @@ namespace osu.Game.Rulesets.Catch.Tests float xCoords = CatchPlayfield.CENTER_X; if (drawableRuleset.Playfield is CatchPlayfield catchPlayfield) - catchPlayfield.CatcherArea.MovableCatcher.X = xCoords - x_offset; + catchPlayfield.Catcher.X = xCoords - x_offset; if (hit) xCoords -= x_offset; diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDash.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDash.cs index db09b2bc6b..163fee49fb 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDash.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDash.cs @@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Catch.Tests // this needs to be done within the frame stable context due to how quickly hyperdash state changes occur. Player.DrawableRuleset.FrameStableComponents.OnUpdate += d => { - var catcher = Player.ChildrenOfType().FirstOrDefault()?.MovableCatcher; + var catcher = Player.ChildrenOfType().FirstOrDefault(); if (catcher == null) return; diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs index e7b0259ea2..73797d0a6a 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs @@ -113,36 +113,45 @@ namespace osu.Game.Rulesets.Catch.Tests private void checkHyperDashCatcherColour(ISkin skin, Color4 expectedCatcherColour, Color4? expectedEndGlowColour = null) { - CatcherArea catcherArea = null; + Container trailsContainer = null; + Catcher catcher = null; CatcherTrailDisplay trails = null; AddStep("create hyper-dashing catcher", () => { - Child = setupSkinHierarchy(catcherArea = new TestCatcherArea + trailsContainer = new Container(); + Child = setupSkinHierarchy(new Container { Anchor = Anchor.Centre, - Origin = Anchor.Centre + Children = new Drawable[] + { + catcher = new Catcher(trailsContainer, new DroppedObjectContainer()) + { + Scale = new Vector2(4) + }, + trailsContainer + } }, skin); }); AddStep("get trails container", () => { - trails = catcherArea.OfType().Single(); - catcherArea.MovableCatcher.SetHyperDashState(2); + trails = trailsContainer.OfType().Single(); + catcher.SetHyperDashState(2); }); - AddUntilStep("catcher colour is correct", () => catcherArea.MovableCatcher.Colour == expectedCatcherColour); + AddUntilStep("catcher colour is correct", () => catcher.Colour == expectedCatcherColour); AddAssert("catcher trails colours are correct", () => trails.HyperDashTrailsColour == expectedCatcherColour); AddAssert("catcher end-glow colours are correct", () => trails.EndGlowSpritesColour == (expectedEndGlowColour ?? expectedCatcherColour)); AddStep("finish hyper-dashing", () => { - catcherArea.MovableCatcher.SetHyperDashState(); - catcherArea.MovableCatcher.FinishTransforms(); + catcher.SetHyperDashState(); + catcher.FinishTransforms(); }); - AddAssert("catcher colour returned to white", () => catcherArea.MovableCatcher.Colour == Color4.White); + AddAssert("catcher colour returned to white", () => catcher.Colour == Color4.White); } private void checkHyperDashFruitColour(ISkin skin, Color4 expectedColour) @@ -205,18 +214,5 @@ namespace osu.Game.Rulesets.Catch.Tests { } } - - private class TestCatcherArea : CatcherArea - { - [Cached] - private readonly DroppedObjectContainer droppedObjectContainer; - - public TestCatcherArea() - { - Scale = new Vector2(4f); - - AddInternal(droppedObjectContainer = new DroppedObjectContainer()); - } - } } } diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs new file mode 100644 index 0000000000..8aaeef045f --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/EditablePath.cs @@ -0,0 +1,190 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components +{ + public abstract class EditablePath : CompositeDrawable + { + public int PathId => path.InvalidationID; + + public IReadOnlyList Vertices => path.Vertices; + + public int VertexCount => path.Vertices.Count; + + protected readonly Func PositionToDistance; + + protected IReadOnlyList VertexStates => vertexStates; + + private readonly JuiceStreamPath path = new JuiceStreamPath(); + + // Invariant: `path.Vertices.Count == vertexStates.Count` + private readonly List vertexStates = new List + { + new VertexState { IsFixed = true } + }; + + private readonly List previousVertexStates = new List(); + + [Resolved(CanBeNull = true)] + [CanBeNull] + private IBeatSnapProvider beatSnapProvider { get; set; } + + protected EditablePath(Func positionToDistance) + { + PositionToDistance = positionToDistance; + + Anchor = Anchor.BottomLeft; + } + + public void UpdateFrom(ScrollingHitObjectContainer hitObjectContainer, JuiceStream hitObject) + { + while (path.Vertices.Count < InternalChildren.Count) + RemoveInternal(InternalChildren[^1]); + + while (InternalChildren.Count < path.Vertices.Count) + AddInternal(new VertexPiece()); + + double distanceToYFactor = -hitObjectContainer.LengthAtTime(hitObject.StartTime, hitObject.StartTime + 1 / hitObject.Velocity); + + for (int i = 0; i < VertexCount; i++) + { + var piece = (VertexPiece)InternalChildren[i]; + var vertex = path.Vertices[i]; + piece.Position = new Vector2(vertex.X, (float)(vertex.Distance * distanceToYFactor)); + piece.UpdateFrom(vertexStates[i]); + } + } + + public void InitializeFromHitObject(JuiceStream hitObject) + { + var sliderPath = hitObject.Path; + path.ConvertFromSliderPath(sliderPath); + + // If the original slider path has non-linear type segments, resample the vertices at nested hit object times to reduce the number of vertices. + if (sliderPath.ControlPoints.Any(p => p.Type.Value != null && p.Type.Value != PathType.Linear)) + { + path.ResampleVertices(hitObject.NestedHitObjects + .Skip(1).TakeWhile(h => !(h is Fruit)) // Only droplets in the first span are used. + .Select(h => (h.StartTime - hitObject.StartTime) * hitObject.Velocity)); + } + + vertexStates.Clear(); + vertexStates.AddRange(path.Vertices.Select((_, i) => new VertexState + { + IsFixed = i == 0 + })); + } + + public void UpdateHitObjectFromPath(JuiceStream hitObject) + { + path.ConvertToSliderPath(hitObject.Path, hitObject.LegacyConvertedY); + + if (beatSnapProvider == null) return; + + double endTime = hitObject.StartTime + path.Distance / hitObject.Velocity; + double snappedEndTime = beatSnapProvider.SnapTime(endTime, hitObject.StartTime); + hitObject.Path.ExpectedDistance.Value = (snappedEndTime - hitObject.StartTime) * hitObject.Velocity; + } + + public Vector2 ToRelativePosition(Vector2 screenSpacePosition) + { + return ToLocalSpace(screenSpacePosition) - new Vector2(0, DrawHeight); + } + + protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false; + + protected int AddVertex(double distance, float x) + { + int index = path.InsertVertex(distance); + path.SetVertexPosition(index, x); + vertexStates.Insert(index, new VertexState()); + + correctFixedVertexPositions(); + + Debug.Assert(vertexStates.Count == VertexCount); + return index; + } + + protected bool RemoveVertex(int index) + { + if (index < 0 || index >= path.Vertices.Count) + return false; + + if (vertexStates[index].IsFixed) + return false; + + path.RemoveVertices((_, i) => i == index); + + vertexStates.RemoveAt(index); + if (vertexStates.Count == 0) + vertexStates.Add(new VertexState()); + + Debug.Assert(vertexStates.Count == VertexCount); + return true; + } + + protected void MoveSelectedVertices(double distanceDelta, float xDelta) + { + // Because the vertex list may be reordered due to distance change, the state list must be reordered as well. + previousVertexStates.Clear(); + previousVertexStates.AddRange(vertexStates); + + // We will recreate the path from scratch. Note that `Clear` leaves the first vertex. + int vertexCount = VertexCount; + path.Clear(); + vertexStates.RemoveRange(1, vertexCount - 1); + + for (int i = 1; i < vertexCount; i++) + { + var state = previousVertexStates[i]; + double distance = state.VertexBeforeChange.Distance; + if (state.IsSelected) + distance += distanceDelta; + + int newIndex = path.InsertVertex(Math.Max(0, distance)); + vertexStates.Insert(newIndex, state); + } + + // First, restore positions of the non-selected vertices. + for (int i = 0; i < vertexCount; i++) + { + if (!vertexStates[i].IsSelected && !vertexStates[i].IsFixed) + path.SetVertexPosition(i, vertexStates[i].VertexBeforeChange.X); + } + + // Then, move the selected vertices. + for (int i = 0; i < vertexCount; i++) + { + if (vertexStates[i].IsSelected && !vertexStates[i].IsFixed) + path.SetVertexPosition(i, vertexStates[i].VertexBeforeChange.X + xDelta); + } + + // Finally, correct the position of fixed vertices. + correctFixedVertexPositions(); + } + + private void correctFixedVertexPositions() + { + for (int i = 0; i < VertexCount; i++) + { + if (vertexStates[i].IsFixed) + path.SetVertexPosition(i, vertexStates[i].VertexBeforeChange.X); + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/SelectionEditablePath.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/SelectionEditablePath.cs new file mode 100644 index 0000000000..8c7314d0b6 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/SelectionEditablePath.cs @@ -0,0 +1,130 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; +using osu.Game.Screens.Edit; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components +{ + public class SelectionEditablePath : EditablePath, IHasContextMenu + { + public MenuItem[] ContextMenuItems => getContextMenuItems().ToArray(); + + // To handle when the editor is scrolled while dragging. + private Vector2 dragStartPosition; + + [Resolved(CanBeNull = true)] + [CanBeNull] + private IEditorChangeHandler changeHandler { get; set; } + + public SelectionEditablePath(Func positionToDistance) + : base(positionToDistance) + { + } + + public void AddVertex(Vector2 relativePosition) + { + double distance = Math.Max(0, PositionToDistance(relativePosition.Y)); + int index = AddVertex(distance, relativePosition.X); + selectOnly(index); + } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => InternalChildren.Any(d => d.ReceivePositionalInputAt(screenSpacePos)); + + protected override bool OnMouseDown(MouseDownEvent e) + { + int index = getMouseTargetVertex(e.ScreenSpaceMouseDownPosition); + if (index == -1 || VertexStates[index].IsFixed) + return false; + + if (e.Button == MouseButton.Left && e.ShiftPressed) + { + RemoveVertex(index); + return true; + } + + if (e.ControlPressed) + VertexStates[index].IsSelected = !VertexStates[index].IsSelected; + else if (!VertexStates[index].IsSelected) + selectOnly(index); + + // Don't inhibit right click, to show the context menu + return e.Button != MouseButton.Right; + } + + protected override bool OnDragStart(DragStartEvent e) + { + int index = getMouseTargetVertex(e.ScreenSpaceMouseDownPosition); + if (index == -1 || VertexStates[index].IsFixed) + return false; + + if (e.Button != MouseButton.Left) + return false; + + dragStartPosition = ToRelativePosition(e.ScreenSpaceMouseDownPosition); + + for (int i = 0; i < VertexCount; i++) + VertexStates[i].VertexBeforeChange = Vertices[i]; + + changeHandler?.BeginChange(); + return true; + } + + protected override void OnDrag(DragEvent e) + { + Vector2 mousePosition = ToRelativePosition(e.ScreenSpaceMousePosition); + double distanceDelta = PositionToDistance(mousePosition.Y) - PositionToDistance(dragStartPosition.Y); + float xDelta = mousePosition.X - dragStartPosition.X; + MoveSelectedVertices(distanceDelta, xDelta); + } + + protected override void OnDragEnd(DragEndEvent e) + { + changeHandler?.EndChange(); + } + + private int getMouseTargetVertex(Vector2 screenSpacePosition) + { + for (int i = InternalChildren.Count - 1; i >= 0; i--) + { + if (i < VertexCount && InternalChildren[i].ReceivePositionalInputAt(screenSpacePosition)) + return i; + } + + return -1; + } + + private IEnumerable getContextMenuItems() + { + int selectedCount = VertexStates.Count(state => state.IsSelected); + + if (selectedCount != 0) + yield return new OsuMenuItem($"Delete selected {(selectedCount == 1 ? "vertex" : $"{selectedCount} vertices")}", MenuItemType.Destructive, deleteSelectedVertices); + } + + private void selectOnly(int index) + { + for (int i = 0; i < VertexCount; i++) + VertexStates[i].IsSelected = i == index; + } + + private void deleteSelectedVertices() + { + for (int i = VertexCount - 1; i >= 0; i--) + { + if (VertexStates[i].IsSelected) + RemoveVertex(i); + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/VertexPiece.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/VertexPiece.cs new file mode 100644 index 0000000000..5ef86b6074 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/VertexPiece.cs @@ -0,0 +1,31 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components +{ + public class VertexPiece : Circle + { + [Resolved] + private OsuColour osuColour { get; set; } + + public VertexPiece() + { + Anchor = Anchor.BottomLeft; + Origin = Anchor.Centre; + Size = new Vector2(15); + } + + public void UpdateFrom(VertexState state) + { + Colour = state.IsSelected ? osuColour.Yellow.Lighten(1) : osuColour.Yellow; + Alpha = state.IsFixed ? 0.5f : 1; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/VertexState.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/VertexState.cs new file mode 100644 index 0000000000..3f240c7944 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/VertexState.cs @@ -0,0 +1,31 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Catch.Objects; + +#nullable enable + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components +{ + /// + /// Holds the state of a vertex in the path of a . + /// + public class VertexState + { + /// + /// Whether the vertex is selected. + /// + public bool IsSelected { get; set; } + + /// + /// Whether the vertex can be moved or deleted. + /// + public bool IsFixed { get; set; } + + /// + /// The position of the vertex before a vertex moving operation starts. + /// This is used to implement "memory-less" moving operations (only the final position matters) to improve UX. + /// + public JuiceStreamPathVertex VertexBeforeChange { get; set; } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs index 0614c4c24d..890d059d19 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs @@ -1,15 +1,22 @@ // 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 System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Caching; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Catch.Edit.Blueprints.Components; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Objects; +using osu.Game.Screens.Edit; using osuTK; +using osuTK.Input; namespace osu.Game.Rulesets.Catch.Edit.Blueprints { @@ -17,6 +24,8 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints { public override Quad SelectionQuad => HitObjectContainer.ToScreenSpace(getBoundingBox().Offset(new Vector2(0, HitObjectContainer.DrawHeight))); + public override MenuItem[] ContextMenuItems => getContextMenuItems().ToArray(); + private float minNestedX; private float maxNestedX; @@ -26,13 +35,34 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints private readonly Cached pathCache = new Cached(); + private readonly SelectionEditablePath editablePath; + + /// + /// The of the corresponding the current of the hit object. + /// When the path is edited, the change is detected and the of the hit object is updated. + /// + private int lastEditablePathId = -1; + + /// + /// The of the current of the hit object. + /// When the of the hit object is changed by external means, the change is detected and the is re-initialized. + /// + private int lastSliderPathVersion = -1; + + private Vector2 rightMouseDownPosition; + + [Resolved(CanBeNull = true)] + [CanBeNull] + private EditorBeatmap editorBeatmap { get; set; } + public JuiceStreamSelectionBlueprint(JuiceStream hitObject) : base(hitObject) { InternalChildren = new Drawable[] { scrollingPath = new ScrollingPath(), - nestedOutlineContainer = new NestedOutlineContainer() + nestedOutlineContainer = new NestedOutlineContainer(), + editablePath = new SelectionEditablePath(positionToDistance) }; } @@ -49,7 +79,13 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints if (!IsSelected) return; - nestedOutlineContainer.Position = scrollingPath.Position = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject); + if (editablePath.PathId != lastEditablePathId) + updateHitObjectFromPath(); + + Vector2 startPosition = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject); + editablePath.Position = nestedOutlineContainer.Position = scrollingPath.Position = startPosition; + + editablePath.UpdateFrom(HitObjectContainer, HitObject); if (pathCache.IsValid) return; @@ -59,10 +95,38 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints pathCache.Validate(); } + protected override void OnSelected() + { + initializeJuiceStreamPath(); + base.OnSelected(); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + if (!IsSelected) return base.OnMouseDown(e); + + switch (e.Button) + { + case MouseButton.Left when e.ControlPressed: + editablePath.AddVertex(editablePath.ToRelativePosition(e.ScreenSpaceMouseDownPosition)); + return true; + + case MouseButton.Right: + // Record the mouse position to be used in the "add vertex" action. + rightMouseDownPosition = editablePath.ToRelativePosition(e.ScreenSpaceMouseDownPosition); + break; + } + + return base.OnMouseDown(e); + } + private void onDefaultsApplied(HitObject _) { computeObjectBounds(); pathCache.Invalidate(); + + if (lastSliderPathVersion != HitObject.Path.Version.Value) + initializeJuiceStreamPath(); } private void computeObjectBounds() @@ -81,6 +145,38 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints return new RectangleF(left, top, right - left, bottom - top).Inflate(objectRadius); } + private double positionToDistance(float relativeYPosition) + { + double time = HitObjectContainer.TimeAtPosition(relativeYPosition, HitObject.StartTime); + return (time - HitObject.StartTime) * HitObject.Velocity; + } + + private void initializeJuiceStreamPath() + { + editablePath.InitializeFromHitObject(HitObject); + + // Record the current ID to update the hit object only when a change is made to the path. + lastEditablePathId = editablePath.PathId; + lastSliderPathVersion = HitObject.Path.Version.Value; + } + + private void updateHitObjectFromPath() + { + editablePath.UpdateHitObjectFromPath(HitObject); + editorBeatmap?.Update(HitObject); + + lastEditablePathId = editablePath.PathId; + lastSliderPathVersion = HitObject.Path.Version.Value; + } + + private IEnumerable getContextMenuItems() + { + yield return new OsuMenuItem("Add vertex", MenuItemType.Standard, () => + { + editablePath.AddVertex(rightMouseDownPosition); + }); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfield.cs b/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfield.cs index d383eb9ba6..8c9f292aa9 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfield.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchEditorPlayfield.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Catch.Edit base.LoadComplete(); // TODO: honor "hit animation" setting? - CatcherArea.MovableCatcher.CatchFruitOnPlate = false; + Catcher.CatchFruitOnPlate = false; // TODO: disable hit lighting as well } diff --git a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectUtils.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectUtils.cs index beffdf0362..b059926668 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectUtils.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectUtils.cs @@ -1,7 +1,10 @@ // 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 System.Linq; using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI.Scrolling; using osuTK; @@ -20,5 +23,41 @@ namespace osu.Game.Rulesets.Catch.Edit { return new Vector2(hitObject.OriginalX, hitObjectContainer.PositionAtTime(hitObject.StartTime)); } + + /// + /// Get the range of horizontal position occupied by the hit object. + /// + /// + /// s are excluded and returns . + /// + public static PositionRange GetPositionRange(HitObject hitObject) + { + switch (hitObject) + { + case Fruit fruit: + return new PositionRange(fruit.OriginalX); + + case Droplet droplet: + return droplet is TinyDroplet ? PositionRange.EMPTY : new PositionRange(droplet.OriginalX); + + case JuiceStream _: + return GetPositionRange(hitObject.NestedHitObjects); + + case BananaShower _: + // A banana shower occupies the whole screen width. + return new PositionRange(0, CatchPlayfield.WIDTH); + + default: + return PositionRange.EMPTY; + } + } + + /// + /// Get the range of horizontal position occupied by the hit objects. + /// + /// + /// s are excluded. + /// + public static PositionRange GetPositionRange(IEnumerable hitObjects) => hitObjects.Select(GetPositionRange).Aggregate(PositionRange.EMPTY, PositionRange.Union); } } diff --git a/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs b/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs index 7eebf04ca2..36072d7fcb 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchSelectionHandler.cs @@ -12,6 +12,7 @@ using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Screens.Edit.Compose.Components; using osuTK; +using Direction = osu.Framework.Graphics.Direction; namespace osu.Game.Rulesets.Catch.Edit { @@ -29,7 +30,7 @@ namespace osu.Game.Rulesets.Catch.Edit Vector2 targetPosition = HitObjectContainer.ToLocalSpace(blueprint.ScreenSpaceSelectionPoint + moveEvent.ScreenSpaceDelta); float deltaX = targetPosition.X - originalPosition.X; - deltaX = limitMovement(deltaX, EditorBeatmap.SelectedHitObjects); + deltaX = limitMovement(deltaX, SelectedItems); if (deltaX == 0) { @@ -39,18 +40,60 @@ namespace osu.Game.Rulesets.Catch.Edit EditorBeatmap.PerformOnSelection(h => { - if (!(h is CatchHitObject hitObject)) return; + if (!(h is CatchHitObject catchObject)) return; - hitObject.OriginalX += deltaX; + catchObject.OriginalX += deltaX; // Move the nested hit objects to give an instant result before nested objects are recreated. - foreach (var nested in hitObject.NestedHitObjects.OfType()) + foreach (var nested in catchObject.NestedHitObjects.OfType()) nested.OriginalX += deltaX; }); return true; } + public override bool HandleFlip(Direction direction) + { + var selectionRange = CatchHitObjectUtils.GetPositionRange(SelectedItems); + + bool changed = false; + EditorBeatmap.PerformOnSelection(h => + { + if (h is CatchHitObject catchObject) + changed |= handleFlip(selectionRange, catchObject); + }); + return changed; + } + + public override bool HandleReverse() + { + double selectionStartTime = SelectedItems.Min(h => h.StartTime); + double selectionEndTime = SelectedItems.Max(h => h.GetEndTime()); + + EditorBeatmap.PerformOnSelection(hitObject => + { + hitObject.StartTime = selectionEndTime - (hitObject.GetEndTime() - selectionStartTime); + + if (hitObject is JuiceStream juiceStream) + { + juiceStream.Path.Reverse(out Vector2 positionalOffset); + juiceStream.OriginalX += positionalOffset.X; + juiceStream.LegacyConvertedY += positionalOffset.Y; + EditorBeatmap.Update(juiceStream); + } + }); + return true; + } + + protected override void OnSelectionChanged() + { + base.OnSelectionChanged(); + + var selectionRange = CatchHitObjectUtils.GetPositionRange(SelectedItems); + SelectionBox.CanFlipX = selectionRange.Length > 0 && SelectedItems.Any(h => h is CatchHitObject && !(h is BananaShower)); + SelectionBox.CanReverse = SelectedItems.Count > 1 || SelectedItems.Any(h => h is JuiceStream); + } + /// /// Limit positional movement of the objects by the constraint that moved objects should stay in bounds. /// @@ -59,20 +102,12 @@ namespace osu.Game.Rulesets.Catch.Edit /// The positional movement with the restriction applied. private float limitMovement(float deltaX, IEnumerable movingObjects) { - float minX = float.PositiveInfinity; - float maxX = float.NegativeInfinity; - - foreach (float x in movingObjects.SelectMany(getOriginalPositions)) - { - minX = Math.Min(minX, x); - maxX = Math.Max(maxX, x); - } - + var range = CatchHitObjectUtils.GetPositionRange(movingObjects); // To make an object with position `x` stay in bounds after `deltaX` movement, `0 <= x + deltaX <= WIDTH` should be satisfied. // Subtracting `x`, we get `-x <= deltaX <= WIDTH - x`. // We only need to apply the inequality to extreme values of `x`. - float lowerBound = -minX; - float upperBound = CatchPlayfield.WIDTH - maxX; + float lowerBound = -range.Min; + float upperBound = CatchPlayfield.WIDTH - range.Max; // The inequality may be unsatisfiable if the objects were already out of bounds. // In that case, don't move objects at all. if (lowerBound > upperBound) @@ -81,35 +116,25 @@ namespace osu.Game.Rulesets.Catch.Edit return Math.Clamp(deltaX, lowerBound, upperBound); } - /// - /// Enumerate X positions that should be contained in-bounds after move offset is applied. - /// - private IEnumerable getOriginalPositions(HitObject hitObject) + private bool handleFlip(PositionRange selectionRange, CatchHitObject hitObject) { switch (hitObject) { - case Fruit fruit: - yield return fruit.OriginalX; - - break; + case BananaShower _: + return false; case JuiceStream juiceStream: - foreach (var nested in juiceStream.NestedHitObjects.OfType()) - { - // Even if `OriginalX` is outside the playfield, tiny droplets can be moved inside the playfield after the random offset application. - if (!(nested is TinyDroplet)) - yield return nested.OriginalX; - } + juiceStream.OriginalX = selectionRange.GetFlippedPosition(juiceStream.OriginalX); - break; + foreach (var point in juiceStream.Path.ControlPoints) + point.Position.Value *= new Vector2(-1, 1); - case BananaShower _: - // A banana shower occupies the whole screen width. - // If the selection contains a banana shower, the selection cannot be moved horizontally. - yield return 0; - yield return CatchPlayfield.WIDTH; + EditorBeatmap.Update(juiceStream); + return true; - break; + default: + hitObject.OriginalX = selectionRange.GetFlippedPosition(hitObject.OriginalX); + return true; } } } diff --git a/osu.Game.Rulesets.Catch/Edit/PositionRange.cs b/osu.Game.Rulesets.Catch/Edit/PositionRange.cs new file mode 100644 index 0000000000..e61603e5e6 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/PositionRange.cs @@ -0,0 +1,42 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +#nullable enable + +namespace osu.Game.Rulesets.Catch.Edit +{ + /// + /// Represents either the empty range or a closed interval of horizontal positions in the playfield. + /// A represents a closed interval if it is <= , and represents the empty range otherwise. + /// + public readonly struct PositionRange + { + public readonly float Min; + public readonly float Max; + + public float Length => Math.Max(0, Max - Min); + + public PositionRange(float value) + : this(value, value) + { + } + + public PositionRange(float min, float max) + { + Min = min; + Max = max; + } + + public static PositionRange Union(PositionRange a, PositionRange b) => new PositionRange(Math.Min(a.Min, b.Min), Math.Max(a.Max, b.Max)); + + /// + /// Get the given position flipped (mirrored) for the axis at the center of this range. + /// Returns the given position unchanged if the range was empty. + /// + public float GetFlippedPosition(float x) => Min <= Max ? Max - (x - Min) : x; + + public static readonly PositionRange EMPTY = new PositionRange(float.PositiveInfinity, float.NegativeInfinity); + } +} diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModFlashlight.cs b/osu.Game.Rulesets.Catch/Mods/CatchModFlashlight.cs index 71268d899d..f399f48ebd 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModFlashlight.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModFlashlight.cs @@ -41,9 +41,7 @@ namespace osu.Game.Rulesets.Catch.Mods { base.Update(); - var catcherArea = playfield.CatcherArea; - - FlashlightPosition = catcherArea.ToSpaceOfOtherDrawable(catcherArea.MovableCatcher.DrawPosition, this); + FlashlightPosition = playfield.CatcherArea.ToSpaceOfOtherDrawable(playfield.Catcher.DrawPosition, this); } private float getSizeFor(int combo) diff --git a/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs b/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs index f9e106f097..d48edbcd74 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModHidden.cs @@ -25,7 +25,7 @@ namespace osu.Game.Rulesets.Catch.Mods var drawableCatchRuleset = (DrawableCatchRuleset)drawableRuleset; var catchPlayfield = (CatchPlayfield)drawableCatchRuleset.Playfield; - catchPlayfield.CatcherArea.MovableCatcher.CatchFruitOnPlate = false; + catchPlayfield.Catcher.CatchFruitOnPlate = false; } protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state) diff --git a/osu.Game.Rulesets.Catch/Objects/Banana.cs b/osu.Game.Rulesets.Catch/Objects/Banana.cs index 178306b3bc..e5a36d08db 100644 --- a/osu.Game.Rulesets.Catch/Objects/Banana.cs +++ b/osu.Game.Rulesets.Catch/Objects/Banana.cs @@ -9,6 +9,7 @@ using osu.Game.Audio; using osu.Game.Rulesets.Catch.Judgements; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Skinning; using osu.Game.Utils; using osuTK.Graphics; @@ -31,7 +32,7 @@ namespace osu.Game.Rulesets.Catch.Objects } // override any external colour changes with banananana - Color4 IHasComboInformation.GetComboColour(IReadOnlyList comboColours) => getBananaColour(); + Color4 IHasComboInformation.GetComboColour(ISkin skin) => getBananaColour(); private Color4 getBananaColour() { diff --git a/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs index aa7cabf38b..4001a4ea76 100644 --- a/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/PalpableCatchHitObject.cs @@ -1,10 +1,10 @@ // 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 Newtonsoft.Json; using osu.Framework.Bindables; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Skinning; using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.Objects @@ -45,6 +45,6 @@ namespace osu.Game.Rulesets.Catch.Objects } } - Color4 IHasComboInformation.GetComboColour(IReadOnlyList comboColours) => comboColours[(IndexInBeatmap + 1) % comboColours.Count]; + Color4 IHasComboInformation.GetComboColour(ISkin skin) => IHasComboInformation.GetSkinComboColour(this, skin, IndexInBeatmap + 1); } } diff --git a/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs b/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs index a81703119a..2fc05701db 100644 --- a/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs +++ b/osu.Game.Rulesets.Catch/Replays/CatchAutoGenerator.cs @@ -51,7 +51,7 @@ namespace osu.Game.Rulesets.Catch.Replays bool impossibleJump = speedRequired > movement_speed * 2; // todo: get correct catcher size, based on difficulty CS. - const float catcher_width_half = CatcherArea.CATCHER_SIZE * 0.3f * 0.5f; + const float catcher_width_half = Catcher.BASE_SIZE * 0.3f * 0.5f; if (lastPosition - catcher_width_half < h.EffectiveX && lastPosition + catcher_width_half > h.EffectiveX) { diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs index 05cd29dff5..b43815a8bd 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Objects.Drawables; @@ -26,38 +27,53 @@ namespace osu.Game.Rulesets.Catch.UI /// public const float CENTER_X = WIDTH / 2; - [Cached] - private readonly DroppedObjectContainer droppedObjectContainer; - - internal readonly CatcherArea CatcherArea; - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => // only check the X position; handle all vertical space. base.ReceivePositionalInputAt(new Vector2(screenSpacePos.X, ScreenSpaceDrawQuad.Centre.Y)); + internal Catcher Catcher { get; private set; } + + internal CatcherArea CatcherArea { get; private set; } + + private readonly BeatmapDifficulty difficulty; + public CatchPlayfield(BeatmapDifficulty difficulty) { - CatcherArea = new CatcherArea(difficulty) - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.TopLeft, - }; - - InternalChildren = new[] - { - droppedObjectContainer = new DroppedObjectContainer(), - CatcherArea.MovableCatcher.CreateProxiedContent(), - 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, - }; + this.difficulty = difficulty; } [BackgroundDependencyLoader] private void load() { + var trailContainer = new Container + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.TopLeft + }; + var droppedObjectContainer = new DroppedObjectContainer(); + + Catcher = new Catcher(trailContainer, droppedObjectContainer, difficulty) + { + X = CENTER_X + }; + + AddRangeInternal(new[] + { + droppedObjectContainer, + Catcher.CreateProxiedContent(), + 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 = new CatcherArea + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.TopLeft, + Catcher = Catcher, + }, + trailContainer, + HitObjectContainer, + }); + RegisterPool(50); RegisterPool(50); RegisterPool(100); @@ -80,7 +96,7 @@ namespace osu.Game.Rulesets.Catch.UI ((DrawableCatchHitObject)d).CheckPosition = checkIfWeCanCatch; } - private bool checkIfWeCanCatch(CatchHitObject obj) => CatcherArea.MovableCatcher.CanCatch(obj); + private bool checkIfWeCanCatch(CatchHitObject obj) => Catcher.CanCatch(obj); private void onNewResult(DrawableHitObject judgedObject, JudgementResult result) => CatcherArea.OnNewResult((DrawableCatchHitObject)judgedObject, result); diff --git a/osu.Game.Rulesets.Catch/UI/CatchReplayRecorder.cs b/osu.Game.Rulesets.Catch/UI/CatchReplayRecorder.cs index 1ddb5ac630..a7879846df 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchReplayRecorder.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchReplayRecorder.cs @@ -21,6 +21,6 @@ namespace osu.Game.Rulesets.Catch.UI } protected override ReplayFrame HandleFrame(Vector2 mousePosition, List actions, ReplayFrame previousFrame) - => new CatchReplayFrame(Time.Current, playfield.CatcherArea.MovableCatcher.X, actions.Contains(CatchAction.Dash), previousFrame as CatchReplayFrame); + => new CatchReplayFrame(Time.Current, playfield.Catcher.X, actions.Contains(CatchAction.Dash), previousFrame as CatchReplayFrame); } } diff --git a/osu.Game.Rulesets.Catch/UI/Catcher.cs b/osu.Game.Rulesets.Catch/UI/Catcher.cs index 57523d3505..49508b1caf 100644 --- a/osu.Game.Rulesets.Catch/UI/Catcher.cs +++ b/osu.Game.Rulesets.Catch/UI/Catcher.cs @@ -25,6 +25,16 @@ namespace osu.Game.Rulesets.Catch.UI { public class Catcher : SkinReloadableDrawable { + /// + /// The size of the catcher at 1x scale. + /// + public const float BASE_SIZE = 106.75f; + + /// + /// The width of the catcher which can receive fruit. Equivalent to "catchMargin" in osu-stable. + /// + public const float ALLOWED_CATCH_RANGE = 0.8f; + /// /// The default colour used to tint hyper-dash fruit, along with the moving catcher, its trail /// and end glow/after-image during a hyper-dash. @@ -74,8 +84,7 @@ namespace osu.Game.Rulesets.Catch.UI /// /// Contains objects dropped from the plate. /// - [Resolved] - private DroppedObjectContainer droppedObjectTarget { get; set; } + private readonly DroppedObjectContainer droppedObjectTarget; public CatcherAnimationState CurrentState { @@ -83,11 +92,6 @@ namespace osu.Game.Rulesets.Catch.UI private set => Body.AnimationState.Value = value; } - /// - /// The width of the catcher which can receive fruit. Equivalent to "catchMargin" in osu-stable. - /// - public const float ALLOWED_CATCH_RANGE = 0.8f; - private bool dashing; public bool Dashing @@ -134,13 +138,14 @@ namespace osu.Game.Rulesets.Catch.UI private readonly DrawablePool caughtBananaPool; private readonly DrawablePool caughtDropletPool; - public Catcher([NotNull] Container trailsTarget, BeatmapDifficulty difficulty = null) + public Catcher([NotNull] Container trailsTarget, [NotNull] DroppedObjectContainer droppedObjectTarget, BeatmapDifficulty difficulty = null) { this.trailsTarget = trailsTarget; + this.droppedObjectTarget = droppedObjectTarget; Origin = Anchor.TopCentre; - Size = new Vector2(CatcherArea.CATCHER_SIZE); + Size = new Vector2(BASE_SIZE); if (difficulty != null) Scale = calculateScale(difficulty); @@ -197,7 +202,7 @@ namespace osu.Game.Rulesets.Catch.UI /// Calculates the width of the area used for attempting catches in gameplay. /// /// The scale of the catcher. - public static float CalculateCatchWidth(Vector2 scale) => CatcherArea.CATCHER_SIZE * Math.Abs(scale.X) * ALLOWED_CATCH_RANGE; + public static float CalculateCatchWidth(Vector2 scale) => BASE_SIZE * Math.Abs(scale.X) * ALLOWED_CATCH_RANGE; /// /// Calculates the width of the area used for attempting catches in gameplay. diff --git a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs index fea314df8d..de0ace9817 100644 --- a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs +++ b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs @@ -5,7 +5,6 @@ using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; -using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Judgements; using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.Replays; @@ -16,13 +15,29 @@ using osuTK; namespace osu.Game.Rulesets.Catch.UI { + /// + /// The horizontal band at the bottom of the playfield the catcher is moving on. + /// It holds a as a child and translates input to the catcher movement. + /// It also holds a combo display that is above the catcher, and judgment results are translated to the catcher and the combo display. + /// public class CatcherArea : Container, IKeyBindingHandler { - public const float CATCHER_SIZE = 106.75f; + public Catcher Catcher + { + get => catcher; + set + { + if (catcher != null) + Remove(catcher); + + Add(catcher = value); + } + } - public readonly Catcher MovableCatcher; private readonly CatchComboDisplay comboDisplay; + private Catcher catcher; + /// /// -1 when only left button is pressed. /// 1 when only right button is pressed. @@ -30,27 +45,26 @@ namespace osu.Game.Rulesets.Catch.UI /// private int currentDirection; - public CatcherArea(BeatmapDifficulty difficulty = null) + /// + /// must be set before loading. + /// + public CatcherArea() { - Size = new Vector2(CatchPlayfield.WIDTH, CATCHER_SIZE); - Children = new Drawable[] + Size = new Vector2(CatchPlayfield.WIDTH, Catcher.BASE_SIZE); + Child = comboDisplay = new CatchComboDisplay { - comboDisplay = new CatchComboDisplay - { - RelativeSizeAxes = Axes.None, - AutoSizeAxes = Axes.Both, - Anchor = Anchor.TopLeft, - Origin = Anchor.Centre, - Margin = new MarginPadding { Bottom = 350f }, - X = CatchPlayfield.CENTER_X - }, - MovableCatcher = new Catcher(this, difficulty) { X = CatchPlayfield.CENTER_X }, + RelativeSizeAxes = Axes.None, + AutoSizeAxes = Axes.Both, + Anchor = Anchor.TopLeft, + Origin = Anchor.Centre, + Margin = new MarginPadding { Bottom = 350f }, + X = CatchPlayfield.CENTER_X }; } public void OnNewResult(DrawableCatchHitObject hitObject, JudgementResult result) { - MovableCatcher.OnNewResult(hitObject, result); + Catcher.OnNewResult(hitObject, result); if (!result.Type.IsScorable()) return; @@ -58,9 +72,9 @@ namespace osu.Game.Rulesets.Catch.UI if (hitObject.HitObject.LastInCombo) { if (result.Judgement is CatchJudgement catchJudgement && catchJudgement.ShouldExplodeFor(result)) - MovableCatcher.Explode(); + Catcher.Explode(); else - MovableCatcher.Drop(); + Catcher.Drop(); } comboDisplay.OnNewResult(hitObject, result); @@ -69,7 +83,7 @@ namespace osu.Game.Rulesets.Catch.UI public void OnRevertResult(DrawableCatchHitObject hitObject, JudgementResult result) { comboDisplay.OnRevertResult(hitObject, result); - MovableCatcher.OnRevertResult(hitObject, result); + Catcher.OnRevertResult(hitObject, result); } protected override void Update() @@ -80,27 +94,27 @@ namespace osu.Game.Rulesets.Catch.UI SetCatcherPosition( replayState?.CatcherX ?? - (float)(MovableCatcher.X + MovableCatcher.Speed * currentDirection * Clock.ElapsedFrameTime)); + (float)(Catcher.X + Catcher.Speed * currentDirection * Clock.ElapsedFrameTime)); } protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); - comboDisplay.X = MovableCatcher.X; + comboDisplay.X = Catcher.X; } public void SetCatcherPosition(float X) { - float lastPosition = MovableCatcher.X; + float lastPosition = Catcher.X; float newPosition = Math.Clamp(X, 0, CatchPlayfield.WIDTH); - MovableCatcher.X = newPosition; + Catcher.X = newPosition; if (lastPosition < newPosition) - MovableCatcher.VisualDirection = Direction.Right; + Catcher.VisualDirection = Direction.Right; else if (lastPosition > newPosition) - MovableCatcher.VisualDirection = Direction.Left; + Catcher.VisualDirection = Direction.Left; } public bool OnPressed(CatchAction action) @@ -116,7 +130,7 @@ namespace osu.Game.Rulesets.Catch.UI return true; case CatchAction.Dash: - MovableCatcher.Dashing = true; + Catcher.Dashing = true; return true; } @@ -136,7 +150,7 @@ namespace osu.Game.Rulesets.Catch.UI break; case CatchAction.Dash: - MovableCatcher.Dashing = false; + Catcher.Dashing = false; break; } } diff --git a/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs index c961d98dc5..ff1a7d7a61 100644 --- a/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs +++ b/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs @@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Catch.UI public CatcherTrail() { - Size = new Vector2(CatcherArea.CATCHER_SIZE); + Size = new Vector2(Catcher.BASE_SIZE); Origin = Anchor.TopCentre; Blending = BlendingParameters.Additive; InternalChild = body = new SkinnableCatcher diff --git a/osu.Game.Rulesets.Catch/UI/SkinnableCatcher.cs b/osu.Game.Rulesets.Catch/UI/SkinnableCatcher.cs index fc34ba4c8b..8d707a4beb 100644 --- a/osu.Game.Rulesets.Catch/UI/SkinnableCatcher.cs +++ b/osu.Game.Rulesets.Catch/UI/SkinnableCatcher.cs @@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Catch.UI { Anchor = Anchor.TopCentre; // Sets the origin roughly to the centre of the catcher's plate to allow for correct scaling. - OriginPosition = new Vector2(0.5f, 0.06f) * CatcherArea.CATCHER_SIZE; + OriginPosition = new Vector2(0.5f, 0.06f) * Catcher.BASE_SIZE; } } } diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs index afd94f4570..8d8387378e 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs @@ -15,13 +15,13 @@ namespace osu.Game.Rulesets.Osu.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Osu"; - [TestCase(6.9311451172574934d, "diffcalc-test")] - [TestCase(1.0736586907780401d, "zero-length-sliders")] + [TestCase(6.7568168283591499d, "diffcalc-test")] + [TestCase(1.0348244046058293d, "zero-length-sliders")] public void Test(double expected, string name) => base.Test(expected, name); - [TestCase(8.7212283220412345d, "diffcalc-test")] - [TestCase(1.3212137158641493d, "zero-length-sliders")] + [TestCase(8.4783236764532557d, "diffcalc-test")] + [TestCase(1.2708532136987165d, "zero-length-sliders")] public void TestClockRateAdjusted(double expected, string name) => Test(expected, name, new OsuModDoubleTime()); diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index a76db4abe3..e6ab978dfb 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -103,22 +103,26 @@ namespace osu.Game.Rulesets.Osu.Difficulty double approachRateTotalHitsFactor = 1.0 / (1.0 + Math.Exp(-(0.007 * (totalHits - 400)))); - aimValue *= 1.0 + (0.03 + 0.37 * approachRateTotalHitsFactor) * approachRateFactor; + double approachRateBonus = 1.0 + (0.03 + 0.37 * approachRateTotalHitsFactor) * approachRateFactor; // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. if (mods.Any(h => h is OsuModHidden)) aimValue *= 1.0 + 0.04 * (12.0 - Attributes.ApproachRate); + double flashlightBonus = 1.0; + if (mods.Any(h => h is OsuModFlashlight)) { // Apply object-based bonus for flashlight. - aimValue *= 1.0 + 0.35 * Math.Min(1.0, totalHits / 200.0) + - (totalHits > 200 - ? 0.3 * Math.Min(1.0, (totalHits - 200) / 300.0) + - (totalHits > 500 ? (totalHits - 500) / 1200.0 : 0.0) - : 0.0); + flashlightBonus = 1.0 + 0.35 * Math.Min(1.0, totalHits / 200.0) + + (totalHits > 200 + ? 0.3 * Math.Min(1.0, (totalHits - 200) / 300.0) + + (totalHits > 500 ? (totalHits - 500) / 1200.0 : 0.0) + : 0.0); } + aimValue *= Math.Max(flashlightBonus, approachRateBonus); + // Scale the aim value with accuracy _slightly_ aimValue *= 0.5 + accuracy / 2.0; // It is important to also consider accuracy difficulty when doing that diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index cb819ec090..16a18cbcb9 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -3,7 +3,6 @@ using System; using osu.Game.Rulesets.Difficulty.Preprocessing; -using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; @@ -13,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// /// Represents the skill required to correctly aim at every object in the map with a uniform CircleSize and normalized distances. /// - public class Aim : StrainSkill + public class Aim : OsuStrainSkill { private const double angle_bonus_begin = Math.PI / 3; private const double timing_threshold = 107; @@ -47,7 +46,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills Math.Max(osuPrevious.JumpDistance - scale, 0) * Math.Pow(Math.Sin(osuCurrent.Angle.Value - angle_bonus_begin), 2) * Math.Max(osuCurrent.JumpDistance - scale, 0)); - result = 1.5 * applyDiminishingExp(Math.Max(0, angleBonus)) / Math.Max(timing_threshold, osuPrevious.StrainTime); + result = 1.4 * applyDiminishingExp(Math.Max(0, angleBonus)) / Math.Max(timing_threshold, osuPrevious.StrainTime); } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs new file mode 100644 index 0000000000..e47edc37cc --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/OsuStrainSkill.cs @@ -0,0 +1,61 @@ +// 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.Difficulty.Skills; +using osu.Game.Rulesets.Mods; +using System.Linq; +using osu.Framework.Utils; + +namespace osu.Game.Rulesets.Osu.Difficulty.Skills +{ + public abstract class OsuStrainSkill : StrainSkill + { + /// + /// The number of sections with the highest strains, which the peak strain reductions will apply to. + /// This is done in order to decrease their impact on the overall difficulty of the map for this skill. + /// + protected virtual int ReducedSectionCount => 10; + + /// + /// The baseline multiplier applied to the section with the biggest strain. + /// + protected virtual double ReducedStrainBaseline => 0.75; + + /// + /// The final multiplier to be applied to after all other calculations. + /// + protected virtual double DifficultyMultiplier => 1.06; + + protected OsuStrainSkill(Mod[] mods) + : base(mods) + { + } + + public override double DifficultyValue() + { + double difficulty = 0; + double weight = 1; + + List strains = GetCurrentStrainPeaks().OrderByDescending(d => d).ToList(); + + // We are reducing the highest strains first to account for extreme difficulty spikes + for (int i = 0; i < Math.Min(strains.Count, ReducedSectionCount); i++) + { + double scale = Math.Log10(Interpolation.Lerp(1, 10, Math.Clamp((float)i / ReducedSectionCount, 0, 1))); + strains[i] *= Interpolation.Lerp(ReducedStrainBaseline, 1.0, scale); + } + + // Difficulty is the weighted sum of the highest strains from every section. + // We're sorting from highest to lowest strain. + foreach (double strain in strains.OrderByDescending(d => d)) + { + difficulty += strain * weight; + weight *= DecayWeight; + } + + return difficulty * DifficultyMultiplier; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index fbac080fc6..f0eb199e5f 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -3,7 +3,6 @@ using System; using osu.Game.Rulesets.Difficulty.Preprocessing; -using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Objects; @@ -13,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills /// /// Represents the skill required to press keys with regards to keeping up with the speed at which objects need to be hit. /// - public class Speed : StrainSkill + public class Speed : OsuStrainSkill { private const double single_spacing_threshold = 125; @@ -23,6 +22,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills protected override double SkillMultiplier => 1400; protected override double StrainDecayBase => 0.3; + protected override int ReducedSectionCount => 5; + protected override double DifficultyMultiplier => 1.04; private const double min_speed_bonus = 75; // ~200BPM private const double max_speed_bonus = 45; // ~330BPM 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 c36768baba..5bbdf9688f 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs @@ -129,9 +129,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components public bool OnPressed(PlatformAction action) { - switch (action.ActionMethod) + switch (action) { - case PlatformActionMethod.Delete: + case PlatformAction.Delete: return DeleteSelected(); } diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs index 57d0cd859d..358a44e0e6 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs @@ -35,8 +35,8 @@ namespace osu.Game.Rulesets.Osu.Edit Quad quad = selectedMovableObjects.Length > 0 ? getSurroundingQuad(selectedMovableObjects) : new Quad(); SelectionBox.CanRotate = quad.Width > 0 || quad.Height > 0; - SelectionBox.CanScaleX = quad.Width > 0; - SelectionBox.CanScaleY = quad.Height > 0; + SelectionBox.CanFlipX = SelectionBox.CanScaleX = quad.Width > 0; + SelectionBox.CanFlipY = SelectionBox.CanScaleY = quad.Height > 0; SelectionBox.CanReverse = EditorBeatmap.SelectedHitObjects.Count > 1 || EditorBeatmap.SelectedHitObjects.Any(s => s is Slider); } @@ -76,32 +76,8 @@ namespace osu.Game.Rulesets.Osu.Edit if (h is Slider slider) { - var points = slider.Path.ControlPoints.ToArray(); - Vector2 endPos = points.Last().Position.Value; - - slider.Path.ControlPoints.Clear(); - - slider.Position += endPos; - - PathType? lastType = null; - - for (var i = 0; i < points.Length; i++) - { - var p = points[i]; - p.Position.Value -= endPos; - - // propagate types forwards to last null type - if (i == points.Length - 1) - p.Type.Value = lastType; - else if (p.Type.Value != null) - { - var newType = p.Type.Value; - p.Type.Value = lastType; - lastType = newType; - } - - slider.Path.ControlPoints.Insert(0, p); - } + slider.Path.Reverse(out Vector2 offset); + slider.Position += offset; } } diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs index 76e5437305..7ff1259307 100644 --- a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs +++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs @@ -129,14 +129,8 @@ namespace osu.Game.Tests.Gameplay { switch (lookup) { - case GlobalSkinColours global: - switch (global) - { - case GlobalSkinColours.ComboColours: - return SkinUtils.As(new Bindable>(ComboColours)); - } - - break; + case SkinComboColourLookup comboColour: + return SkinUtils.As(new Bindable(ComboColours[comboColour.ColourIndex % ComboColours.Count])); } throw new NotImplementedException(); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs b/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs index d5cfeb1878..87dbb90138 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneComposeSelectBox.cs @@ -35,6 +35,8 @@ namespace osu.Game.Tests.Visual.Editing CanRotate = true, CanScaleX = true, CanScaleY = true, + CanFlipX = true, + CanFlipY = true, OnRotation = handleRotation, OnScale = handleScale diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index 92f9c5733f..36dd9c2de3 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -104,6 +104,36 @@ namespace osu.Game.Tests.Visual.Multiplayer }); } + [Test] + public void TestExitMidJoin() + { + Room room = null; + + AddStep("create room", () => + { + room = new Room + { + Name = { Value = "Test Room" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }; + }); + + AddStep("refresh rooms", () => multiplayerScreen.RoomManager.Filter.Value = new FilterCriteria()); + AddStep("select room", () => InputManager.Key(Key.Down)); + AddStep("join room and immediately exit", () => + { + multiplayerScreen.ChildrenOfType().Single().Open(room); + Schedule(() => Stack.CurrentScreen.Exit()); + }); + } + [Test] public void TestJoinRoomWithoutPassword() { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs index de46d9e25a..4ea635fd3e 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs @@ -80,6 +80,22 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("room join password correct", () => lastJoinedPassword == "password"); } + [Test] + public void TestJoinRoomWithPasswordViaKeyboardOnly() + { + DrawableRoom.PasswordEntryPopover passwordEntryPopover = null; + + AddStep("add room", () => RoomManager.AddRooms(1, withPassword: true)); + AddStep("select room", () => InputManager.Key(Key.Down)); + AddStep("attempt join room", () => InputManager.Key(Key.Enter)); + AddUntilStep("password prompt appeared", () => (passwordEntryPopover = InputManager.ChildrenOfType().FirstOrDefault()) != null); + AddStep("enter password in text box", () => passwordEntryPopover.ChildrenOfType().First().Text = "password"); + AddStep("press enter", () => InputManager.Key(Key.Enter)); + + AddAssert("room join requested", () => lastJoinedRoom == RoomManager.Rooms.First()); + AddAssert("room join password correct", () => lastJoinedPassword == "password"); + } + private void onRoomJoined(Room room, string password) { lastJoinedRoom = room; diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs index a1549dfbce..5e234bdacf 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs @@ -330,15 +330,15 @@ namespace osu.Game.Tests.Visual.Online InputManager.ReleaseKey(Key.AltLeft); } - private void pressCloseDocumentKeys() => pressKeysFor(PlatformActionType.DocumentClose); + private void pressCloseDocumentKeys() => pressKeysFor(PlatformAction.DocumentClose); - private void pressNewTabKeys() => pressKeysFor(PlatformActionType.TabNew); + private void pressNewTabKeys() => pressKeysFor(PlatformAction.TabNew); - private void pressRestoreTabKeys() => pressKeysFor(PlatformActionType.TabRestore); + private void pressRestoreTabKeys() => pressKeysFor(PlatformAction.TabRestore); - private void pressKeysFor(PlatformActionType type) + private void pressKeysFor(PlatformAction type) { - var binding = host.PlatformKeyBindings.First(b => ((PlatformAction)b.Action).ActionType == type); + var binding = host.PlatformKeyBindings.First(b => (PlatformAction)b.Action == type); foreach (var k in binding.KeyCombination.Keys) InputManager.PressKey((Key)k); diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs index 76cfe75b59..acacdf8644 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs @@ -29,6 +29,12 @@ namespace osu.Game.Tests.Visual.Ranking AddStep("show example score", () => showPanel(CreateWorkingBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)), new TestScoreInfo(new OsuRuleset().RulesetInfo))); } + [Test] + public void TestExcessMods() + { + AddStep("show excess mods score", () => showPanel(CreateWorkingBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)), new TestScoreInfo(new OsuRuleset().RulesetInfo, true))); + } + private void showPanel(WorkingBeatmap workingBeatmap, ScoreInfo score) { Child = new ContractedPanelMiddleContentContainer(workingBeatmap, score); diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs index 591095252f..5180854aba 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs @@ -12,6 +12,7 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Scoring; using osu.Game.Screens.Ranking; @@ -36,6 +37,17 @@ namespace osu.Game.Tests.Visual.Ranking { Beatmap = createTestBeatmap(author) })); + } + + [Test] + public void TestExcessMods() + { + var author = new User { Username = "mapper_name" }; + + AddStep("show excess mods score", () => showPanel(new TestScoreInfo(new OsuRuleset().RulesetInfo, true) + { + Beatmap = createTestBeatmap(author) + })); AddAssert("mapper name present", () => this.ChildrenOfType().Any(spriteText => spriteText.Current.Value == "mapper_name")); } @@ -50,9 +62,33 @@ namespace osu.Game.Tests.Visual.Ranking AddAssert("mapped by text not present", () => this.ChildrenOfType().All(spriteText => !containsAny(spriteText.Text.ToString(), "mapped", "by"))); + + AddAssert("play time displayed", () => this.ChildrenOfType().Any()); } - private void showPanel(ScoreInfo score) => Child = new ExpandedPanelMiddleContentContainer(score); + [Test] + public void TestWithDefaultDate() + { + AddStep("show autoplay score", () => + { + var ruleset = new OsuRuleset(); + + var mods = new Mod[] { ruleset.GetAutoplayMod() }; + var beatmap = createTestBeatmap(null); + + showPanel(new TestScoreInfo(ruleset.RulesetInfo) + { + Mods = mods, + Beatmap = beatmap, + Date = default, + }); + }); + + AddAssert("play time not displayed", () => !this.ChildrenOfType().Any()); + } + + private void showPanel(ScoreInfo score) => + Child = new ExpandedPanelMiddleContentContainer(score); private BeatmapInfo createTestBeatmap(User author) { diff --git a/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs b/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs index acf9deb3cb..54293485cb 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneKeyBindingPanel.cs @@ -8,7 +8,7 @@ using osu.Framework.Testing; using osu.Framework.Threading; using osu.Game.Graphics.Sprites; using osu.Game.Overlays; -using osu.Game.Overlays.KeyBinding; +using osu.Game.Overlays.Settings.Sections.Input; using osuTK.Input; namespace osu.Game.Tests.Visual.Settings diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModDisplay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModDisplay.cs index 8168faa106..b8f5ee5e86 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModDisplay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModDisplay.cs @@ -11,10 +11,8 @@ namespace osu.Game.Tests.Visual.UserInterface { public class TestSceneModDisplay : OsuTestScene { - [TestCase(ExpansionMode.ExpandOnHover)] - [TestCase(ExpansionMode.AlwaysExpanded)] - [TestCase(ExpansionMode.AlwaysContracted)] - public void TestMode(ExpansionMode mode) + [Test] + public void TestMode([Values] ExpansionMode mode) { AddStep("create mod display", () => { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModFlowDisplay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModFlowDisplay.cs new file mode 100644 index 0000000000..8f057c663b --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModFlowDisplay.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Play.HUD; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneModFlowDisplay : OsuTestScene + { + private ModFlowDisplay modFlow; + + [SetUp] + public void SetUp() => Schedule(() => + { + Child = modFlow = new ModFlowDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.None, + Width = 200, + Current = + { + Value = new OsuRuleset().GetAllMods().ToArray(), + } + }; + }); + + [Test] + public void TestWrapping() + { + AddSliderStep("icon size", 0.1f, 2, 1, val => + { + if (modFlow != null) + modFlow.IconScale = val; + }); + + AddSliderStep("flow width", 100, 500, 200, val => + { + if (modFlow != null) + modFlow.Width = val; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index 3485d7fbc3..1e76c33fca 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs @@ -416,7 +416,6 @@ namespace osu.Game.Tests.Visual.UserInterface { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, - AutoSizeAxes = Axes.Both, Position = new Vector2(-5, 25), Current = { BindTarget = modSelect.SelectedMods } } diff --git a/osu.Game/Beatmaps/BeatmapSetOnlineStatus.cs b/osu.Game/Beatmaps/BeatmapSetOnlineStatus.cs index ae5a44cfcd..6003e23a84 100644 --- a/osu.Game/Beatmaps/BeatmapSetOnlineStatus.cs +++ b/osu.Game/Beatmaps/BeatmapSetOnlineStatus.cs @@ -1,8 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; + namespace osu.Game.Beatmaps { + [LocalisableEnum(typeof(BeatmapSetOnlineStatusEnumLocalisationMapper))] public enum BeatmapSetOnlineStatus { None = -3, @@ -20,4 +25,40 @@ namespace osu.Game.Beatmaps public static bool GrantsPerformancePoints(this BeatmapSetOnlineStatus status) => status == BeatmapSetOnlineStatus.Ranked || status == BeatmapSetOnlineStatus.Approved; } + + public class BeatmapSetOnlineStatusEnumLocalisationMapper : EnumLocalisationMapper + { + public override LocalisableString Map(BeatmapSetOnlineStatus value) + { + switch (value) + { + case BeatmapSetOnlineStatus.None: + return string.Empty; + + case BeatmapSetOnlineStatus.Graveyard: + return BeatmapsetsStrings.ShowStatusGraveyard; + + case BeatmapSetOnlineStatus.WIP: + return BeatmapsetsStrings.ShowStatusWip; + + case BeatmapSetOnlineStatus.Pending: + return BeatmapsetsStrings.ShowStatusPending; + + case BeatmapSetOnlineStatus.Ranked: + return BeatmapsetsStrings.ShowStatusRanked; + + case BeatmapSetOnlineStatus.Approved: + return BeatmapsetsStrings.ShowStatusApproved; + + case BeatmapSetOnlineStatus.Qualified: + return BeatmapsetsStrings.ShowStatusQualified; + + case BeatmapSetOnlineStatus.Loved: + return BeatmapsetsStrings.ShowStatusLoved; + + default: + throw new ArgumentOutOfRangeException(nameof(value), value, null); + } + } + } } diff --git a/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs b/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs index f6e03d40ff..ffc010b3a3 100644 --- a/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs +++ b/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs @@ -1,6 +1,8 @@ // 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.Extensions; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -28,7 +30,7 @@ namespace osu.Game.Beatmaps.Drawables status = value; Alpha = value == BeatmapSetOnlineStatus.None ? 0 : 1; - statusText.Text = value.ToString().ToUpperInvariant(); + statusText.Text = value.GetLocalisableDescription().ToUpper(); } } diff --git a/osu.Game/Graphics/UserInterface/HoverSampleSet.cs b/osu.Game/Graphics/UserInterface/HoverSampleSet.cs index b88f81a143..b4afb4831f 100644 --- a/osu.Game/Graphics/UserInterface/HoverSampleSet.cs +++ b/osu.Game/Graphics/UserInterface/HoverSampleSet.cs @@ -10,6 +10,9 @@ namespace osu.Game.Graphics.UserInterface [Description("default")] Default, + [Description("soft")] + Soft, + [Description("button")] Button, diff --git a/osu.Game/Graphics/UserInterface/SearchTextBox.cs b/osu.Game/Graphics/UserInterface/SearchTextBox.cs index fe92054d25..4a91741ce6 100644 --- a/osu.Game/Graphics/UserInterface/SearchTextBox.cs +++ b/osu.Game/Graphics/UserInterface/SearchTextBox.cs @@ -32,20 +32,15 @@ namespace osu.Game.Graphics.UserInterface public override bool OnPressed(PlatformAction action) { - switch (action.ActionType) + switch (action) { - case PlatformActionType.LineEnd: - case PlatformActionType.LineStart: - return false; - + case PlatformAction.MoveBackwardLine: + case PlatformAction.MoveForwardLine: // Shift+delete is handled via PlatformAction on macOS. this is not so useful in the context of a SearchTextBox // as we do not allow arrow key navigation in the first place (ie. the caret should always be at the end of text) // Avoid handling it here to allow other components to potentially consume the shortcut. - case PlatformActionType.CharNext: - if (action.ActionMethod == PlatformActionMethod.Delete) - return false; - - break; + case PlatformAction.DeleteForwardChar: + return false; } return base.OnPressed(action); diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.cs b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.cs index 8a420cdcfb..794c728e56 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; namespace osu.Game.Graphics.UserInterfaceV2 { @@ -25,9 +26,13 @@ namespace osu.Game.Graphics.UserInterfaceV2 Flow.AutoSizeAxes = Axes.X; Flow.Height = OsuDirectorySelector.ITEM_HEIGHT; - AddInternal(new Background + AddRangeInternal(new Drawable[] { - Depth = 1 + new Background + { + Depth = 1 + }, + new HoverClickSounds(HoverSampleSet.Soft) }); } diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs b/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs index b9fb642cbe..e4c78e723d 100644 --- a/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs +++ b/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; namespace osu.Game.Graphics.UserInterfaceV2 { @@ -50,9 +51,13 @@ namespace osu.Game.Graphics.UserInterfaceV2 Flow.AutoSizeAxes = Axes.X; Flow.Height = OsuDirectorySelector.ITEM_HEIGHT; - AddInternal(new OsuDirectorySelectorDirectory.Background + AddRangeInternal(new Drawable[] { - Depth = 1 + new OsuDirectorySelectorDirectory.Background + { + Depth = 1 + }, + new HoverClickSounds(HoverSampleSet.Soft) }); } diff --git a/osu.Game/Online/API/APIDownloadRequest.cs b/osu.Game/Online/API/APIDownloadRequest.cs index 62e22d8f88..63bb3e2287 100644 --- a/osu.Game/Online/API/APIDownloadRequest.cs +++ b/osu.Game/Online/API/APIDownloadRequest.cs @@ -16,6 +16,11 @@ namespace osu.Game.Online.API /// protected virtual string FileExtension { get; } = @".tmp"; + protected APIDownloadRequest() + { + base.Success += () => Success?.Invoke(filename); + } + protected override WebRequest CreateWebRequest() { var file = Path.GetTempFileName(); @@ -39,12 +44,6 @@ namespace osu.Game.Online.API TriggerSuccess(); } - internal override void TriggerSuccess() - { - base.TriggerSuccess(); - Success?.Invoke(filename); - } - public event APIProgressHandler Progressed; public new event APISuccessHandler Success; diff --git a/osu.Game/Online/API/APIException.cs b/osu.Game/Online/API/APIException.cs new file mode 100644 index 0000000000..97786bced9 --- /dev/null +++ b/osu.Game/Online/API/APIException.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +namespace osu.Game.Online.API +{ + public class APIException : InvalidOperationException + { + public APIException(string messsage, Exception innerException) + : base(messsage, innerException) + { + } + } +} diff --git a/osu.Game/Online/API/APIRequest.cs b/osu.Game/Online/API/APIRequest.cs index 1a6868cfa4..e6bfca166e 100644 --- a/osu.Game/Online/API/APIRequest.cs +++ b/osu.Game/Online/API/APIRequest.cs @@ -25,6 +25,11 @@ namespace osu.Game.Online.API /// public new event APISuccessHandler Success; + protected APIRequest() + { + base.Success += () => Success?.Invoke(Result); + } + protected override void PostProcess() { base.PostProcess(); @@ -40,12 +45,6 @@ namespace osu.Game.Online.API TriggerSuccess(); } - - internal override void TriggerSuccess() - { - base.TriggerSuccess(); - Success?.Invoke(Result); - } } /// @@ -79,7 +78,13 @@ namespace osu.Game.Online.API /// public event APIFailureHandler Failure; - private bool cancelled; + private readonly object completionStateLock = new object(); + + /// + /// The state of this request, from an outside perspective. + /// This is used to ensure correct notification events are fired. + /// + private APIRequestCompletionState completionState; private Action pendingFailure; @@ -116,12 +121,7 @@ namespace osu.Game.Online.API PostProcess(); - API.Schedule(delegate - { - if (cancelled) return; - - TriggerSuccess(); - }); + API.Schedule(TriggerSuccess); } /// @@ -131,16 +131,29 @@ namespace osu.Game.Online.API { } - private bool succeeded; - - internal virtual void TriggerSuccess() + internal void TriggerSuccess() { - succeeded = true; + lock (completionStateLock) + { + if (completionState != APIRequestCompletionState.Waiting) + return; + + completionState = APIRequestCompletionState.Completed; + } + Success?.Invoke(); } internal void TriggerFailure(Exception e) { + lock (completionStateLock) + { + if (completionState != APIRequestCompletionState.Waiting) + return; + + completionState = APIRequestCompletionState.Failed; + } + Failure?.Invoke(e); } @@ -148,10 +161,14 @@ namespace osu.Game.Online.API public void Fail(Exception e) { - if (succeeded || cancelled) - return; + lock (completionStateLock) + { + // while it doesn't matter if code following this check is run more than once, + // this avoids unnecessarily performing work where we are already sure the user has been informed. + if (completionState != APIRequestCompletionState.Waiting) + return; + } - cancelled = true; WebRequest?.Abort(); string responseString = WebRequest?.GetResponseString(); @@ -181,7 +198,11 @@ namespace osu.Game.Online.API /// Whether we are in a failed or cancelled state. private bool checkAndScheduleFailure() { - if (pendingFailure == null) return cancelled; + lock (completionStateLock) + { + if (pendingFailure == null) + return completionState == APIRequestCompletionState.Failed; + } if (API == null) pendingFailure(); @@ -199,14 +220,6 @@ namespace osu.Game.Online.API } } - public class APIException : InvalidOperationException - { - public APIException(string messsage, Exception innerException) - : base(messsage, innerException) - { - } - } - public delegate void APIFailureHandler(Exception e); public delegate void APISuccessHandler(); diff --git a/osu.Game/Online/API/APIRequestCompletionState.cs b/osu.Game/Online/API/APIRequestCompletionState.cs new file mode 100644 index 0000000000..84c9974dd8 --- /dev/null +++ b/osu.Game/Online/API/APIRequestCompletionState.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.Online.API +{ + public enum APIRequestCompletionState + { + /// + /// Not yet run or currently waiting on response. + /// + Waiting, + + /// + /// Ran to completion. + /// + Completed, + + /// + /// Cancelled or failed due to error. + /// + Failed + } +} diff --git a/osu.Game/Online/API/Requests/GetUserBeatmapsRequest.cs b/osu.Game/Online/API/Requests/GetUserBeatmapsRequest.cs index fb1385793f..e2c0ed4301 100644 --- a/osu.Game/Online/API/Requests/GetUserBeatmapsRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserBeatmapsRequest.cs @@ -26,9 +26,9 @@ namespace osu.Game.Online.API.Requests public enum BeatmapSetType { Favourite, - RankedAndApproved, + Ranked, Loved, - Unranked, + Pending, Graveyard } } diff --git a/osu.Game/Online/PollingComponent.cs b/osu.Game/Online/PollingComponent.cs index 3d19f2ab09..806c0047e7 100644 --- a/osu.Game/Online/PollingComponent.cs +++ b/osu.Game/Online/PollingComponent.cs @@ -70,7 +70,7 @@ namespace osu.Game.Online return true; } - // not ennough time has passed since the last poll. we do want to schedule a poll to happen, though. + // not enough time has passed since the last poll. we do want to schedule a poll to happen, though. scheduleNextPoll(); return false; } diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 6741c1cd13..8e32b2e6a7 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -932,7 +932,7 @@ namespace osu.Game try { - Logger.Log($"Loading {component}...", level: LogLevel.Debug); + Logger.Log($"Loading {component}..."); // Since this is running in a separate thread, it is possible for OsuGame to be disposed after LoadComponentAsync has been called // throwing an exception. To avoid this, the call is scheduled on the update thread, which does not run if IsDisposed = true @@ -952,7 +952,7 @@ namespace osu.Game await task.ConfigureAwait(false); - Logger.Log($"Loaded {component}!", level: LogLevel.Debug); + Logger.Log($"Loaded {component}!"); } catch (OperationCanceledException) { diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs index 650d105911..03d36ff5df 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs @@ -208,7 +208,8 @@ namespace osu.Game.Overlays.BeatmapListing { var sets = response.BeatmapSets.Select(responseJson => responseJson.ToBeatmapSet(rulesets)).ToList(); - if (sets.Count == 0) + // If the previous request returned a null cursor, the API is indicating we can't paginate further (maybe there are no more beatmaps left). + if (sets.Count == 0 || response.Cursor == null) noMoreResults = true; if (CurrentPage == 0) diff --git a/osu.Game/Overlays/BeatmapSet/ExplicitContentBeatmapPill.cs b/osu.Game/Overlays/BeatmapSet/ExplicitContentBeatmapPill.cs index 329f8ee0a2..ba78592ed2 100644 --- a/osu.Game/Overlays/BeatmapSet/ExplicitContentBeatmapPill.cs +++ b/osu.Game/Overlays/BeatmapSet/ExplicitContentBeatmapPill.cs @@ -2,11 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapSet { @@ -34,7 +36,7 @@ namespace osu.Game.Overlays.BeatmapSet new OsuSpriteText { Margin = new MarginPadding { Horizontal = 10f, Vertical = 2f }, - Text = "EXPLICIT", + Text = BeatmapsetsStrings.NsfwBadgeLabel.ToUpper(), Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold), Colour = OverlayColourProvider.Orange.Colour2, } diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index 0aa6108815..0445c63eb4 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -374,17 +374,17 @@ namespace osu.Game.Overlays public bool OnPressed(PlatformAction action) { - switch (action.ActionType) + switch (action) { - case PlatformActionType.TabNew: + case PlatformAction.TabNew: ChannelTabControl.SelectChannelSelectorTab(); return true; - case PlatformActionType.TabRestore: + case PlatformAction.TabRestore: channelManager.JoinLastClosedChannel(); return true; - case PlatformActionType.DocumentClose: + case PlatformAction.DocumentClose: channelManager.LeaveChannel(channelManager.CurrentChannel.Value); return true; } diff --git a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs index ec64371a5d..8657e356c9 100644 --- a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs @@ -46,11 +46,11 @@ namespace osu.Game.Overlays.Profile.Sections.Beatmaps case BeatmapSetType.Loved: return user.LovedBeatmapsetCount; - case BeatmapSetType.RankedAndApproved: - return user.RankedAndApprovedBeatmapsetCount; + case BeatmapSetType.Ranked: + return user.RankedBeatmapsetCount; - case BeatmapSetType.Unranked: - return user.UnrankedBeatmapsetCount; + case BeatmapSetType.Pending: + return user.PendingBeatmapsetCount; default: return 0; diff --git a/osu.Game/Overlays/Profile/Sections/BeatmapsSection.cs b/osu.Game/Overlays/Profile/Sections/BeatmapsSection.cs index 843ab531be..af6ab4aad1 100644 --- a/osu.Game/Overlays/Profile/Sections/BeatmapsSection.cs +++ b/osu.Game/Overlays/Profile/Sections/BeatmapsSection.cs @@ -19,9 +19,9 @@ namespace osu.Game.Overlays.Profile.Sections Children = new[] { new PaginatedBeatmapContainer(BeatmapSetType.Favourite, User, UsersStrings.ShowExtraBeatmapsFavouriteTitle), - new PaginatedBeatmapContainer(BeatmapSetType.RankedAndApproved, User, UsersStrings.ShowExtraBeatmapsRankedTitle), + new PaginatedBeatmapContainer(BeatmapSetType.Ranked, User, UsersStrings.ShowExtraBeatmapsRankedTitle), new PaginatedBeatmapContainer(BeatmapSetType.Loved, User, UsersStrings.ShowExtraBeatmapsLovedTitle), - new PaginatedBeatmapContainer(BeatmapSetType.Unranked, User, UsersStrings.ShowExtraBeatmapsPendingTitle), + new PaginatedBeatmapContainer(BeatmapSetType.Pending, User, UsersStrings.ShowExtraBeatmapsPendingTitle), new PaginatedBeatmapContainer(BeatmapSetType.Graveyard, User, UsersStrings.ShowExtraBeatmapsGraveyardTitle) }; } diff --git a/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs b/osu.Game/Overlays/Settings/Sections/Input/GlobalKeyBindingsSection.cs similarity index 97% rename from osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs rename to osu.Game/Overlays/Settings/Sections/Input/GlobalKeyBindingsSection.cs index 6ea4209cce..9898a50320 100644 --- a/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/GlobalKeyBindingsSection.cs @@ -5,9 +5,8 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Input.Bindings; -using osu.Game.Overlays.Settings; -namespace osu.Game.Overlays.KeyBinding +namespace osu.Game.Overlays.Settings.Sections.Input { public class GlobalKeyBindingsSection : SettingsSection { diff --git a/osu.Game/Overlays/KeyBindingPanel.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingPanel.cs similarity index 89% rename from osu.Game/Overlays/KeyBindingPanel.cs rename to osu.Game/Overlays/Settings/Sections/Input/KeyBindingPanel.cs index 928bd080fa..7cdc739b7c 100644 --- a/osu.Game/Overlays/KeyBindingPanel.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingPanel.cs @@ -4,11 +4,9 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Input.Bindings; -using osu.Game.Overlays.KeyBinding; -using osu.Game.Overlays.Settings; using osu.Game.Rulesets; -namespace osu.Game.Overlays +namespace osu.Game.Overlays.Settings.Sections.Input { public class KeyBindingPanel : SettingsSubPanel { diff --git a/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs similarity index 99% rename from osu.Game/Overlays/KeyBinding/KeyBindingRow.cs rename to osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs index ef620df171..4f7deebb5b 100644 --- a/osu.Game/Overlays/KeyBinding/KeyBindingRow.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs @@ -24,7 +24,7 @@ using osuTK; using osuTK.Graphics; using osuTK.Input; -namespace osu.Game.Overlays.KeyBinding +namespace osu.Game.Overlays.Settings.Sections.Input { public class KeyBindingRow : Container, IFilterable { diff --git a/osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs similarity index 97% rename from osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs rename to osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs index 1fdc1b6574..d65684fd37 100644 --- a/osu.Game/Overlays/KeyBinding/KeyBindingsSubsection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs @@ -9,11 +9,10 @@ using osu.Framework.Graphics; using osu.Game.Database; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; -using osu.Game.Overlays.Settings; using osu.Game.Rulesets; using osuTK; -namespace osu.Game.Overlays.KeyBinding +namespace osu.Game.Overlays.Settings.Sections.Input { public abstract class KeyBindingsSubsection : SettingsSubsection { diff --git a/osu.Game/Overlays/KeyBinding/RulesetBindingsSection.cs b/osu.Game/Overlays/Settings/Sections/Input/RulesetBindingsSection.cs similarity index 92% rename from osu.Game/Overlays/KeyBinding/RulesetBindingsSection.cs rename to osu.Game/Overlays/Settings/Sections/Input/RulesetBindingsSection.cs index 332fb6c8fc..81a4d7eccd 100644 --- a/osu.Game/Overlays/KeyBinding/RulesetBindingsSection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/RulesetBindingsSection.cs @@ -4,10 +4,9 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; -using osu.Game.Overlays.Settings; using osu.Game.Rulesets; -namespace osu.Game.Overlays.KeyBinding +namespace osu.Game.Overlays.Settings.Sections.Input { public class RulesetBindingsSection : SettingsSection { diff --git a/osu.Game/Overlays/KeyBinding/VariantBindingsSubsection.cs b/osu.Game/Overlays/Settings/Sections/Input/VariantBindingsSubsection.cs similarity index 93% rename from osu.Game/Overlays/KeyBinding/VariantBindingsSubsection.cs rename to osu.Game/Overlays/Settings/Sections/Input/VariantBindingsSubsection.cs index 7618a42282..a0f069b3bb 100644 --- a/osu.Game/Overlays/KeyBinding/VariantBindingsSubsection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/VariantBindingsSubsection.cs @@ -4,7 +4,7 @@ using osu.Framework.Localisation; using osu.Game.Rulesets; -namespace osu.Game.Overlays.KeyBinding +namespace osu.Game.Overlays.Settings.Sections.Input { public class VariantBindingsSubsection : KeyBindingsSubsection { diff --git a/osu.Game/Overlays/SettingsOverlay.cs b/osu.Game/Overlays/SettingsOverlay.cs index 8c21880cc6..54b780615d 100644 --- a/osu.Game/Overlays/SettingsOverlay.cs +++ b/osu.Game/Overlays/SettingsOverlay.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Overlays.Settings; using osu.Game.Overlays.Settings.Sections; +using osu.Game.Overlays.Settings.Sections.Input; using osuTK.Graphics; using System.Collections.Generic; using System.Linq; diff --git a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs index 71cee36812..d4fcefab9b 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/StrainSkill.cs @@ -109,7 +109,7 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// /// Returns the calculated difficulty value representing all s that have been processed up to this point. /// - public sealed override double DifficultyValue() + public override double DifficultyValue() { double difficulty = 0; double weight = 1; diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index c5db806918..69bdb5fd73 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -502,8 +502,7 @@ namespace osu.Game.Rulesets.Objects.Drawables { if (!(HitObject is IHasComboInformation combo)) return; - var comboColours = CurrentSkin.GetConfig>(GlobalSkinColours.ComboColours)?.Value ?? Array.Empty(); - AccentColour.Value = combo.GetComboColour(comboColours); + AccentColour.Value = combo.GetComboColour(CurrentSkin); } /// diff --git a/osu.Game/Rulesets/Objects/SliderPathExtensions.cs b/osu.Game/Rulesets/Objects/SliderPathExtensions.cs new file mode 100644 index 0000000000..1438c2f128 --- /dev/null +++ b/osu.Game/Rulesets/Objects/SliderPathExtensions.cs @@ -0,0 +1,47 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Game.Rulesets.Objects.Types; +using osuTK; + +#nullable enable + +namespace osu.Game.Rulesets.Objects +{ + public static class SliderPathExtensions + { + /// + /// Reverse the direction of this path. + /// + /// The . + /// The positional offset of the resulting path. It should be added to the start position of this path. + public static void Reverse(this SliderPath sliderPath, out Vector2 positionalOffset) + { + var points = sliderPath.ControlPoints.ToArray(); + positionalOffset = points.Last().Position.Value; + + sliderPath.ControlPoints.Clear(); + + PathType? lastType = null; + + for (var i = 0; i < points.Length; i++) + { + var p = points[i]; + p.Position.Value -= positionalOffset; + + // propagate types forwards to last null type + if (i == points.Length - 1) + p.Type.Value = lastType; + else if (p.Type.Value != null) + { + var newType = p.Type.Value; + p.Type.Value = lastType; + lastType = newType; + } + + sliderPath.ControlPoints.Insert(0, p); + } + } + } +} diff --git a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs index 4f66802079..03e6f76cca 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs @@ -1,9 +1,8 @@ // 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 JetBrains.Annotations; using osu.Framework.Bindables; +using osu.Game.Skinning; using osuTK.Graphics; namespace osu.Game.Rulesets.Objects.Types @@ -40,11 +39,21 @@ namespace osu.Game.Rulesets.Objects.Types bool LastInCombo { get; set; } /// - /// Retrieves the colour of the combo described by this object from a set of possible combo colours. - /// Defaults to using to decide the colour. + /// Retrieves the colour of the combo described by this object. /// - /// A list of possible combo colours provided by the beatmap or skin. - /// The colour of the combo described by this object. - Color4 GetComboColour([NotNull] IReadOnlyList comboColours) => comboColours.Count > 0 ? comboColours[ComboIndex % comboColours.Count] : Color4.White; + /// The skin to retrieve the combo colour from, if wanted. + Color4 GetComboColour(ISkin skin) => GetSkinComboColour(this, skin, ComboIndex); + + /// + /// Retrieves the colour of the combo described by a given object from a given skin. + /// + /// The combo information, should be this. + /// The skin to retrieve the combo colour from. + /// The index to retrieve the combo colour with. + /// + protected static Color4 GetSkinComboColour(IHasComboInformation combo, ISkin skin, int comboIndex) + { + return skin.GetConfig(new SkinComboColourLookup(comboIndex, combo))?.Value ?? Color4.White; + } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index 185f029d14..b99dacbd4a 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -110,9 +110,9 @@ namespace osu.Game.Screens.Edit.Compose.Components bool selectionPerformed = performMouseDownActions(e); // even if a selection didn't occur, a drag event may still move the selection. - prepareSelectionMovement(); + bool movementPossible = prepareSelectionMovement(); - return selectionPerformed || e.Button == MouseButton.Left; + return selectionPerformed || (e.Button == MouseButton.Left && movementPossible); } protected SelectionBlueprint ClickedBlueprint { get; private set; } @@ -230,9 +230,9 @@ namespace osu.Game.Screens.Edit.Compose.Components public bool OnPressed(PlatformAction action) { - switch (action.ActionType) + switch (action) { - case PlatformActionType.SelectAll: + case PlatformAction.SelectAll: SelectAll(); return true; } @@ -427,19 +427,21 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// Attempts to begin the movement of any selected blueprints. /// - private void prepareSelectionMovement() + /// Whether a movement is possible. + private bool prepareSelectionMovement() { if (!SelectionHandler.SelectedBlueprints.Any()) - return; + return false; // Any selected blueprint that is hovered can begin the movement of the group, however only the first item (according to SortForMovement) is used for movement. // A special case is added for when a click selection occurred before the drag if (!clickSelectionBegan && !SelectionHandler.SelectedBlueprints.Any(b => b.IsHovered)) - return; + return false; // Movement is tracked from the blueprint of the earliest item, since it only makes sense to distance snap from that item movementBlueprints = SortForMovement(SelectionHandler.SelectedBlueprints).ToArray(); movementBlueprintOriginalPositions = movementBlueprints.Select(m => m.ScreenSpaceSelectionPoint).ToArray(); + return true; } /// diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs index dc457b5320..be52a968bb 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs @@ -64,7 +64,7 @@ namespace osu.Game.Screens.Edit.Compose.Components private bool canScaleX; /// - /// Whether vertical scale support should be enabled. + /// Whether horizontal scaling support should be enabled. /// public bool CanScaleX { @@ -81,7 +81,7 @@ namespace osu.Game.Screens.Edit.Compose.Components private bool canScaleY; /// - /// Whether horizontal scale support should be enabled. + /// Whether vertical scaling support should be enabled. /// public bool CanScaleY { @@ -95,6 +95,40 @@ namespace osu.Game.Screens.Edit.Compose.Components } } + private bool canFlipX; + + /// + /// Whether horizontal flipping support should be enabled. + /// + public bool CanFlipX + { + get => canFlipX; + set + { + if (canFlipX == value) return; + + canFlipX = value; + recreate(); + } + } + + private bool canFlipY; + + /// + /// Whether vertical flipping support should be enabled. + /// + public bool CanFlipY + { + get => canFlipY; + set + { + if (canFlipY == value) return; + + canFlipY = value; + recreate(); + } + } + private string text; public string Text @@ -142,10 +176,10 @@ namespace osu.Game.Screens.Edit.Compose.Components return CanReverse && runOperationFromHotkey(OnReverse); case Key.H: - return CanScaleX && runOperationFromHotkey(() => OnFlip?.Invoke(Direction.Horizontal) ?? false); + return CanFlipX && runOperationFromHotkey(() => OnFlip?.Invoke(Direction.Horizontal) ?? false); case Key.J: - return CanScaleY && runOperationFromHotkey(() => OnFlip?.Invoke(Direction.Vertical) ?? false); + return CanFlipY && runOperationFromHotkey(() => OnFlip?.Invoke(Direction.Vertical) ?? false); } return base.OnKeyDown(e); @@ -214,6 +248,8 @@ namespace osu.Game.Screens.Edit.Compose.Components if (CanScaleX) addXScaleComponents(); if (CanScaleX && CanScaleY) addFullScaleComponents(); if (CanScaleY) addYScaleComponents(); + if (CanFlipX) addXFlipComponents(); + if (CanFlipY) addYFlipComponents(); if (CanRotate) addRotationComponents(); if (CanReverse) addButton(FontAwesome.Solid.Backward, "Reverse pattern (Ctrl-G)", () => OnReverse?.Invoke()); } @@ -231,8 +267,6 @@ namespace osu.Game.Screens.Edit.Compose.Components private void addYScaleComponents() { - addButton(FontAwesome.Solid.ArrowsAltV, "Flip vertically (Ctrl-J)", () => OnFlip?.Invoke(Direction.Vertical)); - addScaleHandle(Anchor.TopCentre); addScaleHandle(Anchor.BottomCentre); } @@ -247,12 +281,20 @@ namespace osu.Game.Screens.Edit.Compose.Components private void addXScaleComponents() { - addButton(FontAwesome.Solid.ArrowsAltH, "Flip horizontally (Ctrl-H)", () => OnFlip?.Invoke(Direction.Horizontal)); - addScaleHandle(Anchor.CentreLeft); addScaleHandle(Anchor.CentreRight); } + private void addXFlipComponents() + { + addButton(FontAwesome.Solid.ArrowsAltH, "Flip horizontally (Ctrl-H)", () => OnFlip?.Invoke(Direction.Horizontal)); + } + + private void addYFlipComponents() + { + addButton(FontAwesome.Solid.ArrowsAltV, "Flip vertically (Ctrl-J)", () => OnFlip?.Invoke(Direction.Vertical)); + } + private void addButton(IconUsage icon, string tooltip, Action action) { var button = new SelectionBoxButton(icon, tooltip) diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index 8939be925a..1d1d95890f 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -139,9 +139,9 @@ namespace osu.Game.Screens.Edit.Compose.Components public bool OnPressed(PlatformAction action) { - switch (action.ActionMethod) + switch (action) { - case PlatformActionMethod.Delete: + case PlatformAction.Delete: DeleteSelected(); return true; } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs index a642768574..73c38ba23f 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs @@ -13,11 +13,9 @@ using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Utils; using osu.Game.Graphics; -using osu.Game.Input.Bindings; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Screens.Edit.Components.Timelines.Summary.Parts; @@ -31,22 +29,12 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline [Resolved(CanBeNull = true)] private Timeline timeline { get; set; } - [Resolved] - private OsuColour colours { get; set; } - private DragEvent lastDragEvent; private Bindable placement; private SelectionBlueprint placementBlueprint; - private SelectableAreaBackground backgroundBox; - - // we only care about checking vertical validity. - // this allows selecting and dragging selections before time=0. - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) - { - float localY = ToLocalSpace(screenSpacePos).Y; - return DrawRectangle.Top <= localY && DrawRectangle.Bottom >= localY; - } + // We want children within the timeline to be interactable + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => timeline.ScreenSpaceDrawQuad.Contains(screenSpacePos); public TimelineBlueprintContainer(HitObjectComposer composer) : base(composer) @@ -61,7 +49,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline [BackgroundDependencyLoader] private void load() { - AddInternal(backgroundBox = new SelectableAreaBackground + AddInternal(new SelectableAreaBackground { Colour = Color4.Black, Depth = float.MaxValue, @@ -100,18 +88,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline protected override Container> CreateSelectionBlueprintContainer() => new TimelineSelectionBlueprintContainer { RelativeSizeAxes = Axes.Both }; - protected override bool OnHover(HoverEvent e) - { - backgroundBox.FadeColour(colours.BlueLighter, 120, Easing.OutQuint); - return base.OnHover(e); - } - - protected override void OnHoverLost(HoverLostEvent e) - { - backgroundBox.FadeColour(Color4.Black, 600, Easing.OutQuint); - base.OnHoverLost(e); - } - protected override void OnDrag(DragEvent e) { handleScrollViaDrag(e); @@ -184,7 +160,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { return new TimelineHitObjectBlueprint(item) { - OnDragHandled = handleScrollViaDrag + OnDragHandled = handleScrollViaDrag, }; } @@ -212,6 +188,15 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private class SelectableAreaBackground : CompositeDrawable { + [Resolved] + private OsuColour colours { get; set; } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) + { + float localY = ToLocalSpace(screenSpacePos).Y; + return DrawRectangle.Top <= localY && DrawRectangle.Bottom >= localY; + } + [BackgroundDependencyLoader] private void load() { @@ -235,114 +220,17 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } }); } - } - internal class TimelineSelectionHandler : EditorSelectionHandler, IKeyBindingHandler - { - // for now we always allow movement. snapping is provided by the Timeline's "distance" snap implementation - public override bool HandleMovement(MoveSelectionEvent moveEvent) => true; - - public bool OnPressed(GlobalAction action) + protected override bool OnHover(HoverEvent e) { - switch (action) - { - case GlobalAction.EditorNudgeLeft: - nudgeSelection(-1); - return true; - - case GlobalAction.EditorNudgeRight: - nudgeSelection(1); - return true; - } - - return false; + this.FadeColour(colours.BlueLighter, 120, Easing.OutQuint); + return base.OnHover(e); } - public void OnReleased(GlobalAction action) + protected override void OnHoverLost(HoverLostEvent e) { - } - - /// - /// Nudge the current selection by the specified multiple of beat divisor lengths, - /// based on the timing at the first object in the selection. - /// - /// The direction and count of beat divisor lengths to adjust. - private void nudgeSelection(int amount) - { - var selected = EditorBeatmap.SelectedHitObjects; - - if (selected.Count == 0) - return; - - var timingPoint = EditorBeatmap.ControlPointInfo.TimingPointAt(selected.First().StartTime); - double adjustment = timingPoint.BeatLength / EditorBeatmap.BeatDivisor * amount; - - EditorBeatmap.PerformOnSelection(h => - { - h.StartTime += adjustment; - EditorBeatmap.Update(h); - }); - } - } - - private class TimelineDragBox : DragBox - { - // the following values hold the start and end X positions of the drag box in the timeline's local space, - // but with zoom unapplied in order to be able to compensate for positional changes - // while the timeline is being zoomed in/out. - private float? selectionStart; - private float selectionEnd; - - [Resolved] - private Timeline timeline { get; set; } - - public TimelineDragBox(Action performSelect) - : base(performSelect) - { - } - - protected override Drawable CreateBox() => new Box - { - RelativeSizeAxes = Axes.Y, - Alpha = 0.3f - }; - - public override bool HandleDrag(MouseButtonEvent e) - { - selectionStart ??= e.MouseDownPosition.X / timeline.CurrentZoom; - - // only calculate end when a transition is not in progress to avoid bouncing. - if (Precision.AlmostEquals(timeline.CurrentZoom, timeline.Zoom)) - selectionEnd = e.MousePosition.X / timeline.CurrentZoom; - - updateDragBoxPosition(); - return true; - } - - private void updateDragBoxPosition() - { - if (selectionStart == null) - return; - - float rescaledStart = selectionStart.Value * timeline.CurrentZoom; - float rescaledEnd = selectionEnd * timeline.CurrentZoom; - - Box.X = Math.Min(rescaledStart, rescaledEnd); - Box.Width = Math.Abs(rescaledStart - rescaledEnd); - - var boxScreenRect = Box.ScreenSpaceDrawQuad.AABBFloat; - - // we don't care about where the hitobjects are vertically. in cases like stacking display, they may be outside the box without this adjustment. - boxScreenRect.Y -= boxScreenRect.Height; - boxScreenRect.Height *= 2; - - PerformSelection?.Invoke(boxScreenRect); - } - - public override void Hide() - { - base.Hide(); - selectionStart = null; + this.FadeColour(Color4.Black, 600, Easing.OutQuint); + base.OnHoverLost(e); } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineDragBox.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineDragBox.cs new file mode 100644 index 0000000000..8aad8aa6dc --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineDragBox.cs @@ -0,0 +1,79 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Utils; + +namespace osu.Game.Screens.Edit.Compose.Components.Timeline +{ + public class TimelineDragBox : DragBox + { + // the following values hold the start and end X positions of the drag box in the timeline's local space, + // but with zoom unapplied in order to be able to compensate for positional changes + // while the timeline is being zoomed in/out. + private float? selectionStart; + private float selectionEnd; + + [Resolved] + private Timeline timeline { get; set; } + + public TimelineDragBox(Action performSelect) + : base(performSelect) + { + } + + protected override Drawable CreateBox() => new Box + { + RelativeSizeAxes = Axes.Y, + Alpha = 0.3f + }; + + public override bool HandleDrag(MouseButtonEvent e) + { + // The dragbox should only be active if the mouseDownPosition.Y is within this drawable's bounds. + float localY = ToLocalSpace(e.ScreenSpaceMouseDownPosition).Y; + if (DrawRectangle.Top > localY || DrawRectangle.Bottom < localY) + return false; + + selectionStart ??= e.MouseDownPosition.X / timeline.CurrentZoom; + + // only calculate end when a transition is not in progress to avoid bouncing. + if (Precision.AlmostEquals(timeline.CurrentZoom, timeline.Zoom)) + selectionEnd = e.MousePosition.X / timeline.CurrentZoom; + + updateDragBoxPosition(); + return true; + } + + private void updateDragBoxPosition() + { + if (selectionStart == null) + return; + + float rescaledStart = selectionStart.Value * timeline.CurrentZoom; + float rescaledEnd = selectionEnd * timeline.CurrentZoom; + + Box.X = Math.Min(rescaledStart, rescaledEnd); + Box.Width = Math.Abs(rescaledStart - rescaledEnd); + + var boxScreenRect = Box.ScreenSpaceDrawQuad.AABBFloat; + + // we don't care about where the hitobjects are vertically. in cases like stacking display, they may be outside the box without this adjustment. + boxScreenRect.Y -= boxScreenRect.Height; + boxScreenRect.Height *= 2; + + PerformSelection?.Invoke(boxScreenRect); + } + + public override void Hide() + { + base.Hide(); + selectionStart = null; + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs index 377c37c4c7..7f8cc1c8fa 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -153,11 +152,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline break; case IHasComboInformation combo: - { - var comboColours = skin.GetConfig>(GlobalSkinColours.ComboColours)?.Value ?? Array.Empty(); - colour = combo.GetComboColour(comboColours); + colour = combo.GetComboColour(skin); break; - } default: return; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineSelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineSelectionHandler.cs new file mode 100644 index 0000000000..354013a5fd --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineSelectionHandler.cs @@ -0,0 +1,65 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Input.Bindings; +using osu.Game.Input.Bindings; +using osu.Game.Rulesets.Objects; +using osuTK; + +namespace osu.Game.Screens.Edit.Compose.Components.Timeline +{ + internal class TimelineSelectionHandler : EditorSelectionHandler, IKeyBindingHandler + { + [Resolved] + private Timeline timeline { get; set; } + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => timeline.ScreenSpaceDrawQuad.Contains(screenSpacePos); + + // for now we always allow movement. snapping is provided by the Timeline's "distance" snap implementation + public override bool HandleMovement(MoveSelectionEvent moveEvent) => true; + + public bool OnPressed(GlobalAction action) + { + switch (action) + { + case GlobalAction.EditorNudgeLeft: + nudgeSelection(-1); + return true; + + case GlobalAction.EditorNudgeRight: + nudgeSelection(1); + return true; + } + + return false; + } + + public void OnReleased(GlobalAction action) + { + } + + /// + /// Nudge the current selection by the specified multiple of beat divisor lengths, + /// based on the timing at the first object in the selection. + /// + /// The direction and count of beat divisor lengths to adjust. + private void nudgeSelection(int amount) + { + var selected = EditorBeatmap.SelectedHitObjects; + + if (selected.Count == 0) + return; + + var timingPoint = EditorBeatmap.ControlPointInfo.TimingPointAt(selected.First().StartTime); + double adjustment = timingPoint.BeatLength / EditorBeatmap.BeatDivisor * amount; + + EditorBeatmap.PerformOnSelection(h => + { + h.StartTime += adjustment; + EditorBeatmap.Update(h); + }); + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs index b56f9bee14..4a1f1196a9 100644 --- a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs +++ b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs @@ -80,7 +80,7 @@ namespace osu.Game.Screens.Edit.Compose public bool OnPressed(PlatformAction action) { - if (action.ActionType == PlatformActionType.Copy) + if (action == PlatformAction.Copy) host.GetClipboard().SetText(formatSelectionAsString()); return false; diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 71dd47b058..b6dc97a7f6 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -330,29 +330,29 @@ namespace osu.Game.Screens.Edit public bool OnPressed(PlatformAction action) { - switch (action.ActionType) + switch (action) { - case PlatformActionType.Cut: + case PlatformAction.Cut: Cut(); return true; - case PlatformActionType.Copy: + case PlatformAction.Copy: Copy(); return true; - case PlatformActionType.Paste: + case PlatformAction.Paste: Paste(); return true; - case PlatformActionType.Undo: + case PlatformAction.Undo: Undo(); return true; - case PlatformActionType.Redo: + case PlatformAction.Redo: Redo(); return true; - case PlatformActionType.Save: + case PlatformAction.Save: Save(); return true; } diff --git a/osu.Game/Screens/Edit/EditorTable.cs b/osu.Game/Screens/Edit/EditorTable.cs index 815f3ed0ea..9578b96897 100644 --- a/osu.Game/Screens/Edit/EditorTable.cs +++ b/osu.Game/Screens/Edit/EditorTable.cs @@ -9,6 +9,7 @@ using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osuTK.Graphics; @@ -64,6 +65,7 @@ namespace osu.Game.Screens.Edit private EditorClock clock { get; set; } public RowBackground(object item) + : base(HoverSampleSet.Soft) { Item = item; diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs index a3a61ccc36..69eb857661 100644 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs +++ b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs @@ -108,7 +108,11 @@ namespace osu.Game.Screens.OnlinePlay difficultyIconContainer.Child = new DifficultyIcon(beatmap.Value, ruleset.Value, requiredMods) { Size = new Vector2(32) }; beatmapText.Clear(); - beatmapText.AddLink(Item.Beatmap.Value.ToRomanisableString(), LinkAction.OpenBeatmap, Item.Beatmap.Value.OnlineBeatmapID.ToString()); + beatmapText.AddLink(Item.Beatmap.Value.ToRomanisableString(), LinkAction.OpenBeatmap, Item.Beatmap.Value.OnlineBeatmapID.ToString(), null, text => + { + text.Truncate = true; + text.RelativeSizeAxes = Axes.X; + }); authorText.Clear(); @@ -147,84 +151,96 @@ namespace osu.Game.Screens.OnlinePlay RelativeSizeAxes = Axes.Both, Beatmap = { BindTarget = beatmap } }, - new FillFlowContainer + new GridContainer { RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding { Left = 8 }, - Spacing = new Vector2(8, 0), - Direction = FillDirection.Horizontal, - Children = new Drawable[] + ColumnDimensions = new[] { - difficultyIconContainer = new Container + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new Drawable[] { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - }, - new FillFlowContainer - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Children = new Drawable[] + difficultyIconContainer = new Container { - beatmapText = new LinkFlowContainer(fontParameters) { AutoSizeAxes = Axes.Both }, - new FillFlowContainer + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Margin = new MarginPadding { Left = 8, Right = 8, }, + }, + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10f, 0), - Children = new Drawable[] + beatmapText = new LinkFlowContainer(fontParameters) { - new FillFlowContainer + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10f, 0), + Children = new Drawable[] { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10f, 0), - Children = new Drawable[] + new FillFlowContainer { - authorText = new LinkFlowContainer(fontParameters) { AutoSizeAxes = Axes.Both }, - explicitContentPill = new ExplicitContentBeatmapPill + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10f, 0), + Children = new Drawable[] { - Alpha = 0f, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Margin = new MarginPadding { Top = 3f }, - } + authorText = new LinkFlowContainer(fontParameters) { AutoSizeAxes = Axes.Both }, + explicitContentPill = new ExplicitContentBeatmapPill + { + Alpha = 0f, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Top = 3f }, + } + }, }, - }, - new Container - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - Child = modDisplay = new ModDisplay + new Container { - Scale = new Vector2(0.4f), - ExpansionMode = ExpansionMode.AlwaysExpanded + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Child = modDisplay = new ModDisplay + { + Scale = new Vector2(0.4f), + ExpansionMode = ExpansionMode.AlwaysExpanded + } } } } } + }, + new FillFlowContainer + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding { Left = 8, Right = 10, }, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(5), + ChildrenEnumerable = CreateButtons().Select(button => button.With(b => + { + b.Anchor = Anchor.Centre; + b.Origin = Anchor.Centre; + })) } } } }, - new FillFlowContainer - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Direction = FillDirection.Horizontal, - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(5), - X = -10, - ChildrenEnumerable = CreateButtons().Select(button => button.With(b => - { - b.Anchor = Anchor.Centre; - b.Origin = Anchor.Centre; - })) - } } }; } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs index 236408851f..940ae873ec 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs @@ -390,6 +390,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components base.LoadComplete(); Schedule(() => GetContainingInputManager().ChangeFocus(passwordTextbox)); + passwordTextbox.OnCommit += (_, __) => JoinRequested?.Invoke(room, passwordTextbox.Text); } } } diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs index ceee002c6e..25b02e5084 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.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.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -30,17 +31,19 @@ namespace osu.Game.Screens.OnlinePlay [Cached] public abstract class OnlinePlayScreen : OsuScreen, IHasSubScreenStack { - public override bool CursorVisible => (screenStack.CurrentScreen as IOnlinePlaySubScreen)?.CursorVisible ?? true; + public override bool CursorVisible => (screenStack?.CurrentScreen as IOnlinePlaySubScreen)?.CursorVisible ?? true; // this is required due to PlayerLoader eventually being pushed to the main stack // while leases may be taken out by a subscreen. public override bool DisallowExternalBeatmapRulesetChanges => true; - private readonly MultiplayerWaveContainer waves; + private MultiplayerWaveContainer waves; - private readonly OsuButton createButton; - private readonly LoungeSubScreen loungeSubScreen; - private readonly ScreenStack screenStack; + private OsuButton createButton; + + private ScreenStack screenStack; + + private LoungeSubScreen loungeSubScreen; private readonly IBindable isIdle = new BindableBool(); @@ -54,7 +57,7 @@ namespace osu.Game.Screens.OnlinePlay private readonly Bindable currentFilter = new Bindable(new FilterCriteria()); [Cached] - private OngoingOperationTracker ongoingOperationTracker { get; set; } + private readonly OngoingOperationTracker ongoingOperationTracker = new OngoingOperationTracker(); [Resolved(CanBeNull = true)] private MusicController music { get; set; } @@ -65,11 +68,14 @@ namespace osu.Game.Screens.OnlinePlay [Resolved] protected IAPIProvider API { get; private set; } + [Resolved(CanBeNull = true)] + private IdleTracker idleTracker { get; set; } + [Resolved(CanBeNull = true)] private OsuLogo logo { get; set; } - private readonly Drawable header; - private readonly Drawable headerBackground; + private Drawable header; + private Drawable headerBackground; protected OnlinePlayScreen() { @@ -78,6 +84,14 @@ namespace osu.Game.Screens.OnlinePlay RelativeSizeAxes = Axes.Both; Padding = new MarginPadding { Horizontal = -HORIZONTAL_OVERFLOW_PADDING }; + RoomManager = CreateRoomManager(); + } + + private readonly IBindable apiState = new Bindable(); + + [BackgroundDependencyLoader] + private void load() + { var backgroundColour = Color4Extensions.FromHex(@"3e3a44"); InternalChild = waves = new MultiplayerWaveContainer @@ -144,27 +158,14 @@ namespace osu.Game.Screens.OnlinePlay }; button.Action = () => OpenNewRoom(); }), - RoomManager = CreateRoomManager(), - ongoingOperationTracker = new OngoingOperationTracker() + RoomManager, + ongoingOperationTracker, } }; - screenStack.ScreenPushed += screenPushed; - screenStack.ScreenExited += screenExited; - - screenStack.Push(loungeSubScreen = CreateLounge()); - } - - private readonly IBindable apiState = new Bindable(); - - [BackgroundDependencyLoader(true)] - private void load(IdleTracker idleTracker) - { - apiState.BindTo(API.State); - apiState.BindValueChanged(onlineStateChanged, true); - - if (idleTracker != null) - isIdle.BindTo(idleTracker.IsIdle); + // a lot of the functionality in this class depends on loungeSubScreen being in a ready to go state. + // as such, we intentionally load this inline so it is ready alongside this screen. + LoadComponent(loungeSubScreen = CreateLounge()); } private void onlineStateChanged(ValueChangedEvent state) => Schedule(() => @@ -179,7 +180,20 @@ namespace osu.Game.Screens.OnlinePlay protected override void LoadComplete() { base.LoadComplete(); - isIdle.BindValueChanged(idle => UpdatePollingRate(idle.NewValue), true); + + screenStack.ScreenPushed += screenPushed; + screenStack.ScreenExited += screenExited; + + screenStack.Push(loungeSubScreen); + + apiState.BindTo(API.State); + apiState.BindValueChanged(onlineStateChanged, true); + + if (idleTracker != null) + { + isIdle.BindTo(idleTracker.IsIdle); + isIdle.BindValueChanged(idle => UpdatePollingRate(idle.NewValue), true); + } } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) @@ -222,7 +236,9 @@ namespace osu.Game.Screens.OnlinePlay this.FadeIn(250); this.ScaleTo(1, 250, Easing.OutSine); - screenStack.CurrentScreen?.OnResuming(last); + Debug.Assert(screenStack.CurrentScreen != null); + screenStack.CurrentScreen.OnResuming(last); + base.OnResuming(last); UpdatePollingRate(isIdle.Value); @@ -233,14 +249,16 @@ namespace osu.Game.Screens.OnlinePlay this.ScaleTo(1.1f, 250, Easing.InSine); this.FadeOut(250); - screenStack.CurrentScreen?.OnSuspending(next); + Debug.Assert(screenStack.CurrentScreen != null); + screenStack.CurrentScreen.OnSuspending(next); UpdatePollingRate(isIdle.Value); } public override bool OnExiting(IScreen next) { - if (screenStack.CurrentScreen?.OnExiting(next) == true) + var subScreen = screenStack.CurrentScreen as Drawable; + if (subScreen?.IsLoaded == true && screenStack.CurrentScreen.OnExiting(next)) return true; RoomManager.PartRoom(); diff --git a/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs b/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs index fd1150650c..4265a83ce1 100644 --- a/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs +++ b/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs @@ -172,7 +172,6 @@ namespace osu.Game.Screens.Play { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - AutoSizeAxes = Axes.Both, Margin = new MarginPadding { Top = 20 }, Current = mods }, diff --git a/osu.Game/Screens/Play/HUD/ModDisplay.cs b/osu.Game/Screens/Play/HUD/ModDisplay.cs index 2f7ca74372..b4a3eb209a 100644 --- a/osu.Game/Screens/Play/HUD/ModDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ModDisplay.cs @@ -15,24 +15,26 @@ using osuTK; namespace osu.Game.Screens.Play.HUD { - public class ModDisplay : Container, IHasCurrentValue> + /// + /// Displays a single-line horizontal auto-sized flow of mods. For cases where wrapping is required, use instead. + /// + public class ModDisplay : CompositeDrawable, IHasCurrentValue> { private const int fade_duration = 1000; public ExpansionMode ExpansionMode = ExpansionMode.ExpandOnHover; - private readonly Bindable> current = new Bindable>(); + private readonly BindableWithCurrent> current = new BindableWithCurrent>(); public Bindable> Current { - get => current; + get => current.Current; set { if (value == null) throw new ArgumentNullException(nameof(value)); - current.UnbindBindings(); - current.BindTo(value); + current.Current = value; } } @@ -42,51 +44,34 @@ namespace osu.Game.Screens.Play.HUD { AutoSizeAxes = Axes.Both; - Child = new FillFlowContainer + InternalChild = iconsContainer = new ReverseChildIDFillFlowContainer { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - iconsContainer = new ReverseChildIDFillFlowContainer - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - }, - }, + Direction = FillDirection.Horizontal, }; } - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - Current.UnbindAll(); - } - protected override void LoadComplete() { base.LoadComplete(); - Current.BindValueChanged(mods => - { - iconsContainer.Clear(); - - if (mods.NewValue != null) - { - foreach (Mod mod in mods.NewValue) - iconsContainer.Add(new ModIcon(mod) { Scale = new Vector2(0.6f) }); - - appearTransform(); - } - }, true); + Current.BindValueChanged(updateDisplay, true); iconsContainer.FadeInFromZero(fade_duration, Easing.OutQuint); } + private void updateDisplay(ValueChangedEvent> mods) + { + iconsContainer.Clear(); + + if (mods.NewValue == null) return; + + foreach (Mod mod in mods.NewValue) + iconsContainer.Add(new ModIcon(mod) { Scale = new Vector2(0.6f) }); + + appearTransform(); + } + private void appearTransform() { expand(); diff --git a/osu.Game/Screens/Play/HUD/ModFlowDisplay.cs b/osu.Game/Screens/Play/HUD/ModFlowDisplay.cs new file mode 100644 index 0000000000..ff3ca6460f --- /dev/null +++ b/osu.Game/Screens/Play/HUD/ModFlowDisplay.cs @@ -0,0 +1,83 @@ +// 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.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics.Containers; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Screens.Play.HUD +{ + /// + /// A horizontally wrapping display of mods. For cases where wrapping is not required, use instead. + /// + public class ModFlowDisplay : ReverseChildIDFillFlowContainer, IHasCurrentValue> + { + private const int fade_duration = 1000; + + private readonly BindableWithCurrent> current = new BindableWithCurrent>(); + + public Bindable> Current + { + get => current.Current; + set + { + if (value == null) + throw new ArgumentNullException(nameof(value)); + + current.Current = value; + } + } + + private float iconScale = 1; + + public float IconScale + { + get => iconScale; + set + { + iconScale = value; + updateDisplay(); + } + } + + public ModFlowDisplay() + { + Direction = FillDirection.Full; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(_ => updateDisplay(), true); + + this.FadeInFromZero(fade_duration, Easing.OutQuint); + } + + private void updateDisplay() + { + Clear(); + + if (current.Value == null) return; + + Spacing = new Vector2(0, -12 * iconScale); + + foreach (Mod mod in current.Value) + { + Add(new ModIcon(mod) + { + Scale = new Vector2(0.6f * iconScale), + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }); + } + } + } +} diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index ffe03815f5..2cf2555b3e 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -282,7 +282,6 @@ namespace osu.Game.Screens.Play { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, - AutoSizeAxes = Axes.Both, }; protected PlayerSettingsOverlay CreatePlayerSettingsOverlay() => new PlayerSettingsOverlay(); diff --git a/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs index 7e8dcdcfe0..20c603295b 100644 --- a/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs @@ -131,14 +131,14 @@ namespace osu.Game.Screens.Ranking.Contracted createStatistic("Accuracy", $"{score.Accuracy.FormatAccuracy()}"), } }, - new ModDisplay + new ModFlowDisplay { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - AutoSizeAxes = Axes.Both, - ExpansionMode = ExpansionMode.AlwaysExpanded, + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, Current = { Value = score.Mods }, - Scale = new Vector2(0.5f), + IconScale = 0.5f, } } } diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs index 4d3f7a4184..e10fe5726d 100644 --- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; @@ -79,162 +80,155 @@ namespace osu.Game.Screens.Ranking.Expanded var starDifficulty = beatmapDifficultyCache.GetDifficultyAsync(beatmap, score.Ruleset, score.Mods).Result; - InternalChildren = new Drawable[] + AddInternal(new FillFlowContainer { - new FillFlowContainer + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(20), + Children = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(20), - Children = new Drawable[] + new FillFlowContainer { - new FillFlowContainer + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] + new OsuSpriteText { - new OsuSpriteText + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = new RomanisableString(metadata.TitleUnicode, metadata.Title), + Font = OsuFont.Torus.With(size: 20, weight: FontWeight.SemiBold), + MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, + Truncate = true, + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = new RomanisableString(metadata.ArtistUnicode, metadata.Artist), + Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold), + MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, + Truncate = true, + }, + new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Margin = new MarginPadding { Top = 40 }, + RelativeSizeAxes = Axes.X, + Height = 230, + Child = new AccuracyCircle(score, withFlair) { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Text = new RomanisableString(metadata.TitleUnicode, metadata.Title), - Font = OsuFont.Torus.With(size: 20, weight: FontWeight.SemiBold), - MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, - Truncate = true, - }, - new OsuSpriteText + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + } + }, + scoreCounter = new TotalScoreCounter + { + Margin = new MarginPadding { Top = 0, Bottom = 5 }, + Current = { Value = 0 }, + Alpha = 0, + AlwaysPresent = true + }, + starAndModDisplay = new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(5, 0), + Children = new Drawable[] { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Text = new RomanisableString(metadata.ArtistUnicode, metadata.Artist), - Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold), - MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, - Truncate = true, - }, - new Container - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Margin = new MarginPadding { Top = 40 }, - RelativeSizeAxes = Axes.X, - Height = 230, - Child = new AccuracyCircle(score, withFlair) + new StarRatingDisplay(starDifficulty) { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit, - } - }, - scoreCounter = new TotalScoreCounter + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + }, + } + }, + new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] { - Margin = new MarginPadding { Top = 0, Bottom = 5 }, - Current = { Value = 0 }, - Alpha = 0, - AlwaysPresent = true - }, - starAndModDisplay = new FillFlowContainer - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(5, 0), - Children = new Drawable[] + new OsuSpriteText { - new StarRatingDisplay(starDifficulty) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft - }, - } - }, - new FillFlowContainer - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Direction = FillDirection.Vertical, - AutoSizeAxes = Axes.Both, - Children = new Drawable[] + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = beatmap.Version, + Font = OsuFont.Torus.With(size: 16, weight: FontWeight.SemiBold), + }, + new OsuTextFlowContainer(s => s.Font = OsuFont.Torus.With(size: 12)) { - new OsuSpriteText + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + }.With(t => + { + if (!string.IsNullOrEmpty(creator)) { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Text = beatmap.Version, - Font = OsuFont.Torus.With(size: 16, weight: FontWeight.SemiBold), - }, - new OsuTextFlowContainer(s => s.Font = OsuFont.Torus.With(size: 12)) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - }.With(t => - { - if (!string.IsNullOrEmpty(creator)) - { - t.AddText("mapped by "); - t.AddText(creator, s => s.Font = s.Font.With(weight: FontWeight.SemiBold)); - } - }) - } - }, - } - }, - new FillFlowContainer + t.AddText("mapped by "); + t.AddText(creator, s => s.Font = s.Font.With(weight: FontWeight.SemiBold)); + } + }) + } + }, + } + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 5), - Children = new Drawable[] + new GridContainer { - new GridContainer + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] { topStatistics.Cast().ToArray() }, + RowDimensions = new[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Content = new[] { topStatistics.Cast().ToArray() }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - } - }, - new GridContainer + new Dimension(GridSizeMode.AutoSize), + } + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] { bottomStatistics.Where(s => s.Result <= HitResult.Perfect).ToArray() }, + RowDimensions = new[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Content = new[] { bottomStatistics.Where(s => s.Result <= HitResult.Perfect).ToArray() }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - } - }, - new GridContainer + new Dimension(GridSizeMode.AutoSize), + } + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] { bottomStatistics.Where(s => s.Result > HitResult.Perfect).ToArray() }, + RowDimensions = new[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Content = new[] { bottomStatistics.Where(s => s.Result > HitResult.Perfect).ToArray() }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - } + new Dimension(GridSizeMode.AutoSize), } } } } - }, - new OsuSpriteText - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold), - Text = $"Played on {score.Date.ToLocalTime():d MMMM yyyy HH:mm}" } - }; + }); + + if (score.Date != default) + AddInternal(new PlayedOnText(score.Date)); if (score.Mods.Any()) { @@ -276,5 +270,16 @@ namespace osu.Game.Screens.Ranking.Expanded FinishTransforms(true); }); } + + public class PlayedOnText : OsuSpriteText + { + public PlayedOnText(DateTimeOffset time) + { + Anchor = Anchor.BottomCentre; + Origin = Anchor.BottomCentre; + Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold); + Text = $"Played on {time.ToLocalTime():d MMMM yyyy HH:mm}"; + } + } } } diff --git a/osu.Game/Skinning/DefaultSkin.cs b/osu.Game/Skinning/DefaultSkin.cs index 893819b2c2..d3adae5c8c 100644 --- a/osu.Game/Skinning/DefaultSkin.cs +++ b/osu.Game/Skinning/DefaultSkin.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Game.Audio; +using osu.Game.Beatmaps.Formats; using osu.Game.Extensions; using osu.Game.IO; using osu.Game.Screens.Play; @@ -136,10 +137,10 @@ namespace osu.Game.Skinning public override IBindable GetConfig(TLookup lookup) { + // todo: this code is pulled from LegacySkin and should not exist. + // will likely change based on how databased storage of skin configuration goes. switch (lookup) { - // todo: this code is pulled from LegacySkin and should not exist. - // will likely change based on how databased storage of skin configuration goes. case GlobalSkinColours global: switch (global) { @@ -148,9 +149,15 @@ namespace osu.Game.Skinning } break; + + case SkinComboColourLookup comboColour: + return SkinUtils.As(new Bindable(getComboColour(Configuration, comboColour.ColourIndex))); } return null; } + + private static Color4 getComboColour(IHasComboColours source, int colourIndex) + => source.ComboColours[colourIndex % source.ComboColours.Count]; } } diff --git a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs index 17eb88226d..0790faad34 100644 --- a/osu.Game/Skinning/Editor/SkinSelectionHandler.cs +++ b/osu.Game/Skinning/Editor/SkinSelectionHandler.cs @@ -170,6 +170,8 @@ namespace osu.Game.Skinning.Editor SelectionBox.CanRotate = true; SelectionBox.CanScaleX = true; SelectionBox.CanScaleY = true; + SelectionBox.CanFlipX = true; + SelectionBox.CanFlipY = true; SelectionBox.CanReverse = false; } diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index e255fbae81..b09620411b 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -16,6 +16,7 @@ using osu.Framework.IO.Stores; using osu.Game.Audio; using osu.Game.Beatmaps.Formats; using osu.Game.IO; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; @@ -129,6 +130,9 @@ namespace osu.Game.Skinning break; + case SkinComboColourLookup comboColour: + return SkinUtils.As(GetComboColour(Configuration, comboColour.ColourIndex, comboColour.Combo)); + case SkinCustomColourLookup customColour: return SkinUtils.As(getCustomColour(Configuration, customColour.Lookup.ToString())); @@ -286,6 +290,18 @@ namespace osu.Game.Skinning return null; } + /// + /// Retrieves the correct combo colour for a given colour index and information on the combo. + /// + /// The source to retrieve the combo colours from. + /// The preferred index for retrieving the combo colour with. + /// Information on the combo whose using the returned colour. + protected virtual IBindable GetComboColour(IHasComboColours source, int colourIndex, IHasComboInformation combo) + { + var colour = source.ComboColours?[colourIndex % source.ComboColours.Count]; + return colour.HasValue ? new Bindable(colour.Value) : null; + } + private IBindable getCustomColour(IHasCustomColours source, string lookup) => source.CustomColours.TryGetValue(lookup, out var col) ? new Bindable(col) : null; diff --git a/osu.Game/Skinning/SkinComboColourLookup.cs b/osu.Game/Skinning/SkinComboColourLookup.cs new file mode 100644 index 0000000000..33e35a96fb --- /dev/null +++ b/osu.Game/Skinning/SkinComboColourLookup.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Objects.Types; + +namespace osu.Game.Skinning +{ + public class SkinComboColourLookup + { + /// + /// The index to use for deciding the combo colour. + /// + public readonly int ColourIndex; + + /// + /// The combo information requesting the colour. + /// + public readonly IHasComboInformation Combo; + + public SkinComboColourLookup(int colourIndex, IHasComboInformation combo) + { + ColourIndex = colourIndex; + Combo = combo; + } + } +} diff --git a/osu.Game/Skinning/SkinProvidingContainer.cs b/osu.Game/Skinning/SkinProvidingContainer.cs index 7c26fdaf03..ada6e4b788 100644 --- a/osu.Game/Skinning/SkinProvidingContainer.cs +++ b/osu.Game/Skinning/SkinProvidingContainer.cs @@ -272,6 +272,7 @@ namespace osu.Game.Skinning switch (lookup) { case GlobalSkinColours _: + case SkinComboColourLookup _: case SkinCustomColourLookup _: if (provider.AllowColourLookup) return skin.GetConfig(lookup); diff --git a/osu.Game/Tests/TestScoreInfo.cs b/osu.Game/Tests/TestScoreInfo.cs index 9090a12d3f..8ce71ace69 100644 --- a/osu.Game/Tests/TestScoreInfo.cs +++ b/osu.Game/Tests/TestScoreInfo.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; @@ -13,7 +14,7 @@ namespace osu.Game.Tests { public class TestScoreInfo : ScoreInfo { - public TestScoreInfo(RulesetInfo ruleset) + public TestScoreInfo(RulesetInfo ruleset, bool excessMods = false) { User = new User { @@ -25,7 +26,10 @@ namespace osu.Game.Tests Beatmap = new TestBeatmap(ruleset).BeatmapInfo; Ruleset = ruleset; RulesetID = ruleset.ID ?? 0; - Mods = new Mod[] { new TestModHardRock(), new TestModDoubleTime() }; + + Mods = excessMods + ? ruleset.CreateInstance().GetAllMods().ToArray() + : new Mod[] { new TestModHardRock(), new TestModDoubleTime() }; TotalScore = 2845370; Accuracy = 0.95; diff --git a/osu.Game/Users/User.cs b/osu.Game/Users/User.cs index 2e04693e82..20c23153f0 100644 --- a/osu.Game/Users/User.cs +++ b/osu.Game/Users/User.cs @@ -138,11 +138,11 @@ namespace osu.Game.Users [JsonProperty(@"loved_beatmapset_count")] public int LovedBeatmapsetCount; - [JsonProperty(@"ranked_and_approved_beatmapset_count")] - public int RankedAndApprovedBeatmapsetCount; + [JsonProperty(@"ranked_beatmapset_count")] + public int RankedBeatmapsetCount; - [JsonProperty(@"unranked_beatmapset_count")] - public int UnrankedBeatmapsetCount; + [JsonProperty(@"pending_beatmapset_count")] + public int PendingBeatmapsetCount; [JsonProperty(@"scores_best_count")] public int ScoresBestCount; diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 152ba55e08..9825d29405 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -36,8 +36,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/osu.iOS.props b/osu.iOS.props index dc15df6ea6..3f81b36216 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -70,8 +70,8 @@ - - + + @@ -93,7 +93,7 @@ - + diff --git a/osu.sln.DotSettings b/osu.sln.DotSettings index 7284ca1a9a..139ee02b96 100644 --- a/osu.sln.DotSettings +++ b/osu.sln.DotSettings @@ -19,8 +19,8 @@ HINT DO_NOT_SHOW WARNING - WARNING - WARNING + HINT + HINT WARNING WARNING WARNING