diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 97fcb52ab1..007e4341b8 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -27,10 +27,10 @@ ] }, "ppy.localisationanalyser.tools": { - "version": "2021.705.0", + "version": "2021.725.0", "commands": [ "localisation" ] } } -} \ No newline at end of file +} diff --git a/.editorconfig b/.editorconfig index 19bd89c52f..3c4997c88d 100644 --- a/.editorconfig +++ b/.editorconfig @@ -190,3 +190,5 @@ dotnet_diagnostic.CA2225.severity = none # Banned APIs dotnet_diagnostic.RS0030.severity = error + +dotnet_diagnostic.OLOC001.license_header = // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.\n// See the LICENCE file in the repository root for full licence text. 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/CodeAnalysis/BannedSymbols.txt b/CodeAnalysis/BannedSymbols.txt index 46c50dbfa2..ea3e25142c 100644 --- a/CodeAnalysis/BannedSymbols.txt +++ b/CodeAnalysis/BannedSymbols.txt @@ -3,6 +3,7 @@ M:System.Object.Equals(System.Object)~System.Boolean;Don't use object.Equals. Us M:System.ValueType.Equals(System.Object)~System.Boolean;Don't use object.Equals(Fallbacks to ValueType). Use IEquatable or EqualityComparer.Default instead. M:System.Nullable`1.Equals(System.Object)~System.Boolean;Use == instead. T:System.IComparable;Don't use non-generic IComparable. Use generic version instead. +T:SixLabors.ImageSharp.IDeepCloneable`1;Use osu.Game.Utils.IDeepCloneable instead. M:osu.Framework.Graphics.Sprites.SpriteText.#ctor;Use OsuSpriteText. M:osu.Framework.Bindables.IBindableList`1.GetBoundCopy();Fails on iOS. Use manual ctor + BindTo instead. (see https://github.com/mono/mono/issues/19900) T:Microsoft.EntityFrameworkCore.Internal.EnumerableExtensions;Don't use internal extension methods. diff --git a/README.md b/README.md index e95c12cfdc..016bd7d922 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ We are accepting bug reports (please report with as much detail as possible and - Detailed release changelogs are available on the [official osu! site](https://osu.ppy.sh/home/changelog/lazer). - You can learn more about our approach to [project management](https://github.com/ppy/osu/wiki/Project-management). -- Read peppy's [latest blog post](https://blog.ppy.sh/a-definitive-lazer-faq/) exploring where the project is currently and the roadmap going forward. +- Read peppy's [blog post](https://blog.ppy.sh/a-definitive-lazer-faq/) exploring where the project is currently and the roadmap going forward. ## Running osu! diff --git a/osu.Android.props b/osu.Android.props index cd57d7478e..1866acd248 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,8 +51,8 @@ - - + + diff --git a/osu.Android/osu.Android.csproj b/osu.Android/osu.Android.csproj index 582c856a47..b2599535ae 100644 --- a/osu.Android/osu.Android.csproj +++ b/osu.Android/osu.Android.csproj @@ -64,7 +64,7 @@ - + \ No newline at end of file diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index 5fb09c0cef..dc712f2593 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.Length > 1 ? split[1] : string.Empty; + + 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.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj index 53a4e5edf5..89b9ffb94b 100644 --- a/osu.Desktop/osu.Desktop.csproj +++ b/osu.Desktop/osu.Desktop.csproj @@ -6,7 +6,7 @@ A free-to-win rhythm game. Rhythm is just a *click* away! osu! osu! - osu! + osu!(lazer) lazer.ico app.manifest 0.0.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/TestSceneJuiceStreamPlacementBlueprint.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamPlacementBlueprint.cs new file mode 100644 index 0000000000..cd1fa31b61 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneJuiceStreamPlacementBlueprint.cs @@ -0,0 +1,155 @@ +// 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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Utils; +using osu.Game.Rulesets.Catch.Edit.Blueprints; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.Objects.Drawables; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osuTK.Input; + +namespace osu.Game.Rulesets.Catch.Tests.Editor +{ + public class TestSceneJuiceStreamPlacementBlueprint : CatchPlacementBlueprintTestScene + { + private const double velocity = 0.5; + + private JuiceStream lastObject => LastObject?.HitObject as JuiceStream; + + [BackgroundDependencyLoader] + private void load() + { + Beatmap.Value.BeatmapInfo.BaseDifficulty.SliderTickRate = 5; + Beatmap.Value.BeatmapInfo.BaseDifficulty.SliderMultiplier = velocity * 10; + } + + [Test] + public void TestBasicPlacement() + { + double[] times = { 300, 800 }; + float[] positions = { 100, 200 }; + addPlacementSteps(times, positions); + + AddAssert("juice stream is placed", () => lastObject != null); + AddAssert("start time is correct", () => Precision.AlmostEquals(lastObject.StartTime, times[0])); + AddAssert("end time is correct", () => Precision.AlmostEquals(lastObject.EndTime, times[1])); + AddAssert("start position is correct", () => Precision.AlmostEquals(lastObject.OriginalX, positions[0])); + AddAssert("end position is correct", () => Precision.AlmostEquals(lastObject.EndX, positions[1])); + } + + [Test] + public void TestEmptyNotCommitted() + { + addMoveAndClickSteps(100, 100); + addMoveAndClickSteps(100, 100); + addMoveAndClickSteps(100, 100, true); + AddAssert("juice stream not placed", () => lastObject == null); + } + + [Test] + public void TestMultipleSegments() + { + double[] times = { 100, 300, 500, 700 }; + float[] positions = { 100, 150, 100, 100 }; + addPlacementSteps(times, positions); + + AddAssert("has 4 vertices", () => lastObject.Path.ControlPoints.Count == 4); + addPathCheckStep(times, positions); + } + + [Test] + public void TestVelocityLimit() + { + double[] times = { 100, 300 }; + float[] positions = { 200, 500 }; + addPlacementSteps(times, positions); + addPathCheckStep(times, new float[] { 200, 300 }); + } + + [Test] + public void TestPreviousVerticesAreFixed() + { + double[] times = { 100, 300, 500, 700 }; + float[] positions = { 200, 400, 100, 500 }; + addPlacementSteps(times, positions); + addPathCheckStep(times, new float[] { 200, 300, 200, 300 }); + } + + [Test] + public void TestClampedPositionIsRestored() + { + double[] times = { 100, 300, 500 }; + float[] positions = { 200, 200, 0, 250 }; + + addMoveAndClickSteps(times[0], positions[0]); + addMoveAndClickSteps(times[1], positions[1]); + AddMoveStep(times[2], positions[2]); + addMoveAndClickSteps(times[2], positions[3], true); + + addPathCheckStep(times, new float[] { 200, 200, 250 }); + } + + [Test] + public void TestFirstVertexIsFixed() + { + double[] times = { 100, 200 }; + float[] positions = { 100, 300 }; + addPlacementSteps(times, positions); + addPathCheckStep(times, new float[] { 100, 150 }); + } + + [Test] + public void TestOutOfOrder() + { + double[] times = { 100, 700, 500, 300 }; + float[] positions = { 100, 200, 150, 50 }; + addPlacementSteps(times, positions); + addPathCheckStep(times, positions); + } + + [Test] + public void TestMoveBeforeFirstVertex() + { + double[] times = { 300, 500, 100 }; + float[] positions = { 100, 100, 100 }; + addPlacementSteps(times, positions); + AddAssert("start time is correct", () => Precision.AlmostEquals(lastObject.StartTime, times[0])); + AddAssert("end time is correct", () => Precision.AlmostEquals(lastObject.EndTime, times[1], 1e-3)); + } + + protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableJuiceStream((JuiceStream)hitObject); + + protected override PlacementBlueprint CreateBlueprint() => new JuiceStreamPlacementBlueprint(); + + private void addMoveAndClickSteps(double time, float position, bool end = false) + { + AddMoveStep(time, position); + AddClickStep(end ? MouseButton.Right : MouseButton.Left); + } + + private void addPlacementSteps(double[] times, float[] positions) + { + for (int i = 0; i < times.Length; i++) + addMoveAndClickSteps(times[i], positions[i], i == times.Length - 1); + } + + private void addPathCheckStep(double[] times, float[] positions) => AddStep("assert path is correct", () => + Assert.That(getPositions(times), Is.EqualTo(positions).Within(Precision.FLOAT_EPSILON))); + + private float[] getPositions(IEnumerable times) + { + JuiceStream hitObject = lastObject.AsNonNull(); + return times + .Select(time => (time - hitObject.StartTime) * hitObject.Velocity) + .Select(distance => hitObject.EffectiveX + hitObject.Path.PositionAt(distance / hitObject.Distance).X) + .ToArray(); + } + } +} 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/JuiceStreamPathTest.cs b/osu.Game.Rulesets.Catch.Tests/JuiceStreamPathTest.cs new file mode 100644 index 0000000000..5e4b6d9e1a --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/JuiceStreamPathTest.cs @@ -0,0 +1,288 @@ +// 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 NUnit.Framework; +using osu.Framework.Utils; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Tests +{ + [TestFixture] + public class JuiceStreamPathTest + { + [TestCase(1e3, true, false)] + // When the coordinates are large, the slope invariant fails within the specified absolute allowance due to the floating-number precision. + [TestCase(1e9, false, false)] + // Using discrete values sometimes discover more edge cases. + [TestCase(10, true, true)] + public void TestRandomInsertSetPosition(double scale, bool checkSlope, bool integralValues) + { + var rng = new Random(1); + var path = new JuiceStreamPath(); + + for (int iteration = 0; iteration < 100000; iteration++) + { + if (rng.Next(10) == 0) + path.Clear(); + + int vertexCount = path.Vertices.Count; + + switch (rng.Next(2)) + { + case 0: + { + double distance = rng.NextDouble() * scale * 2 - scale; + if (integralValues) + distance = Math.Round(distance); + + float oldX = path.PositionAtDistance(distance); + int index = path.InsertVertex(distance); + Assert.That(path.Vertices.Count, Is.EqualTo(vertexCount + 1)); + Assert.That(path.Vertices[index].Distance, Is.EqualTo(distance)); + Assert.That(path.Vertices[index].X, Is.EqualTo(oldX)); + break; + } + + case 1: + { + int index = rng.Next(path.Vertices.Count); + double distance = path.Vertices[index].Distance; + float newX = (float)(rng.NextDouble() * scale * 2 - scale); + if (integralValues) + newX = MathF.Round(newX); + + path.SetVertexPosition(index, newX); + Assert.That(path.Vertices.Count, Is.EqualTo(vertexCount)); + Assert.That(path.Vertices[index].Distance, Is.EqualTo(distance)); + Assert.That(path.Vertices[index].X, Is.EqualTo(newX)); + break; + } + } + + assertInvariants(path.Vertices, checkSlope); + } + } + + [Test] + public void TestRemoveVertices() + { + var path = new JuiceStreamPath(); + path.Add(10, 5); + path.Add(20, -5); + + int removeCount = path.RemoveVertices((v, i) => v.Distance == 10 && i == 1); + Assert.That(removeCount, Is.EqualTo(1)); + Assert.That(path.Vertices, Is.EqualTo(new[] + { + new JuiceStreamPathVertex(0, 0), + new JuiceStreamPathVertex(20, -5) + })); + + removeCount = path.RemoveVertices((_, i) => i == 0); + Assert.That(removeCount, Is.EqualTo(1)); + Assert.That(path.Vertices, Is.EqualTo(new[] + { + new JuiceStreamPathVertex(20, -5) + })); + + removeCount = path.RemoveVertices((_, i) => true); + Assert.That(removeCount, Is.EqualTo(1)); + Assert.That(path.Vertices, Is.EqualTo(new[] + { + new JuiceStreamPathVertex() + })); + } + + [Test] + public void TestResampleVertices() + { + var path = new JuiceStreamPath(); + path.Add(-100, -10); + path.Add(100, 50); + path.ResampleVertices(new double[] + { + -50, + 0, + 70, + 120 + }); + Assert.That(path.Vertices, Is.EqualTo(new[] + { + new JuiceStreamPathVertex(-100, -10), + new JuiceStreamPathVertex(-50, -5), + new JuiceStreamPathVertex(0, 0), + new JuiceStreamPathVertex(70, 35), + new JuiceStreamPathVertex(100, 50), + new JuiceStreamPathVertex(100, 50), + })); + + path.Clear(); + path.SetVertexPosition(0, 10); + path.ResampleVertices(Array.Empty()); + Assert.That(path.Vertices, Is.EqualTo(new[] + { + new JuiceStreamPathVertex(0, 10) + })); + } + + [Test] + public void TestRandomConvertFromSliderPath() + { + var rng = new Random(1); + var path = new JuiceStreamPath(); + var sliderPath = new SliderPath(); + + for (int iteration = 0; iteration < 10000; iteration++) + { + sliderPath.ControlPoints.Clear(); + + do + { + int start = sliderPath.ControlPoints.Count; + + do + { + float x = (float)(rng.NextDouble() * 1e3); + float y = (float)(rng.NextDouble() * 1e3); + sliderPath.ControlPoints.Add(new PathControlPoint(new Vector2(x, y))); + } while (rng.Next(2) != 0); + + int length = sliderPath.ControlPoints.Count - start + 1; + sliderPath.ControlPoints[start].Type.Value = length <= 2 ? PathType.Linear : length == 3 ? PathType.PerfectCurve : PathType.Bezier; + } while (rng.Next(3) != 0); + + if (rng.Next(5) == 0) + sliderPath.ExpectedDistance.Value = rng.NextDouble() * 3e3; + else + sliderPath.ExpectedDistance.Value = null; + + path.ConvertFromSliderPath(sliderPath); + Assert.That(path.Vertices[0].Distance, Is.EqualTo(0)); + Assert.That(path.Distance, Is.EqualTo(sliderPath.Distance).Within(1e-3)); + assertInvariants(path.Vertices, true); + + double[] sampleDistances = Enumerable.Range(0, 10) + .Select(_ => rng.NextDouble() * sliderPath.Distance) + .ToArray(); + + foreach (double distance in sampleDistances) + { + float expected = sliderPath.PositionAt(distance / sliderPath.Distance).X; + Assert.That(path.PositionAtDistance(distance), Is.EqualTo(expected).Within(1e-3)); + } + + path.ResampleVertices(sampleDistances); + assertInvariants(path.Vertices, true); + + foreach (double distance in sampleDistances) + { + float expected = sliderPath.PositionAt(distance / sliderPath.Distance).X; + Assert.That(path.PositionAtDistance(distance), Is.EqualTo(expected).Within(1e-3)); + } + } + } + + [Test] + public void TestRandomConvertToSliderPath() + { + var rng = new Random(1); + var path = new JuiceStreamPath(); + var sliderPath = new SliderPath(); + + for (int iteration = 0; iteration < 10000; iteration++) + { + path.Clear(); + + do + { + double distance = rng.NextDouble() * 1e3; + float x = (float)(rng.NextDouble() * 1e3); + path.Add(distance, x); + } while (rng.Next(5) != 0); + + float sliderStartY = (float)(rng.NextDouble() * JuiceStreamPath.OSU_PLAYFIELD_HEIGHT); + + path.ConvertToSliderPath(sliderPath, sliderStartY); + Assert.That(sliderPath.Distance, Is.EqualTo(path.Distance).Within(1e-3)); + Assert.That(sliderPath.ControlPoints[0].Position.Value.X, Is.EqualTo(path.Vertices[0].X)); + assertInvariants(path.Vertices, true); + + foreach (var point in sliderPath.ControlPoints) + { + Assert.That(point.Type.Value, Is.EqualTo(PathType.Linear).Or.Null); + Assert.That(sliderStartY + point.Position.Value.Y, Is.InRange(0, JuiceStreamPath.OSU_PLAYFIELD_HEIGHT)); + } + + for (int i = 0; i < 10; i++) + { + double distance = rng.NextDouble() * path.Distance; + float expected = path.PositionAtDistance(distance); + Assert.That(sliderPath.PositionAt(distance / sliderPath.Distance).X, Is.EqualTo(expected).Within(1e-3)); + } + } + } + + [Test] + public void TestInvalidation() + { + var path = new JuiceStreamPath(); + Assert.That(path.InvalidationID, Is.EqualTo(1)); + int previousId = path.InvalidationID; + + path.InsertVertex(10); + checkNewId(); + + path.SetVertexPosition(1, 5); + checkNewId(); + + path.Add(20, 0); + checkNewId(); + + path.RemoveVertices((v, _) => v.Distance == 20); + checkNewId(); + + path.ResampleVertices(new double[] { 5, 10, 15 }); + checkNewId(); + + path.Clear(); + checkNewId(); + + path.ConvertFromSliderPath(new SliderPath()); + checkNewId(); + + void checkNewId() + { + Assert.That(path.InvalidationID, Is.Not.EqualTo(previousId)); + previousId = path.InvalidationID; + } + } + + private void assertInvariants(IReadOnlyList vertices, bool checkSlope) + { + Assert.That(vertices, Is.Not.Empty); + + for (int i = 0; i < vertices.Count; i++) + { + Assert.That(double.IsFinite(vertices[i].Distance)); + Assert.That(float.IsFinite(vertices[i].X)); + } + + for (int i = 1; i < vertices.Count; i++) + { + Assert.That(vertices[i].Distance, Is.GreaterThanOrEqualTo(vertices[i - 1].Distance)); + + if (!checkSlope) continue; + + float xDiff = Math.Abs(vertices[i].X - vertices[i - 1].X); + double distanceDiff = vertices[i].Distance - vertices[i - 1].Distance; + Assert.That(xDiff, Is.LessThanOrEqualTo(distanceDiff).Within(Precision.FLOAT_EPSILON)); + } + } + } +} diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchSkinConfiguration.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchSkinConfiguration.cs index ec186bcfb2..8ae2bcca0e 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 DroppedObjectContainer()) { Anchor = Anchor.Centre } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs index 8359657f84..540f02580f 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcher.cs @@ -31,23 +31,10 @@ namespace osu.Game.Rulesets.Catch.Tests [Resolved] private OsuConfigManager config { get; set; } - [Cached] - private readonly DroppedObjectContainer droppedObjectContainer; - - private readonly Container trailContainer; + private DroppedObjectContainer droppedObjectContainer; private TestCatcher catcher; - public TestSceneCatcher() - { - Add(trailContainer = new Container - { - Anchor = Anchor.Centre, - Depth = -1 - }); - Add(droppedObjectContainer = new DroppedObjectContainer()); - } - [SetUp] public void SetUp() => Schedule(() => { @@ -56,13 +43,17 @@ namespace osu.Game.Rulesets.Catch.Tests CircleSize = 0, }; - if (catcher != null) - Remove(catcher); + droppedObjectContainer = new DroppedObjectContainer(); - Add(catcher = new TestCatcher(trailContainer, difficulty) + Child = new Container { - Anchor = Anchor.Centre - }); + Anchor = Anchor.Centre, + Children = new Drawable[] + { + droppedObjectContainer, + catcher = new TestCatcher(droppedObjectContainer, difficulty), + } + }; }); [Test] @@ -299,8 +290,8 @@ namespace osu.Game.Rulesets.Catch.Tests { public IEnumerable CaughtObjects => this.ChildrenOfType(); - public TestCatcher(Container trailsTarget, BeatmapDifficulty difficulty) - : base(trailsTarget, difficulty) + public TestCatcher(DroppedObjectContainer droppedObjectTarget, BeatmapDifficulty difficulty) + : base(droppedObjectTarget, difficulty) { } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatcherArea.cs index 877e115e2f..a3307c9224 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,18 @@ 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(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..70b2c8c82a 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs @@ -47,7 +47,7 @@ namespace osu.Game.Rulesets.Catch.Tests } [Test] - public void TestCustomEndGlowColour() + public void TestCustomAfterImageColour() { var skin = new TestSkin { @@ -58,7 +58,7 @@ namespace osu.Game.Rulesets.Catch.Tests } [Test] - public void TestCustomEndGlowColourPriority() + public void TestCustomAfterImageColourPriority() { var skin = new TestSkin { @@ -111,38 +111,45 @@ namespace osu.Game.Rulesets.Catch.Tests checkHyperDashFruitColour(skin, skin.HyperDashColour); } - private void checkHyperDashCatcherColour(ISkin skin, Color4 expectedCatcherColour, Color4? expectedEndGlowColour = null) + private void checkHyperDashCatcherColour(ISkin skin, Color4 expectedCatcherColour, Color4? expectedAfterImageColour = null) { - CatcherArea catcherArea = null; CatcherTrailDisplay trails = null; + Catcher catcher = null; AddStep("create hyper-dashing catcher", () => { - Child = setupSkinHierarchy(catcherArea = new TestCatcherArea + CatcherArea catcherArea; + Child = setupSkinHierarchy(new Container { Anchor = Anchor.Centre, - Origin = Anchor.Centre + Child = catcherArea = new CatcherArea + { + Catcher = catcher = new Catcher(new DroppedObjectContainer()) + { + Scale = new Vector2(4) + } + } }, skin); + trails = catcherArea.ChildrenOfType().Single(); }); - AddStep("get trails container", () => + AddStep("start hyper-dash", () => { - trails = catcherArea.OfType().Single(); - catcherArea.MovableCatcher.SetHyperDashState(2); + 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)); + AddAssert("catcher after-image colours are correct", () => trails.HyperDashAfterImageColour == (expectedAfterImageColour ?? 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 +212,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/Beatmaps/CatchBeatmapConverter.cs b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs index 34964fc4ae..7774a7da09 100644 --- a/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs +++ b/osu.Game.Rulesets.Catch/Beatmaps/CatchBeatmapConverter.cs @@ -23,7 +23,8 @@ namespace osu.Game.Rulesets.Catch.Beatmaps protected override IEnumerable ConvertHitObject(HitObject obj, IBeatmap beatmap, CancellationToken cancellationToken) { - var positionData = obj as IHasXPosition; + var xPositionData = obj as IHasXPosition; + var yPositionData = obj as IHasYPosition; var comboData = obj as IHasCombo; switch (obj) @@ -36,10 +37,11 @@ namespace osu.Game.Rulesets.Catch.Beatmaps Path = curveData.Path, NodeSamples = curveData.NodeSamples, RepeatCount = curveData.RepeatCount, - X = positionData?.X ?? 0, + X = xPositionData?.X ?? 0, NewCombo = comboData?.NewCombo ?? false, ComboOffset = comboData?.ComboOffset ?? 0, - LegacyLastTickOffset = (obj as IHasLegacyLastTickOffset)?.LegacyLastTickOffset ?? 0 + LegacyLastTickOffset = (obj as IHasLegacyLastTickOffset)?.LegacyLastTickOffset ?? 0, + LegacyConvertedY = yPositionData?.Y ?? CatchHitObject.DEFAULT_LEGACY_CONVERT_Y }.Yield(); case IHasDuration endTime: @@ -59,7 +61,8 @@ namespace osu.Game.Rulesets.Catch.Beatmaps Samples = obj.Samples, NewCombo = comboData?.NewCombo ?? false, ComboOffset = comboData?.ComboOffset ?? 0, - X = positionData?.X ?? 0 + X = xPositionData?.X ?? 0, + LegacyConvertedY = yPositionData?.Y ?? CatchHitObject.DEFAULT_LEGACY_CONVERT_Y }.Yield(); } } diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs index 76863acc78..eafa1b9b9d 100644 --- a/osu.Game.Rulesets.Catch/CatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs @@ -130,7 +130,8 @@ namespace osu.Game.Rulesets.Catch return new Mod[] { new MultiMod(new ModWindUp(), new ModWindDown()), - new CatchModFloatingFruits() + new CatchModFloatingFruits(), + new CatchModMuted(), }; default: diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs index fdd6ac0857..439890dac2 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchPerformanceCalculator.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.Extensions; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; @@ -33,11 +32,11 @@ namespace osu.Game.Rulesets.Catch.Difficulty { mods = Score.Mods; - fruitsHit = Score.Statistics.GetOrDefault(HitResult.Great); - ticksHit = Score.Statistics.GetOrDefault(HitResult.LargeTickHit); - tinyTicksHit = Score.Statistics.GetOrDefault(HitResult.SmallTickHit); - tinyTicksMissed = Score.Statistics.GetOrDefault(HitResult.SmallTickMiss); - misses = Score.Statistics.GetOrDefault(HitResult.Miss); + fruitsHit = Score.Statistics.GetValueOrDefault(HitResult.Great); + ticksHit = Score.Statistics.GetValueOrDefault(HitResult.LargeTickHit); + tinyTicksHit = Score.Statistics.GetValueOrDefault(HitResult.SmallTickHit); + tinyTicksMissed = Score.Statistics.GetValueOrDefault(HitResult.SmallTickMiss); + misses = Score.Statistics.GetValueOrDefault(HitResult.Miss); // We are heavily relying on aim in catch the beat double value = Math.Pow(5.0 * Math.Max(1.0, Attributes.StarRating / 0.0049) - 4.0, 2.0) / 100000.0; diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchSelectionBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchSelectionBlueprint.cs index 720d730858..7e566c810c 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/CatchSelectionBlueprint.cs @@ -19,9 +19,8 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints { get { - float x = HitObject.OriginalX; - float y = HitObjectContainer.PositionAtTime(HitObject.StartTime); - return HitObjectContainer.ToScreenSpace(new Vector2(x, y + HitObjectContainer.DrawHeight)); + Vector2 position = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject); + return HitObjectContainer.ToScreenSpace(position + new Vector2(0, HitObjectContainer.DrawHeight)); } } 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/FruitOutline.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/FruitOutline.cs index 345b59bdcd..0c03068e26 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/FruitOutline.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/FruitOutline.cs @@ -1,14 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Skinning.Default; -using osu.Game.Rulesets.UI.Scrolling; using osuTK; namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components @@ -28,10 +26,8 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components Colour = osuColour.Yellow; } - public void UpdateFrom(ScrollingHitObjectContainer hitObjectContainer, CatchHitObject hitObject, [CanBeNull] CatchHitObject parent = null) + public void UpdateFrom(CatchHitObject hitObject) { - X = hitObject.EffectiveX - (parent?.OriginalX ?? 0); - Y = hitObjectContainer.PositionAtTime(hitObject.StartTime, parent?.StartTime ?? hitObjectContainer.Time.Current); Scale = new Vector2(hitObject.Scale); } } diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/NestedOutlineContainer.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/NestedOutlineContainer.cs index 48d90e8b24..cf916b27a4 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/NestedOutlineContainer.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/NestedOutlineContainer.cs @@ -20,12 +20,6 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components Anchor = Anchor.BottomLeft; } - public void UpdatePositionFrom(ScrollingHitObjectContainer hitObjectContainer, CatchHitObject parentHitObject) - { - X = parentHitObject.OriginalX; - Y = hitObjectContainer.PositionAtTime(parentHitObject.StartTime); - } - public void UpdateNestedObjectsFrom(ScrollingHitObjectContainer hitObjectContainer, CatchHitObject parentHitObject) { nestedHitObjects.Clear(); @@ -43,7 +37,8 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components { var hitObject = nestedHitObjects[i]; var outline = (FruitOutline)InternalChildren[i]; - outline.UpdateFrom(hitObjectContainer, hitObject, parentHitObject); + outline.Position = CatchHitObjectUtils.GetStartPosition(hitObjectContainer, hitObject) - Position; + outline.UpdateFrom(hitObject); outline.Scale *= hitObject is Droplet ? 0.5f : 1; } } diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/PlacementEditablePath.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/PlacementEditablePath.cs new file mode 100644 index 0000000000..158872fbab --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/PlacementEditablePath.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; +using osu.Game.Rulesets.Catch.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components +{ + public class PlacementEditablePath : EditablePath + { + /// + /// The original position of the last added vertex. + /// This is not same as the last vertex of the current path because the vertex ordering can change. + /// + private JuiceStreamPathVertex lastVertex; + + public PlacementEditablePath(Func positionToDistance) + : base(positionToDistance) + { + } + + public void AddNewVertex() + { + var endVertex = Vertices[^1]; + int index = AddVertex(endVertex.Distance, endVertex.X); + + for (int i = 0; i < VertexCount; i++) + { + VertexStates[i].IsSelected = i == index; + VertexStates[i].IsFixed = i != index; + VertexStates[i].VertexBeforeChange = Vertices[i]; + } + + lastVertex = Vertices[index]; + } + + /// + /// Move the vertex added by in the last time. + /// + public void MoveLastVertex(Vector2 screenSpacePosition) + { + Vector2 position = ToRelativePosition(screenSpacePosition); + double distanceDelta = PositionToDistance(position.Y) - lastVertex.Distance; + float xDelta = position.X - lastVertex.X; + MoveSelectedVertices(distanceDelta, xDelta); + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/ScrollingPath.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/ScrollingPath.cs index 96111beda4..109bf61ea5 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/ScrollingPath.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/Components/ScrollingPath.cs @@ -33,12 +33,6 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components }; } - public void UpdatePositionFrom(ScrollingHitObjectContainer hitObjectContainer, CatchHitObject hitObject) - { - X = hitObject.OriginalX; - Y = hitObjectContainer.PositionAtTime(hitObject.StartTime); - } - public void UpdatePathFrom(ScrollingHitObjectContainer hitObjectContainer, JuiceStream hitObject) { double distanceToYFactor = -hitObjectContainer.LengthAtTime(hitObject.StartTime, hitObject.StartTime + 1 / hitObject.Velocity); 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/FruitPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitPlacementBlueprint.cs index 0f28cf6786..e169e3b75c 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitPlacementBlueprint.cs @@ -29,7 +29,8 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints { base.Update(); - outline.UpdateFrom(HitObjectContainer, HitObject); + outline.Position = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject); + outline.UpdateFrom(HitObject); } protected override bool OnMouseDown(MouseDownEvent e) diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitSelectionBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitSelectionBlueprint.cs index 9665aac2fb..150297badb 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/FruitSelectionBlueprint.cs @@ -20,8 +20,10 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints { base.Update(); - if (IsSelected) - outline.UpdateFrom(HitObjectContainer, HitObject); + if (!IsSelected) return; + + outline.Position = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject); + outline.UpdateFrom(HitObject); } } } diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs new file mode 100644 index 0000000000..cff5bc2417 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs @@ -0,0 +1,128 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Input; +using osu.Framework.Input.Events; +using osu.Game.Rulesets.Catch.Edit.Blueprints.Components; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Edit; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Catch.Edit.Blueprints +{ + public class JuiceStreamPlacementBlueprint : CatchPlacementBlueprint + { + private readonly ScrollingPath scrollingPath; + + private readonly NestedOutlineContainer nestedOutlineContainer; + + private readonly PlacementEditablePath editablePath; + + private int lastEditablePathId = -1; + + private InputManager inputManager; + + public JuiceStreamPlacementBlueprint() + { + InternalChildren = new Drawable[] + { + scrollingPath = new ScrollingPath(), + nestedOutlineContainer = new NestedOutlineContainer(), + editablePath = new PlacementEditablePath(positionToDistance) + }; + } + + protected override void Update() + { + base.Update(); + + if (PlacementActive == PlacementState.Active) + editablePath.UpdateFrom(HitObjectContainer, HitObject); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + inputManager = GetContainingInputManager(); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + switch (PlacementActive) + { + case PlacementState.Waiting: + if (e.Button != MouseButton.Left) break; + + editablePath.AddNewVertex(); + BeginPlacement(true); + return true; + + case PlacementState.Active: + switch (e.Button) + { + case MouseButton.Left: + editablePath.AddNewVertex(); + return true; + + case MouseButton.Right: + EndPlacement(HitObject.Duration > 0); + return true; + } + + break; + } + + return base.OnMouseDown(e); + } + + public override void UpdateTimeAndPosition(SnapResult result) + { + switch (PlacementActive) + { + case PlacementState.Waiting: + if (!(result.Time is double snappedTime)) return; + + HitObject.OriginalX = ToLocalSpace(result.ScreenSpacePosition).X; + HitObject.StartTime = snappedTime; + break; + + case PlacementState.Active: + Vector2 unsnappedPosition = inputManager.CurrentState.Mouse.Position; + editablePath.MoveLastVertex(unsnappedPosition); + break; + + default: + return; + } + + // Make sure the up-to-date position is used for outlines. + Vector2 startPosition = CatchHitObjectUtils.GetStartPosition(HitObjectContainer, HitObject); + editablePath.Position = nestedOutlineContainer.Position = scrollingPath.Position = startPosition; + + updateHitObjectFromPath(); + } + + private void updateHitObjectFromPath() + { + if (lastEditablePathId == editablePath.PathId) + return; + + editablePath.UpdateHitObjectFromPath(HitObject); + ApplyDefaultsToHitObject(); + + scrollingPath.UpdatePathFrom(HitObjectContainer, HitObject); + nestedOutlineContainer.UpdateNestedObjectsFrom(HitObjectContainer, HitObject); + + lastEditablePathId = editablePath.PathId; + } + + private double positionToDistance(float relativeYPosition) + { + double time = HitObjectContainer.TimeAtPosition(relativeYPosition, HitObject.StartTime); + return (time - HitObject.StartTime) * HitObject.Velocity; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamSelectionBlueprint.cs index bf7b962e0a..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,8 +79,13 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints if (!IsSelected) return; - scrollingPath.UpdatePositionFrom(HitObjectContainer, HitObject); - nestedOutlineContainer.UpdatePositionFrom(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; @@ -60,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() @@ -82,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/CatchHitObjectComposer.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs index d360274aa6..050c2f625d 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs @@ -38,6 +38,7 @@ namespace osu.Game.Rulesets.Catch.Edit protected override IReadOnlyList CompositionTools => new HitObjectCompositionTool[] { new FruitCompositionTool(), + new JuiceStreamCompositionTool(), new BananaShowerCompositionTool() }; diff --git a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectUtils.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectUtils.cs new file mode 100644 index 0000000000..b059926668 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectUtils.cs @@ -0,0 +1,63 @@ +// 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; + +namespace osu.Game.Rulesets.Catch.Edit +{ + /// + /// Utility functions used by the editor. + /// + public static class CatchHitObjectUtils + { + /// + /// Get the position of the hit object in the playfield based on and . + /// + public static Vector2 GetStartPosition(ScrollingHitObjectContainer hitObjectContainer, CatchHitObject hitObject) + { + 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/JuiceStreamCompositionTool.cs b/osu.Game.Rulesets.Catch/Edit/JuiceStreamCompositionTool.cs new file mode 100644 index 0000000000..cb66e2952e --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/JuiceStreamCompositionTool.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Catch.Edit.Blueprints; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Tools; + +namespace osu.Game.Rulesets.Catch.Edit +{ + public class JuiceStreamCompositionTool : HitObjectCompositionTool + { + public JuiceStreamCompositionTool() + : base(nameof(JuiceStream)) + { + } + + public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders); + + public override PlacementBlueprint CreatePlacementBlueprint() => new JuiceStreamPlacementBlueprint(); + } +} 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/Mods/CatchModMuted.cs b/osu.Game.Rulesets.Catch/Mods/CatchModMuted.cs new file mode 100644 index 0000000000..6d2565440a --- /dev/null +++ b/osu.Game.Rulesets.Catch/Mods/CatchModMuted.cs @@ -0,0 +1,12 @@ +// 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; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Catch.Mods +{ + public class CatchModMuted : ModMuted + { + } +} 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/CatchHitObject.cs b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs index 0b8c0e28a7..d43e6f1c8b 100644 --- a/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs +++ b/osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs @@ -9,10 +9,11 @@ using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; +using osuTK; namespace osu.Game.Rulesets.Catch.Objects { - public abstract class CatchHitObject : HitObject, IHasXPosition, IHasComboInformation + public abstract class CatchHitObject : HitObject, IHasPosition, IHasComboInformation { public const float OBJECT_RADIUS = 64; @@ -31,8 +32,6 @@ namespace osu.Game.Rulesets.Catch.Objects set => OriginalXBindable.Value = value; } - float IHasXPosition.X => OriginalXBindable.Value; - public readonly Bindable XOffsetBindable = new Bindable(); /// @@ -96,6 +95,14 @@ namespace osu.Game.Rulesets.Catch.Objects set => ComboIndexBindable.Value = value; } + public Bindable ComboIndexWithOffsetsBindable { get; } = new Bindable(); + + public int ComboIndexWithOffsets + { + get => ComboIndexWithOffsetsBindable.Value; + set => ComboIndexWithOffsetsBindable.Value = value; + } + public Bindable LastInComboBindable { get; } = new Bindable(); /// @@ -131,5 +138,24 @@ namespace osu.Game.Rulesets.Catch.Objects } protected override HitWindows CreateHitWindows() => HitWindows.Empty; + + #region Hit object conversion + + // The half of the height of the osu! playfield. + public const float DEFAULT_LEGACY_CONVERT_Y = 192; + + /// + /// The Y position of the hit object is not used in the normal osu!catch gameplay. + /// It is preserved to maximize the backward compatibility with the legacy editor, in which the mappers use the Y position to organize the patterns. + /// + public float LegacyConvertedY { get; set; } = DEFAULT_LEGACY_CONVERT_Y; + + float IHasXPosition.X => OriginalX; + + float IHasYPosition.Y => LegacyConvertedY; + + Vector2 IHasPosition.Position => new Vector2(OriginalX, LegacyConvertedY); + + #endregion } } diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStreamPath.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStreamPath.cs new file mode 100644 index 0000000000..f1cdb39e91 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/JuiceStreamPath.cs @@ -0,0 +1,340 @@ +// 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.Utils; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osuTK; + +#nullable enable + +namespace osu.Game.Rulesets.Catch.Objects +{ + /// + /// Represents the path of a juice stream. + /// + /// A holds a legacy as the representation of the path. + /// However, the representation is difficult to work with. + /// This represents the path in a more convenient way, a polyline connecting list of s. + /// + /// + /// The path can be regarded as a function from the closed interval [Vertices[0].Distance, Vertices[^1].Distance] to the x position, given by . + /// To ensure the path is convertible to a , the slope of the function must not be more than 1 everywhere, + /// and this slope condition is always maintained as an invariant. + /// + /// + public class JuiceStreamPath + { + /// + /// The height of legacy osu!standard playfield. + /// The sliders converted by are vertically contained in this height. + /// + internal const float OSU_PLAYFIELD_HEIGHT = 384; + + /// + /// The list of vertices of the path, which is represented as a polyline connecting the vertices. + /// + public IReadOnlyList Vertices => vertices; + + /// + /// The current version number. + /// This starts from 1 and incremented whenever this is modified. + /// + public int InvalidationID { get; private set; } = 1; + + /// + /// The difference between first vertex's and last vertex's . + /// + public double Distance => vertices[^1].Distance - vertices[0].Distance; + + /// + /// This list should always be non-empty. + /// + private readonly List vertices = new List + { + new JuiceStreamPathVertex() + }; + + /// + /// Compute the x-position of the path at the given . + /// + /// + /// When the given distance is outside of the path, the x position at the corresponding endpoint is returned, + /// + public float PositionAtDistance(double distance) + { + int index = vertexIndexAtDistance(distance); + return positionAtDistance(distance, index); + } + + /// + /// Remove all vertices of this path, then add a new vertex (0, 0). + /// + public void Clear() + { + vertices.Clear(); + vertices.Add(new JuiceStreamPathVertex()); + invalidate(); + } + + /// + /// Insert a vertex at given . + /// The is used as the position of the new vertex. + /// Thus, the set of points of the path is not changed (up to floating-point precision). + /// + /// The index of the new vertex. + public int InsertVertex(double distance) + { + if (!double.IsFinite(distance)) + throw new ArgumentOutOfRangeException(nameof(distance)); + + int index = vertexIndexAtDistance(distance); + float x = positionAtDistance(distance, index); + vertices.Insert(index, new JuiceStreamPathVertex(distance, x)); + + invalidate(); + return index; + } + + /// + /// Move the vertex of given to the given position . + /// When the distances between vertices are too small for the new vertex positions, the adjacent vertices are moved towards . + /// + public void SetVertexPosition(int index, float newX) + { + if (index < 0 || index >= vertices.Count) + throw new ArgumentOutOfRangeException(nameof(index)); + + if (!float.IsFinite(newX)) + throw new ArgumentOutOfRangeException(nameof(newX)); + + var newVertex = new JuiceStreamPathVertex(vertices[index].Distance, newX); + + for (int i = index - 1; i >= 0 && !canConnect(vertices[i], newVertex); i--) + { + float clampedX = clampToConnectablePosition(newVertex, vertices[i]); + vertices[i] = new JuiceStreamPathVertex(vertices[i].Distance, clampedX); + } + + for (int i = index + 1; i < vertices.Count; i++) + { + float clampedX = clampToConnectablePosition(newVertex, vertices[i]); + vertices[i] = new JuiceStreamPathVertex(vertices[i].Distance, clampedX); + } + + vertices[index] = newVertex; + + invalidate(); + } + + /// + /// Add a new vertex at given and position. + /// Adjacent vertices are moved when necessary in the same way as . + /// + public void Add(double distance, float x) + { + int index = InsertVertex(distance); + SetVertexPosition(index, x); + } + + /// + /// Remove all vertices that satisfy the given . + /// + /// + /// If all vertices are removed, a new vertex (0, 0) is added. + /// + /// The predicate to determine whether a vertex should be removed given the vertex and its index in the path. + /// The number of removed vertices. + public int RemoveVertices(Func predicate) + { + int index = 0; + int removeCount = vertices.RemoveAll(vertex => predicate(vertex, index++)); + + if (vertices.Count == 0) + vertices.Add(new JuiceStreamPathVertex()); + + if (removeCount != 0) + invalidate(); + + return removeCount; + } + + /// + /// Recreate this path by using difference set of vertices at given distances. + /// In addition to the given , the first vertex and the last vertex are always added to the new path. + /// New vertices use the positions on the original path. Thus, s at are preserved. + /// + public void ResampleVertices(IEnumerable sampleDistances) + { + var sampledVertices = new List(); + + foreach (double distance in sampleDistances) + { + if (!double.IsFinite(distance)) + throw new ArgumentOutOfRangeException(nameof(sampleDistances)); + + double clampedDistance = Math.Clamp(distance, vertices[0].Distance, vertices[^1].Distance); + float x = PositionAtDistance(clampedDistance); + sampledVertices.Add(new JuiceStreamPathVertex(clampedDistance, x)); + } + + sampledVertices.Sort(); + + // The first vertex and the last vertex are always used in the result. + vertices.RemoveRange(1, vertices.Count - (vertices.Count == 1 ? 1 : 2)); + vertices.InsertRange(1, sampledVertices); + + invalidate(); + } + + /// + /// Convert a to list of vertices and write the result to this . + /// + /// + /// Duplicated vertices are automatically removed. + /// + public void ConvertFromSliderPath(SliderPath sliderPath) + { + var sliderPathVertices = new List(); + sliderPath.GetPathToProgress(sliderPathVertices, 0, 1); + + double distance = 0; + + vertices.Clear(); + vertices.Add(new JuiceStreamPathVertex(0, sliderPathVertices.FirstOrDefault().X)); + + for (int i = 1; i < sliderPathVertices.Count; i++) + { + distance += Vector2.Distance(sliderPathVertices[i - 1], sliderPathVertices[i]); + + if (!Precision.AlmostEquals(vertices[^1].Distance, distance)) + vertices.Add(new JuiceStreamPathVertex(distance, sliderPathVertices[i].X)); + } + + invalidate(); + } + + /// + /// Convert the path of this to a and write the result to . + /// The resulting slider is "folded" to make it vertically contained in the playfield `(0..)` assuming the slider start position is . + /// + public void ConvertToSliderPath(SliderPath sliderPath, float sliderStartY) + { + const float margin = 1; + + // Note: these two variables and `sliderPath` are modified by the local functions. + double currentDistance = 0; + Vector2 lastPosition = new Vector2(vertices[0].X, 0); + + sliderPath.ControlPoints.Clear(); + sliderPath.ControlPoints.Add(new PathControlPoint(lastPosition)); + + for (int i = 1; i < vertices.Count; i++) + { + sliderPath.ControlPoints[^1].Type.Value = PathType.Linear; + + float deltaX = vertices[i].X - lastPosition.X; + double length = vertices[i].Distance - currentDistance; + + // Should satisfy `deltaX^2 + deltaY^2 = length^2`. + // By invariants, the expression inside the `sqrt` is (almost) non-negative. + double deltaY = Math.Sqrt(Math.Max(0, length * length - (double)deltaX * deltaX)); + + // When `deltaY` is small, one segment is always enough. + // This case is handled separately to prevent divide-by-zero. + if (deltaY <= OSU_PLAYFIELD_HEIGHT / 2 - margin) + { + float nextX = vertices[i].X; + float nextY = (float)(lastPosition.Y + getYDirection() * deltaY); + addControlPoint(nextX, nextY); + continue; + } + + // When `deltaY` is large or when the slider velocity is fast, the segment must be partitioned to subsegments to stay in bounds. + for (double currentProgress = 0; currentProgress < deltaY;) + { + double nextProgress = Math.Min(currentProgress + getMaxDeltaY(), deltaY); + float nextX = (float)(vertices[i - 1].X + nextProgress / deltaY * deltaX); + float nextY = (float)(lastPosition.Y + getYDirection() * (nextProgress - currentProgress)); + addControlPoint(nextX, nextY); + currentProgress = nextProgress; + } + } + + int getYDirection() + { + float lastSliderY = sliderStartY + lastPosition.Y; + return lastSliderY < OSU_PLAYFIELD_HEIGHT / 2 ? 1 : -1; + } + + float getMaxDeltaY() + { + float lastSliderY = sliderStartY + lastPosition.Y; + return Math.Max(lastSliderY, OSU_PLAYFIELD_HEIGHT - lastSliderY) - margin; + } + + void addControlPoint(float nextX, float nextY) + { + Vector2 nextPosition = new Vector2(nextX, nextY); + sliderPath.ControlPoints.Add(new PathControlPoint(nextPosition)); + currentDistance += Vector2.Distance(lastPosition, nextPosition); + lastPosition = nextPosition; + } + } + + /// + /// Find the index at which a new vertex with can be inserted. + /// + private int vertexIndexAtDistance(double distance) + { + // The position of `(distance, Infinity)` is uniquely determined because infinite positions are not allowed. + int i = vertices.BinarySearch(new JuiceStreamPathVertex(distance, float.PositiveInfinity)); + return i < 0 ? ~i : i; + } + + /// + /// Compute the position at the given , assuming is the vertex index returned by . + /// + private float positionAtDistance(double distance, int index) + { + if (index <= 0) + return vertices[0].X; + if (index >= vertices.Count) + return vertices[^1].X; + + double length = vertices[index].Distance - vertices[index - 1].Distance; + if (Precision.AlmostEquals(length, 0)) + return vertices[index].X; + + float deltaX = vertices[index].X - vertices[index - 1].X; + + return (float)(vertices[index - 1].X + deltaX * ((distance - vertices[index - 1].Distance) / length)); + } + + /// + /// Check the two vertices can connected directly while satisfying the slope condition. + /// + private bool canConnect(JuiceStreamPathVertex vertex1, JuiceStreamPathVertex vertex2, float allowance = 0) + { + double xDistance = Math.Abs((double)vertex2.X - vertex1.X); + float length = (float)Math.Abs(vertex2.Distance - vertex1.Distance); + return xDistance <= length + allowance; + } + + /// + /// Move the position of towards the position of + /// until the vertex pair satisfies the condition . + /// + /// The resulting position of . + private float clampToConnectablePosition(JuiceStreamPathVertex fixedVertex, JuiceStreamPathVertex movableVertex) + { + float length = (float)Math.Abs(movableVertex.Distance - fixedVertex.Distance); + return Math.Clamp(movableVertex.X, fixedVertex.X - length, fixedVertex.X + length); + } + + private void invalidate() => InvalidationID++; + } +} diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStreamPathVertex.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStreamPathVertex.cs new file mode 100644 index 0000000000..58c50603c4 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Objects/JuiceStreamPathVertex.cs @@ -0,0 +1,33 @@ +// 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.Objects +{ + /// + /// A vertex of a . + /// + public readonly struct JuiceStreamPathVertex : IComparable + { + public readonly double Distance; + + public readonly float X; + + public JuiceStreamPathVertex(double distance, float x) + { + Distance = distance; + X = x; + } + + public int CompareTo(JuiceStreamPathVertex other) + { + int c = Distance.CompareTo(other.Distance); + return c != 0 ? c : X.CompareTo(other.X); + } + + public override string ToString() => $"({Distance}, {X})"; + } +} 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..1e20643a08 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs @@ -26,38 +26,47 @@ 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 droppedObjectContainer = new DroppedObjectContainer(); + + Catcher = new Catcher(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, + }, + HitObjectContainer, + }); + RegisterPool(50); RegisterPool(50); RegisterPool(100); @@ -80,7 +89,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..9fd4610e6e 100644 --- a/osu.Game.Rulesets.Catch/UI/Catcher.cs +++ b/osu.Game.Rulesets.Catch/UI/Catcher.cs @@ -26,8 +26,17 @@ namespace osu.Game.Rulesets.Catch.UI public class Catcher : SkinReloadableDrawable { /// - /// 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. + /// 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 after-image during a hyper-dash. /// public static readonly Color4 DEFAULT_HYPER_DASH_COLOUR = Color4.Red; @@ -61,11 +70,6 @@ namespace osu.Game.Rulesets.Catch.UI /// private const float caught_fruit_scale_adjust = 0.5f; - [NotNull] - private readonly Container trailsTarget; - - private CatcherTrailDisplay trails; - /// /// Contains caught objects on the plate. /// @@ -74,40 +78,26 @@ 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 { - get => Body.AnimationState.Value; - private set => Body.AnimationState.Value = value; + get => body.AnimationState.Value; + private set => body.AnimationState.Value = value; } /// - /// The width of the catcher which can receive fruit. Equivalent to "catchMargin" in osu-stable. + /// Whether the catcher is currently dashing. /// - public const float ALLOWED_CATCH_RANGE = 0.8f; - - private bool dashing; - - public bool Dashing - { - get => dashing; - set - { - if (value == dashing) return; - - dashing = value; - - updateTrailVisibility(); - } - } + public bool Dashing { get; set; } /// /// The currently facing direction. /// public Direction VisualDirection { get; set; } = Direction.Right; + public Vector2 BodyScale => Scale * body.Scale; + /// /// Whether the contents of the catcher plate should be visually flipped when the catcher direction is changed. /// @@ -118,10 +108,9 @@ namespace osu.Game.Rulesets.Catch.UI /// private readonly float catchWidth; - internal readonly SkinnableCatcher Body; + private readonly SkinnableCatcher body; private Color4 hyperDashColour = DEFAULT_HYPER_DASH_COLOUR; - private Color4 hyperDashEndGlowColour = DEFAULT_HYPER_DASH_COLOUR; private double hyperDashModifier = 1; private int hyperDashDirection; @@ -134,13 +123,13 @@ 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] 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); @@ -159,7 +148,7 @@ namespace osu.Game.Rulesets.Catch.UI // offset fruit vertically to better place "above" the plate. Y = -5 }, - Body = new SkinnableCatcher(), + body = new SkinnableCatcher(), hitExplosionContainer = new HitExplosionContainer { Anchor = Anchor.TopCentre, @@ -172,15 +161,6 @@ namespace osu.Game.Rulesets.Catch.UI private void load(OsuConfigManager config) { hitLighting = config.GetBindable(OsuSetting.HitLighting); - trails = new CatcherTrailDisplay(this); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - // don't add in above load as we may potentially modify a parent in an unsafe manner. - trailsTarget.Add(trails); } /// @@ -197,7 +177,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. @@ -213,14 +193,9 @@ namespace osu.Game.Rulesets.Catch.UI if (!(hitObject is PalpableCatchHitObject fruit)) return false; - var halfCatchWidth = catchWidth * 0.5f; - - // this stuff wil disappear once we move fruit to non-relative coordinate space in the future. - var catchObjectPosition = fruit.EffectiveX; - var catcherPosition = Position.X; - - return catchObjectPosition >= catcherPosition - halfCatchWidth && - catchObjectPosition <= catcherPosition + halfCatchWidth; + float halfCatchWidth = catchWidth * 0.5f; + return fruit.EffectiveX >= X - halfCatchWidth && + fruit.EffectiveX <= X + halfCatchWidth; } public void OnNewResult(DrawableCatchHitObject drawableObject, JudgementResult result) @@ -307,10 +282,7 @@ namespace osu.Game.Rulesets.Catch.UI hyperDashTargetPosition = targetPosition; if (!wasHyperDashing) - { - trails.DisplayEndGlow(); runHyperDashStateTransition(true); - } } } @@ -326,13 +298,9 @@ namespace osu.Game.Rulesets.Catch.UI private void runHyperDashStateTransition(bool hyperDashing) { - updateTrailVisibility(); - this.FadeColour(hyperDashing ? hyperDashColour : Color4.White, HYPER_DASH_TRANSITION_DURATION, Easing.OutQuint); } - private void updateTrailVisibility() => trails.DisplayTrail = Dashing || HyperDashing; - protected override void SkinChanged(ISkinSource skin) { base.SkinChanged(skin); @@ -341,13 +309,6 @@ namespace osu.Game.Rulesets.Catch.UI skin.GetConfig(CatchSkinColour.HyperDash)?.Value ?? DEFAULT_HYPER_DASH_COLOUR; - hyperDashEndGlowColour = - skin.GetConfig(CatchSkinColour.HyperDashAfterImage)?.Value ?? - hyperDashColour; - - trails.HyperDashTrailsColour = hyperDashColour; - trails.EndGlowSpritesColour = hyperDashEndGlowColour; - flipCatcherPlate = skin.GetConfig(CatchSkinConfiguration.FlipCatcherPlate)?.Value ?? true; runHyperDashStateTransition(HyperDashing); @@ -358,7 +319,7 @@ namespace osu.Game.Rulesets.Catch.UI base.Update(); var scaleFromDirection = new Vector2((int)VisualDirection, 1); - Body.Scale = scaleFromDirection; + body.Scale = scaleFromDirection; caughtObjectContainer.Scale = hitExplosionContainer.Scale = flipCatcherPlate ? scaleFromDirection : Vector2.One; // Correct overshooting. diff --git a/osu.Game.Rulesets.Catch/UI/CatcherArea.cs b/osu.Game.Rulesets.Catch/UI/CatcherArea.cs index fea314df8d..b30c3d82a4 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,27 @@ 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 => catcherContainer.Child = catcher = value; + } + + private readonly Container catcherContainer; - public readonly Catcher MovableCatcher; private readonly CatchComboDisplay comboDisplay; + private readonly CatcherTrailDisplay catcherTrails; + + private Catcher catcher; + /// /// -1 when only left button is pressed. /// 1 when only right button is pressed. @@ -30,11 +43,19 @@ namespace osu.Game.Rulesets.Catch.UI /// private int currentDirection; - public CatcherArea(BeatmapDifficulty difficulty = null) + // TODO: support replay rewind + private bool lastHyperDashState; + + /// + /// must be set before loading. + /// + public CatcherArea() { - Size = new Vector2(CatchPlayfield.WIDTH, CATCHER_SIZE); + Size = new Vector2(CatchPlayfield.WIDTH, Catcher.BASE_SIZE); Children = new Drawable[] { + catcherContainer = new Container { RelativeSizeAxes = Axes.Both }, + catcherTrails = new CatcherTrailDisplay(), comboDisplay = new CatchComboDisplay { RelativeSizeAxes = Axes.None, @@ -43,14 +64,13 @@ namespace osu.Game.Rulesets.Catch.UI Origin = Anchor.Centre, Margin = new MarginPadding { Bottom = 350f }, X = CatchPlayfield.CENTER_X - }, - MovableCatcher = new Catcher(this, difficulty) { 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 +78,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 +89,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 +100,48 @@ 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; + + if (Time.Elapsed <= 0) + { + // This is probably a wrong value, but currently the true value is not recorded. + // Setting `true` will prevent generation of false-positive after-images (with more false-negatives). + lastHyperDashState = true; + return; + } + + if (!lastHyperDashState && Catcher.HyperDashing) + displayCatcherTrail(CatcherTrailAnimation.HyperDashAfterImage); + + if (Catcher.Dashing || Catcher.HyperDashing) + { + double generationInterval = Catcher.HyperDashing ? 25 : 50; + + if (Time.Current - catcherTrails.LastDashTrailTime >= generationInterval) + displayCatcherTrail(Catcher.HyperDashing ? CatcherTrailAnimation.HyperDashing : CatcherTrailAnimation.Dashing); + } + + lastHyperDashState = Catcher.HyperDashing; } 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 +157,7 @@ namespace osu.Game.Rulesets.Catch.UI return true; case CatchAction.Dash: - MovableCatcher.Dashing = true; + Catcher.Dashing = true; return true; } @@ -136,9 +177,11 @@ namespace osu.Game.Rulesets.Catch.UI break; case CatchAction.Dash: - MovableCatcher.Dashing = false; + Catcher.Dashing = false; break; } } + + private void displayCatcherTrail(CatcherTrailAnimation animation) => catcherTrails.Add(new CatcherTrailEntry(Time.Current, Catcher.CurrentState, Catcher.X, Catcher.BodyScale, animation)); } } diff --git a/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs index c961d98dc5..6d2ac7e488 100644 --- a/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs +++ b/osu.Game.Rulesets.Catch/UI/CatcherTrail.cs @@ -2,8 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; -using osu.Framework.Graphics.Pooling; using osu.Framework.Timing; +using osu.Game.Rulesets.Objects.Pooling; using osuTK; namespace osu.Game.Rulesets.Catch.UI @@ -12,18 +12,13 @@ namespace osu.Game.Rulesets.Catch.UI /// A trail of the catcher. /// It also represents a hyper dash afterimage. /// - public class CatcherTrail : PoolableDrawable + public class CatcherTrail : PoolableDrawableWithLifetime { - public CatcherAnimationState AnimationState - { - set => body.AnimationState.Value = value; - } - private readonly SkinnableCatcher body; public CatcherTrail() { - Size = new Vector2(CatcherArea.CATCHER_SIZE); + Size = new Vector2(Catcher.BASE_SIZE); Origin = Anchor.TopCentre; Blending = BlendingParameters.Additive; InternalChild = body = new SkinnableCatcher @@ -34,11 +29,40 @@ namespace osu.Game.Rulesets.Catch.UI }; } - protected override void FreeAfterUse() + protected override void OnApply(CatcherTrailEntry entry) { + Position = new Vector2(entry.Position, 0); + Scale = entry.Scale; + + body.AnimationState.Value = entry.CatcherState; + + using (BeginAbsoluteSequence(entry.LifetimeStart, false)) + applyTransforms(entry.Animation); + } + + protected override void OnFree(CatcherTrailEntry entry) + { + ApplyTransformsAt(double.MinValue); ClearTransforms(); - Alpha = 1; - base.FreeAfterUse(); + } + + private void applyTransforms(CatcherTrailAnimation animation) + { + switch (animation) + { + case CatcherTrailAnimation.Dashing: + case CatcherTrailAnimation.HyperDashing: + this.FadeTo(0.4f).FadeOut(800, Easing.OutQuint); + break; + + case CatcherTrailAnimation.HyperDashAfterImage: + this.MoveToOffset(new Vector2(0, -10), 1200, Easing.In); + this.ScaleTo(Scale * 0.95f).ScaleTo(Scale * 1.2f, 1200, Easing.In); + this.FadeOut(1200); + break; + } + + Expire(); } } } diff --git a/osu.Game.Rulesets.Catch/UI/CatcherTrailAnimation.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrailAnimation.cs new file mode 100644 index 0000000000..0a5281cd10 --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/CatcherTrailAnimation.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Rulesets.Catch.UI +{ + public enum CatcherTrailAnimation + { + Dashing, + HyperDashing, + HyperDashAfterImage + } +} diff --git a/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs index b59fabcb70..0f2530e56a 100644 --- a/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs +++ b/osu.Game.Rulesets.Catch/UI/CatcherTrailDisplay.cs @@ -2,11 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System; -using JetBrains.Annotations; +using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Pooling; -using osuTK; +using osu.Game.Rulesets.Catch.Skinning; +using osu.Game.Rulesets.Objects.Pooling; +using osu.Game.Skinning; using osuTK.Graphics; namespace osu.Game.Rulesets.Catch.UI @@ -15,70 +17,32 @@ namespace osu.Game.Rulesets.Catch.UI /// Represents a component responsible for displaying /// the appropriate catcher trails when requested to. /// - public class CatcherTrailDisplay : CompositeDrawable + public class CatcherTrailDisplay : PooledDrawableWithLifetimeContainer { - private readonly Catcher catcher; + /// + /// The most recent time a dash trail was added to this container. + /// Only alive (not faded out) trails are considered. + /// Returns if no dash trail is alive. + /// + public double LastDashTrailTime => getLastDashTrailTime(); + + public Color4 HyperDashTrailsColour => hyperDashTrails.Colour; + + public Color4 HyperDashAfterImageColour => hyperDashAfterImages.Colour; + + protected override bool RemoveRewoundEntry => true; private readonly DrawablePool trailPool; private readonly Container dashTrails; private readonly Container hyperDashTrails; - private readonly Container endGlowSprites; + private readonly Container hyperDashAfterImages; - private Color4 hyperDashTrailsColour = Catcher.DEFAULT_HYPER_DASH_COLOUR; + [Resolved] + private ISkinSource skin { get; set; } - public Color4 HyperDashTrailsColour + public CatcherTrailDisplay() { - get => hyperDashTrailsColour; - set - { - if (hyperDashTrailsColour == value) - return; - - hyperDashTrailsColour = value; - hyperDashTrails.Colour = hyperDashTrailsColour; - } - } - - private Color4 endGlowSpritesColour = Catcher.DEFAULT_HYPER_DASH_COLOUR; - - public Color4 EndGlowSpritesColour - { - get => endGlowSpritesColour; - set - { - if (endGlowSpritesColour == value) - return; - - endGlowSpritesColour = value; - endGlowSprites.Colour = endGlowSpritesColour; - } - } - - private bool trail; - - /// - /// Whether to start displaying trails following the catcher. - /// - public bool DisplayTrail - { - get => trail; - set - { - if (trail == value) - return; - - trail = value; - - if (trail) - displayTrail(); - } - } - - public CatcherTrailDisplay([NotNull] Catcher catcher) - { - this.catcher = catcher ?? throw new ArgumentNullException(nameof(catcher)); - RelativeSizeAxes = Axes.Both; InternalChildren = new Drawable[] @@ -86,47 +50,86 @@ namespace osu.Game.Rulesets.Catch.UI trailPool = new DrawablePool(30), dashTrails = new Container { RelativeSizeAxes = Axes.Both }, hyperDashTrails = new Container { RelativeSizeAxes = Axes.Both, Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR }, - endGlowSprites = new Container { RelativeSizeAxes = Axes.Both, Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR }, + hyperDashAfterImages = new Container { RelativeSizeAxes = Axes.Both, Colour = Catcher.DEFAULT_HYPER_DASH_COLOUR }, }; } - /// - /// Displays a single end-glow catcher sprite. - /// - public void DisplayEndGlow() + protected override void LoadComplete() { - var endGlow = createTrailSprite(endGlowSprites); + base.LoadComplete(); - endGlow.MoveToOffset(new Vector2(0, -10), 1200, Easing.In); - endGlow.ScaleTo(endGlow.Scale * 0.95f).ScaleTo(endGlow.Scale * 1.2f, 1200, Easing.In); - endGlow.FadeOut(1200); - endGlow.Expire(true); + skin.SourceChanged += skinSourceChanged; + skinSourceChanged(); } - private void displayTrail() + private void skinSourceChanged() { - if (!DisplayTrail) - return; - - var sprite = createTrailSprite(catcher.HyperDashing ? hyperDashTrails : dashTrails); - - sprite.FadeTo(0.4f).FadeOut(800, Easing.OutQuint); - sprite.Expire(true); - - Scheduler.AddDelayed(displayTrail, catcher.HyperDashing ? 25 : 50); + hyperDashTrails.Colour = skin.GetConfig(CatchSkinColour.HyperDash)?.Value ?? Catcher.DEFAULT_HYPER_DASH_COLOUR; + hyperDashAfterImages.Colour = skin.GetConfig(CatchSkinColour.HyperDashAfterImage)?.Value ?? hyperDashTrails.Colour; } - private CatcherTrail createTrailSprite(Container target) + protected override void AddDrawable(CatcherTrailEntry entry, CatcherTrail drawable) { - CatcherTrail sprite = trailPool.Get(); + switch (entry.Animation) + { + case CatcherTrailAnimation.Dashing: + dashTrails.Add(drawable); + break; - sprite.AnimationState = catcher.CurrentState; - sprite.Scale = catcher.Scale * catcher.Body.Scale; - sprite.Position = catcher.Position; + case CatcherTrailAnimation.HyperDashing: + hyperDashTrails.Add(drawable); + break; - target.Add(sprite); + case CatcherTrailAnimation.HyperDashAfterImage: + hyperDashAfterImages.Add(drawable); + break; + } + } - return sprite; + protected override void RemoveDrawable(CatcherTrailEntry entry, CatcherTrail drawable) + { + switch (entry.Animation) + { + case CatcherTrailAnimation.Dashing: + dashTrails.Remove(drawable); + break; + + case CatcherTrailAnimation.HyperDashing: + hyperDashTrails.Remove(drawable); + break; + + case CatcherTrailAnimation.HyperDashAfterImage: + hyperDashAfterImages.Remove(drawable); + break; + } + } + + protected override CatcherTrail GetDrawable(CatcherTrailEntry entry) + { + CatcherTrail trail = trailPool.Get(); + trail.Apply(entry); + return trail; + } + + private double getLastDashTrailTime() + { + double maxTime = double.NegativeInfinity; + + foreach (var trail in dashTrails) + maxTime = Math.Max(maxTime, trail.LifetimeStart); + + foreach (var trail in hyperDashTrails) + maxTime = Math.Max(maxTime, trail.LifetimeStart); + + return maxTime; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (skin != null) + skin.SourceChanged -= skinSourceChanged; } } } diff --git a/osu.Game.Rulesets.Catch/UI/CatcherTrailEntry.cs b/osu.Game.Rulesets.Catch/UI/CatcherTrailEntry.cs new file mode 100644 index 0000000000..3a40ab26cc --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/CatcherTrailEntry.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.Graphics.Performance; +using osuTK; + +namespace osu.Game.Rulesets.Catch.UI +{ + public class CatcherTrailEntry : LifetimeEntry + { + public readonly CatcherAnimationState CatcherState; + + public readonly float Position; + + /// + /// The scaling of the catcher body. It also represents a flipped catcher (negative x component). + /// + public readonly Vector2 Scale; + + public readonly CatcherTrailAnimation Animation; + + public CatcherTrailEntry(double startTime, CatcherAnimationState catcherState, float position, Vector2 scale, CatcherTrailAnimation animation) + { + LifetimeStart = startTime; + CatcherState = catcherState; + Position = position; + Scale = scale; + Animation = animation; + } + } +} 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.Mania/Difficulty/ManiaPerformanceCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs index 405ac56e94..b04ff3548f 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.Extensions; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; @@ -37,12 +36,12 @@ namespace osu.Game.Rulesets.Mania.Difficulty { mods = Score.Mods; scaledScore = Score.TotalScore; - countPerfect = Score.Statistics.GetOrDefault(HitResult.Perfect); - countGreat = Score.Statistics.GetOrDefault(HitResult.Great); - countGood = Score.Statistics.GetOrDefault(HitResult.Good); - countOk = Score.Statistics.GetOrDefault(HitResult.Ok); - countMeh = Score.Statistics.GetOrDefault(HitResult.Meh); - countMiss = Score.Statistics.GetOrDefault(HitResult.Miss); + countPerfect = Score.Statistics.GetValueOrDefault(HitResult.Perfect); + countGreat = Score.Statistics.GetValueOrDefault(HitResult.Great); + countGood = Score.Statistics.GetValueOrDefault(HitResult.Good); + countOk = Score.Statistics.GetValueOrDefault(HitResult.Ok); + countMeh = Score.Statistics.GetValueOrDefault(HitResult.Meh); + countMiss = Score.Statistics.GetValueOrDefault(HitResult.Miss); IEnumerable scoreIncreaseMods = Ruleset.GetModsFor(ModType.DifficultyIncrease); diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index fe736766d9..f4b6e10af4 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -253,7 +253,8 @@ namespace osu.Game.Rulesets.Mania case ModType.Fun: return new Mod[] { - new MultiMod(new ModWindUp(), new ModWindDown()) + new MultiMod(new ModWindUp(), new ModWindDown()), + new ManiaModMuted(), }; default: diff --git a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs index f89750a96e..36fa336d0c 100644 --- a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs +++ b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs @@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Mania { public class ManiaSettingsSubsection : RulesetSettingsSubsection { - protected override string Header => "osu!mania"; + protected override LocalisableString Header => "osu!mania"; public ManiaSettingsSubsection(ManiaRuleset ruleset) : base(ruleset) diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs index cf404cc98e..f01884c97f 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModMirror.cs @@ -10,13 +10,9 @@ using osu.Game.Rulesets.Mania.Beatmaps; namespace osu.Game.Rulesets.Mania.Mods { - public class ManiaModMirror : Mod, IApplicableToBeatmap + public class ManiaModMirror : ModMirror, IApplicableToBeatmap { - public override string Name => "Mirror"; - public override string Acronym => "MR"; - public override ModType Type => ModType.Conversion; public override string Description => "Notes are flipped horizontally."; - public override double ScoreMultiplier => 1; public void ApplyToBeatmap(IBeatmap beatmap) { diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModMuted.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModMuted.cs new file mode 100644 index 0000000000..33ebcf303a --- /dev/null +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModMuted.cs @@ -0,0 +1,12 @@ +// 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.Mania.Objects; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Mania.Mods +{ + public class ManiaModMuted : ModMuted + { + } +} diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs index 3ec68bfb56..5aff4e200b 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableManiaHitObject.cs @@ -22,6 +22,10 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables protected readonly IBindable Direction = new Bindable(); + // Leaving the default (10s) makes hitobjects not appear, as this offset is used for the initial state transforms. + // Calculated as DrawableManiaRuleset.MAX_TIME_RANGE + some additional allowance for velocity < 1. + protected override double InitialLifetimeOffset => 30000; + [Resolved(canBeNull: true)] private ManiaPlayfield playfield { get; set; } diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs index e497646a13..614a7b00c7 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs @@ -33,12 +33,12 @@ namespace osu.Game.Rulesets.Mania.UI /// /// The minimum time range. This occurs at a of 40. /// - public const double MIN_TIME_RANGE = 340; + public const double MIN_TIME_RANGE = 290; /// /// The maximum time range. This occurs at a of 1. /// - public const double MAX_TIME_RANGE = 13720; + public const double MAX_TIME_RANGE = 11485; protected new ManiaPlayfield Playfield => (ManiaPlayfield)base.Playfield; @@ -67,7 +67,7 @@ namespace osu.Game.Rulesets.Mania.UI protected override ScrollVisualisationMethod VisualisationMethod => scrollMethod; private readonly Bindable configDirection = new Bindable(); - private readonly Bindable configTimeRange = new BindableDouble(); + private readonly BindableDouble configTimeRange = new BindableDouble(); // Stores the current speed adjustment active in gameplay. private readonly Track speedAdjustmentTrack = new TrackVirtual(0); @@ -103,6 +103,8 @@ namespace osu.Game.Rulesets.Mania.UI configDirection.BindValueChanged(direction => Direction.Value = (ScrollingDirection)direction.NewValue, true); Config.BindWith(ManiaRulesetSetting.ScrollTime, configTimeRange); + TimeRange.MinValue = configTimeRange.MinValue; + TimeRange.MaxValue = configTimeRange.MaxValue; } protected override void AdjustScrollSpeed(int amount) 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.Tests/TestSceneLegacyBeatmapSkin.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyBeatmapSkin.cs index 56307861f1..8b51225e98 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyBeatmapSkin.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyBeatmapSkin.cs @@ -1,16 +1,21 @@ // 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 NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Skinning; using osu.Game.Tests.Beatmaps; using osuTK; +using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Tests { @@ -77,23 +82,106 @@ namespace osu.Game.Rulesets.Osu.Tests AddAssert("is custom user skin colours", () => TestPlayer.UsableComboColours.SequenceEqual(TestSkin.Colours)); } + [TestCase(true, true)] + [TestCase(true, false)] + [TestCase(false, true)] + [TestCase(false, false)] + public void TestComboOffsetWithBeatmapColours(bool userHasCustomColours, bool useBeatmapSkin) + { + PrepareBeatmap(() => new OsuCustomSkinWorkingBeatmap(audio, true, getHitCirclesWithLegacyOffsets())); + ConfigureTest(useBeatmapSkin, true, userHasCustomColours); + assertCorrectObjectComboColours("is beatmap skin colours with combo offsets applied", + TestBeatmapSkin.Colours, + (i, obj) => i + 1 + obj.ComboOffset); + } + + [TestCase(true)] + [TestCase(false)] + public void TestComboOffsetWithIgnoredBeatmapColours(bool useBeatmapSkin) + { + PrepareBeatmap(() => new OsuCustomSkinWorkingBeatmap(audio, true, getHitCirclesWithLegacyOffsets())); + ConfigureTest(useBeatmapSkin, false, true); + assertCorrectObjectComboColours("is user skin colours without combo offsets applied", + TestSkin.Colours, + (i, _) => i + 1); + } + + private void assertCorrectObjectComboColours(string description, Color4[] expectedColours, Func nextExpectedComboIndex) + { + AddUntilStep("wait for objects to become alive", () => + TestPlayer.DrawableRuleset.Playfield.AllHitObjects.Count() == TestPlayer.DrawableRuleset.Objects.Count()); + + AddAssert(description, () => + { + int index = 0; + + return TestPlayer.DrawableRuleset.Playfield.AllHitObjects.All(d => + { + index = nextExpectedComboIndex(index, (OsuHitObject)d.HitObject); + return checkComboColour(d, expectedColours[index % expectedColours.Length]); + }); + }); + + static bool checkComboColour(DrawableHitObject drawableHitObject, Color4 expectedColour) + { + return drawableHitObject.AccentColour.Value == expectedColour && + drawableHitObject.NestedHitObjects.All(n => checkComboColour(n, expectedColour)); + } + } + + private static IEnumerable getHitCirclesWithLegacyOffsets() + { + var hitObjects = new List(); + + for (int i = 0; i < 10; i++) + { + var hitObject = i % 2 == 0 + ? (OsuHitObject)new HitCircle() + : new Slider + { + Path = new SliderPath(new[] + { + new PathControlPoint(new Vector2(0, 0)), + new PathControlPoint(new Vector2(100, 0)), + }) + }; + + hitObject.StartTime = i; + hitObject.Position = new Vector2(256, 192); + hitObject.NewCombo = true; + hitObject.ComboOffset = i; + + hitObjects.Add(hitObject); + } + + return hitObjects; + } + private class OsuCustomSkinWorkingBeatmap : CustomSkinWorkingBeatmap { - public OsuCustomSkinWorkingBeatmap(AudioManager audio, bool hasColours) - : base(createBeatmap(), audio, hasColours) + public OsuCustomSkinWorkingBeatmap(AudioManager audio, bool hasColours, IEnumerable hitObjects = null) + : base(createBeatmap(hitObjects), audio, hasColours) { } - private static IBeatmap createBeatmap() => - new Beatmap + private static IBeatmap createBeatmap(IEnumerable hitObjects) + { + var beatmap = new Beatmap { BeatmapInfo = { BeatmapSet = new BeatmapSetInfo(), Ruleset = new OsuRuleset().RulesetInfo, }, - HitObjects = { new HitCircle { Position = new Vector2(256, 192) } } }; + + beatmap.HitObjects.AddRange(hitObjects ?? new[] + { + new HitCircle { Position = new Vector2(256, 192) } + }); + + return beatmap; + } } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 749d7d1b41..e6ab978dfb 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.Extensions; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; @@ -36,10 +35,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty mods = Score.Mods; accuracy = Score.Accuracy; scoreMaxCombo = Score.MaxCombo; - countGreat = Score.Statistics.GetOrDefault(HitResult.Great); - countOk = Score.Statistics.GetOrDefault(HitResult.Ok); - countMeh = Score.Statistics.GetOrDefault(HitResult.Meh); - countMiss = Score.Statistics.GetOrDefault(HitResult.Miss); + countGreat = Score.Statistics.GetValueOrDefault(HitResult.Great); + countOk = Score.Statistics.GetValueOrDefault(HitResult.Ok); + countMeh = Score.Statistics.GetValueOrDefault(HitResult.Meh); + countMiss = Score.Statistics.GetValueOrDefault(HitResult.Miss); // Custom multipliers for NoFail and SpunOut. double multiplier = 1.12; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things @@ -98,26 +97,32 @@ namespace osu.Game.Rulesets.Osu.Difficulty double approachRateFactor = 0.0; if (Attributes.ApproachRate > 10.33) - approachRateFactor += 0.4 * (Attributes.ApproachRate - 10.33); + approachRateFactor = Attributes.ApproachRate - 10.33; else if (Attributes.ApproachRate < 8.0) - approachRateFactor += 0.01 * (8.0 - Attributes.ApproachRate); + approachRateFactor = 0.025 * (8.0 - Attributes.ApproachRate); - aimValue *= 1.0 + Math.Min(approachRateFactor, approachRateFactor * (totalHits / 1000.0)); + double approachRateTotalHitsFactor = 1.0 / (1.0 + Math.Exp(-(0.007 * (totalHits - 400)))); + + 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 @@ -145,9 +150,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty double approachRateFactor = 0.0; if (Attributes.ApproachRate > 10.33) - approachRateFactor += 0.4 * (Attributes.ApproachRate - 10.33); + approachRateFactor = Attributes.ApproachRate - 10.33; - speedValue *= 1.0 + Math.Min(approachRateFactor, approachRateFactor * (totalHits / 1000.0)); + double approachRateTotalHitsFactor = 1.0 / (1.0 + Math.Exp(-(0.007 * (totalHits - 400)))); + + speedValue *= 1.0 + (0.03 + 0.37 * approachRateTotalHitsFactor) * approachRateFactor; if (mods.Any(m => m is OsuModHidden)) speedValue *= 1.0 + 0.04 * (12.0 - Attributes.ApproachRate); 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.Rulesets.Osu/Mods/OsuModHardRock.cs b/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs index 16c166257a..007820b016 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModHardRock.cs @@ -1,13 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; -using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Objects; -using osu.Game.Rulesets.Osu.UI; -using osuTK; +using osu.Game.Rulesets.Osu.Utils; namespace osu.Game.Rulesets.Osu.Mods { @@ -15,23 +14,13 @@ namespace osu.Game.Rulesets.Osu.Mods { public override double ScoreMultiplier => 1.06; + public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModMirror)).ToArray(); + public void ApplyToHitObject(HitObject hitObject) { var osuObject = (OsuHitObject)hitObject; - osuObject.Position = new Vector2(osuObject.Position.X, OsuPlayfield.BASE_SIZE.Y - osuObject.Y); - - if (!(hitObject is Slider slider)) - return; - - slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y)); - slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y)); - - var controlPoints = slider.Path.ControlPoints.Select(p => new PathControlPoint(p.Position.Value, p.Type.Value)).ToArray(); - foreach (var point in controlPoints) - point.Position.Value = new Vector2(point.Position.Value.X, -point.Position.Value.Y); - - slider.Path = new SliderPath(controlPoints, slider.Path.ExpectedDistance.Value); + OsuHitObjectGenerationUtils.ReflectVertically(osuObject); } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModMirror.cs b/osu.Game.Rulesets.Osu/Mods/OsuModMirror.cs new file mode 100644 index 0000000000..3faca0b01f --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModMirror.cs @@ -0,0 +1,50 @@ +// 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.Bindables; +using osu.Game.Configuration; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Utils; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public class OsuModMirror : ModMirror, IApplicableToHitObject + { + public override string Description => "Flip objects on the chosen axes."; + public override Type[] IncompatibleMods => new[] { typeof(ModHardRock) }; + + [SettingSource("Mirrored axes", "Choose which axes objects are mirrored over.")] + public Bindable Reflection { get; } = new Bindable(); + + public void ApplyToHitObject(HitObject hitObject) + { + var osuObject = (OsuHitObject)hitObject; + + switch (Reflection.Value) + { + case MirrorType.Horizontal: + OsuHitObjectGenerationUtils.ReflectHorizontally(osuObject); + break; + + case MirrorType.Vertical: + OsuHitObjectGenerationUtils.ReflectVertically(osuObject); + break; + + case MirrorType.Both: + OsuHitObjectGenerationUtils.ReflectHorizontally(osuObject); + OsuHitObjectGenerationUtils.ReflectVertically(osuObject); + break; + } + } + + public enum MirrorType + { + Horizontal, + Vertical, + Both + } + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModMuted.cs b/osu.Game.Rulesets.Osu/Mods/OsuModMuted.cs new file mode 100644 index 0000000000..5e3ee37b61 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModMuted.cs @@ -0,0 +1,12 @@ +// 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.Mods; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public class OsuModMuted : ModMuted + { + } +} diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs b/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs index 2464308347..210d5e0403 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModTarget.cs @@ -1,13 +1,39 @@ // 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.Bindables; +using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Utils; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Timing; +using osu.Game.Configuration; using osu.Game.Graphics; +using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Osu.Utils; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osuTK; +using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModTarget : Mod + public class OsuModTarget : ModWithVisibilityAdjustment, IApplicableToDrawableRuleset, + IApplicableToHealthProcessor, IApplicableToDifficulty, IApplicableFailOverride, + IHasSeed, IHidesApproachCircles { public override string Name => "Target"; public override string Acronym => "TP"; @@ -15,5 +41,466 @@ namespace osu.Game.Rulesets.Osu.Mods public override IconUsage? Icon => OsuIcon.ModTarget; public override string Description => @"Practice keeping up with the beat of the song."; public override double ScoreMultiplier => 1; + + public override Type[] IncompatibleMods => new[] { typeof(IRequiresApproachCircles) }; + + [SettingSource("Seed", "Use a custom seed instead of a random one", SettingControlType = typeof(SettingsNumberBox))] + public Bindable Seed { get; } = new Bindable + { + Default = null, + Value = null + }; + + #region Constants + + /// + /// Jump distance for circles in the last combo + /// + private const float max_base_distance = 333f; + + /// + /// The maximum allowed jump distance after multipliers are applied + /// + private const float distance_cap = 380f; + + /// + /// The extent of rotation towards playfield centre when a circle is near the edge + /// + private const float edge_rotation_multiplier = 0.75f; + + /// + /// Number of recent circles to check for overlap + /// + private const int overlap_check_count = 5; + + /// + /// Duration of the undimming animation + /// + private const double undim_duration = 96; + + /// + /// Acceptable difference for timing comparisons + /// + private const double timing_precision = 1; + + #endregion + + #region Private Fields + + private ControlPointInfo controlPointInfo; + + private List originalHitObjects; + + private Random rng; + + #endregion + + #region Sudden Death (IApplicableFailOverride) + + public bool PerformFail() => true; + + public bool RestartOnFail => false; + + public void ApplyToHealthProcessor(HealthProcessor healthProcessor) + { + // Sudden death + healthProcessor.FailConditions += (_, result) + => result.Type.AffectsCombo() + && !result.IsHit; + } + + #endregion + + #region Reduce AR (IApplicableToDifficulty) + + public void ReadFromDifficulty(BeatmapDifficulty difficulty) + { + } + + public void ApplyToDifficulty(BeatmapDifficulty difficulty) + { + // Decrease AR to increase preempt time + difficulty.ApproachRate *= 0.5f; + } + + #endregion + + #region Circle Transforms (ModWithVisibilityAdjustment) + + protected override void ApplyIncreasedVisibilityState(DrawableHitObject drawable, ArmedState state) + { + } + + protected override void ApplyNormalVisibilityState(DrawableHitObject drawable, ArmedState state) + { + if (!(drawable is DrawableHitCircle circle)) return; + + double startTime = circle.HitObject.StartTime; + double preempt = circle.HitObject.TimePreempt; + + using (circle.BeginAbsoluteSequence(startTime - preempt)) + { + // initial state + circle.ScaleTo(0.5f) + .FadeColour(OsuColour.Gray(0.5f)); + + // scale to final size + circle.ScaleTo(1f, preempt); + + // Remove approach circles + circle.ApproachCircle.Hide(); + } + + using (circle.BeginAbsoluteSequence(startTime - controlPointInfo.TimingPointAt(startTime).BeatLength - undim_duration)) + circle.FadeColour(Color4.White, undim_duration); + } + + #endregion + + #region Beatmap Generation (IApplicableToBeatmap) + + public override void ApplyToBeatmap(IBeatmap beatmap) + { + Seed.Value ??= RNG.Next(); + rng = new Random(Seed.Value.Value); + + var osuBeatmap = (OsuBeatmap)beatmap; + + if (osuBeatmap.HitObjects.Count == 0) return; + + controlPointInfo = osuBeatmap.ControlPointInfo; + originalHitObjects = osuBeatmap.HitObjects.OrderBy(x => x.StartTime).ToList(); + + var hitObjects = generateBeats(osuBeatmap) + .Select(beat => + { + var newCircle = new HitCircle(); + newCircle.ApplyDefaults(controlPointInfo, osuBeatmap.BeatmapInfo.BaseDifficulty); + newCircle.StartTime = beat; + return (OsuHitObject)newCircle; + }).ToList(); + + addHitSamples(hitObjects); + + fixComboInfo(hitObjects); + + randomizeCirclePos(hitObjects); + + osuBeatmap.HitObjects = hitObjects; + + base.ApplyToBeatmap(beatmap); + } + + private IEnumerable generateBeats(IBeatmap beatmap) + { + var startTime = originalHitObjects.First().StartTime; + var endTime = originalHitObjects.Last().GetEndTime(); + + var beats = beatmap.ControlPointInfo.TimingPoints + // Ignore timing points after endTime + .Where(timingPoint => !definitelyBigger(timingPoint.Time, endTime)) + // Generate the beats + .SelectMany(timingPoint => getBeatsForTimingPoint(timingPoint, endTime)) + // Remove beats before startTime + .Where(beat => almostBigger(beat, startTime)) + // Remove beats during breaks + .Where(beat => !isInsideBreakPeriod(beatmap.Breaks, beat)) + .ToList(); + + // Remove beats that are too close to the next one (e.g. due to timing point changes) + for (var i = beats.Count - 2; i >= 0; i--) + { + var beat = beats[i]; + + if (!definitelyBigger(beats[i + 1] - beat, beatmap.ControlPointInfo.TimingPointAt(beat).BeatLength / 2)) + beats.RemoveAt(i); + } + + return beats; + } + + private void addHitSamples(IEnumerable hitObjects) + { + foreach (var obj in hitObjects) + { + var samples = getSamplesAtTime(originalHitObjects, obj.StartTime); + + // If samples aren't available at the exact start time of the object, + // use samples (without additions) in the closest original hit object instead + obj.Samples = samples ?? getClosestHitObject(originalHitObjects, obj.StartTime).Samples.Where(s => !HitSampleInfo.AllAdditions.Contains(s.Name)).ToList(); + } + } + + private void fixComboInfo(List hitObjects) + { + // Copy combo indices from an original object at the same time or from the closest preceding object + // (Objects lying between two combos are assumed to belong to the preceding combo) + hitObjects.ForEach(newObj => + { + var closestOrigObj = originalHitObjects.FindLast(y => almostBigger(newObj.StartTime, y.StartTime)); + + // It shouldn't be possible for closestOrigObj to be null + // But if it is, obj should be in the first combo + newObj.ComboIndex = closestOrigObj?.ComboIndex ?? 0; + }); + + // The copied combo indices may not be continuous if the original map starts and ends a combo in between beats + // e.g. A stream with each object starting a new combo + // So combo indices need to be reprocessed to ensure continuity + // Other kinds of combo info are also added in the process + var combos = hitObjects.GroupBy(x => x.ComboIndex).ToList(); + + for (var i = 0; i < combos.Count; i++) + { + var group = combos[i].ToList(); + group.First().NewCombo = true; + group.Last().LastInCombo = true; + + for (var j = 0; j < group.Count; j++) + { + var x = group[j]; + x.ComboIndex = i; + x.IndexInCurrentCombo = j; + } + } + } + + private void randomizeCirclePos(IReadOnlyList hitObjects) + { + if (hitObjects.Count == 0) return; + + float nextSingle(float max = 1f) => (float)(rng.NextDouble() * max); + + const float two_pi = MathF.PI * 2; + + var direction = two_pi * nextSingle(); + var maxComboIndex = hitObjects.Last().ComboIndex; + + for (var i = 0; i < hitObjects.Count; i++) + { + var obj = hitObjects[i]; + var lastPos = i == 0 + ? Vector2.Divide(OsuPlayfield.BASE_SIZE, 2) + : hitObjects[i - 1].Position; + + var distance = maxComboIndex == 0 + ? (float)obj.Radius + : mapRange(obj.ComboIndex, 0, maxComboIndex, (float)obj.Radius, max_base_distance); + if (obj.NewCombo) distance *= 1.5f; + if (obj.Kiai) distance *= 1.2f; + distance = Math.Min(distance_cap, distance); + + // Attempt to place the circle at a place that does not overlap with previous ones + + var tryCount = 0; + + // for checking overlap + var precedingObjects = hitObjects.SkipLast(hitObjects.Count - i).TakeLast(overlap_check_count).ToList(); + + do + { + if (tryCount > 0) direction = two_pi * nextSingle(); + + var relativePos = new Vector2( + distance * MathF.Cos(direction), + distance * MathF.Sin(direction) + ); + // Rotate the new circle away from playfield border + relativePos = OsuHitObjectGenerationUtils.RotateAwayFromEdge(lastPos, relativePos, edge_rotation_multiplier); + direction = MathF.Atan2(relativePos.Y, relativePos.X); + + var newPosition = Vector2.Add(lastPos, relativePos); + + obj.Position = newPosition; + + clampToPlayfield(obj); + + tryCount++; + if (tryCount % 10 == 0) distance *= 0.9f; + } while (distance >= obj.Radius * 2 && checkForOverlap(precedingObjects, obj)); + + if (obj.LastInCombo) + direction = two_pi * nextSingle(); + else + direction += distance / distance_cap * (nextSingle() * two_pi - MathF.PI); + } + } + + #endregion + + #region Metronome (IApplicableToDrawableRuleset) + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + drawableRuleset.Overlays.Add(new Metronome(drawableRuleset.Beatmap.HitObjects.First().StartTime)); + } + + #endregion + + #region Helper Subroutines + + /// + /// Check if a given time is inside a . + /// + /// + /// The given time is also considered to be inside a break if it is earlier than the + /// start time of the first original hit object after the break. + /// + /// The breaks of the beatmap. + /// The time to be checked.= + private bool isInsideBreakPeriod(IEnumerable breaks, double time) + { + return breaks.Any(breakPeriod => + { + var firstObjAfterBreak = originalHitObjects.First(obj => almostBigger(obj.StartTime, breakPeriod.EndTime)); + + return almostBigger(time, breakPeriod.StartTime) + && definitelyBigger(firstObjAfterBreak.StartTime, time); + }); + } + + private IEnumerable getBeatsForTimingPoint(TimingControlPoint timingPoint, double mapEndTime) + { + var beats = new List(); + int i = 0; + var currentTime = timingPoint.Time; + + while (!definitelyBigger(currentTime, mapEndTime) && controlPointInfo.TimingPointAt(currentTime) == timingPoint) + { + beats.Add(Math.Floor(currentTime)); + i++; + currentTime = timingPoint.Time + i * timingPoint.BeatLength; + } + + return beats; + } + + private OsuHitObject getClosestHitObject(List hitObjects, double time) + { + var precedingIndex = hitObjects.FindLastIndex(h => h.StartTime < time); + + if (precedingIndex == hitObjects.Count - 1) return hitObjects[precedingIndex]; + + // return the closest preceding/succeeding hit object, whoever is closer in time + return hitObjects[precedingIndex + 1].StartTime - time < time - hitObjects[precedingIndex].StartTime + ? hitObjects[precedingIndex + 1] + : hitObjects[precedingIndex]; + } + + /// + /// Get samples (if any) for a specific point in time. + /// + /// + /// Samples will be returned if a hit circle or a slider node exists at that point of time. + /// + /// The list of hit objects in a beatmap, ordered by StartTime + /// The point in time to get samples for + /// Hit samples + private IList getSamplesAtTime(IEnumerable hitObjects, double time) + { + // Get a hit object that + // either has StartTime equal to the target time + // or has a repeat node at the target time + var sampleObj = hitObjects.FirstOrDefault(hitObject => + { + if (almostEquals(time, hitObject.StartTime)) + return true; + + if (!(hitObject is IHasRepeats s)) + return false; + // If time is outside the duration of the IHasRepeats, + // then this hitObject isn't the one we want + if (!almostBigger(time, hitObject.StartTime) + || !almostBigger(s.EndTime, time)) + return false; + + return nodeIndexFromTime(s, time - hitObject.StartTime) != -1; + }); + if (sampleObj == null) return null; + + IList samples; + + if (sampleObj is IHasRepeats slider) + samples = slider.NodeSamples[nodeIndexFromTime(slider, time - sampleObj.StartTime)]; + else + samples = sampleObj.Samples; + + return samples; + } + + /// + /// Get the repeat node at a point in time. + /// + /// The slider. + /// The time since the start time of the slider. + /// Index of the node. -1 if there isn't a node at the specific time. + private int nodeIndexFromTime(IHasRepeats curve, double timeSinceStart) + { + double spanDuration = curve.Duration / curve.SpanCount(); + double nodeIndex = timeSinceStart / spanDuration; + + if (almostEquals(nodeIndex, Math.Round(nodeIndex))) + return (int)Math.Round(nodeIndex); + + return -1; + } + + private bool checkForOverlap(IEnumerable objectsToCheck, OsuHitObject target) + { + return objectsToCheck.Any(h => Vector2.Distance(h.Position, target.Position) < target.Radius * 2); + } + + /// + /// Move the hit object into playfield, taking its radius into account. + /// + /// The hit object to be clamped. + private void clampToPlayfield(OsuHitObject obj) + { + var position = obj.Position; + var radius = (float)obj.Radius; + + if (position.Y < radius) + position.Y = radius; + else if (position.Y > OsuPlayfield.BASE_SIZE.Y - radius) + position.Y = OsuPlayfield.BASE_SIZE.Y - radius; + + if (position.X < radius) + position.X = radius; + else if (position.X > OsuPlayfield.BASE_SIZE.X - radius) + position.X = OsuPlayfield.BASE_SIZE.X - radius; + + obj.Position = position; + } + + /// + /// Re-maps a number from one range to another. + /// + /// The number to be re-mapped. + /// Beginning of the original range. + /// End of the original range. + /// Beginning of the new range. + /// End of the new range. + /// The re-mapped number. + private static float mapRange(float value, float fromLow, float fromHigh, float toLow, float toHigh) + { + return (value - fromLow) * (toHigh - toLow) / (fromHigh - fromLow) + toLow; + } + + private static bool almostBigger(double value1, double value2) + { + return Precision.AlmostBigger(value1, value2, timing_precision); + } + + private static bool definitelyBigger(double value1, double value2) + { + return Precision.DefinitelyBigger(value1, value2, timing_precision); + } + + private static bool almostEquals(double value1, double value2) + { + return Precision.AlmostEquals(value1, value2, timing_precision); + } + + #endregion } } diff --git a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs index 22b64af3df..36629fa41e 100644 --- a/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs +++ b/osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs @@ -97,6 +97,14 @@ namespace osu.Game.Rulesets.Osu.Objects set => ComboIndexBindable.Value = value; } + public Bindable ComboIndexWithOffsetsBindable { get; } = new Bindable(); + + public int ComboIndexWithOffsets + { + get => ComboIndexWithOffsetsBindable.Value; + set => ComboIndexWithOffsetsBindable.Value = value; + } + public Bindable LastInComboBindable { get; } = new Bindable(); public bool LastInCombo diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 5f37b0d040..b13cdff1ec 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -166,6 +166,7 @@ namespace osu.Game.Rulesets.Osu new OsuModDifficultyAdjust(), new OsuModClassic(), new OsuModRandom(), + new OsuModMirror(), }; case ModType.Automation: @@ -188,6 +189,7 @@ namespace osu.Game.Rulesets.Osu new OsuModTraceable(), new OsuModBarrelRoll(), new OsuModApproachDifferent(), + new OsuModMuted(), }; case ModType.System: diff --git a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs index 8993a9b18a..c1c2ea2299 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuPlayfield.cs @@ -104,7 +104,7 @@ namespace osu.Game.Rulesets.Osu.UI private void onJudgementLoaded(DrawableOsuJudgement judgement) { - judgementAboveHitObjectLayer.Add(judgement.GetProxyAboveHitObjectsContent()); + judgementAboveHitObjectLayer.Add(judgement.ProxiedAboveHitObjectsContent); } [BackgroundDependencyLoader(true)] @@ -150,6 +150,10 @@ namespace osu.Game.Rulesets.Osu.UI DrawableOsuJudgement explosion = poolDictionary[result.Type].Get(doj => doj.Apply(result, judgedObject)); judgementLayer.Add(explosion); + + // the proxied content is added to judgementAboveHitObjectLayer once, on first load, and never removed from it. + // ensure that ordering is consistent with expectations (latest judgement should be front-most). + judgementAboveHitObjectLayer.ChangeChildDepth(explosion.ProxiedAboveHitObjectsContent, (float)-result.TimeAbsolute); } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => HitObjectContainer.ReceivePositionalInputAt(screenSpacePos); diff --git a/osu.Game.Rulesets.Osu/UI/OsuSettingsSubsection.cs b/osu.Game.Rulesets.Osu/UI/OsuSettingsSubsection.cs index 705ba3e929..a4c0381d16 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuSettingsSubsection.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuSettingsSubsection.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Osu.Configuration; using osu.Game.Rulesets.UI; @@ -11,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.UI { public class OsuSettingsSubsection : RulesetSettingsSubsection { - protected override string Header => "osu!"; + protected override LocalisableString Header => "osu!"; public OsuSettingsSubsection(Ruleset ruleset) : base(ruleset) diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs index 06b964a647..57ec51cf64 100644 --- a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs +++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs @@ -2,7 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; using osuTK; namespace osu.Game.Rulesets.Osu.Utils @@ -100,5 +104,47 @@ namespace osu.Game.Rulesets.Osu.Utils initial.Length * MathF.Sin(finalAngleRad) ); } + + /// + /// Reflects the position of the in the playfield horizontally. + /// + /// The object to reflect. + public static void ReflectHorizontally(OsuHitObject osuObject) + { + osuObject.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - osuObject.X, osuObject.Position.Y); + + if (!(osuObject is Slider slider)) + return; + + slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - h.Position.X, h.Position.Y)); + slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(OsuPlayfield.BASE_SIZE.X - h.Position.X, h.Position.Y)); + + var controlPoints = slider.Path.ControlPoints.Select(p => new PathControlPoint(p.Position.Value, p.Type.Value)).ToArray(); + foreach (var point in controlPoints) + point.Position.Value = new Vector2(-point.Position.Value.X, point.Position.Value.Y); + + slider.Path = new SliderPath(controlPoints, slider.Path.ExpectedDistance.Value); + } + + /// + /// Reflects the position of the in the playfield vertically. + /// + /// The object to reflect. + public static void ReflectVertically(OsuHitObject osuObject) + { + osuObject.Position = new Vector2(osuObject.Position.X, OsuPlayfield.BASE_SIZE.Y - osuObject.Y); + + if (!(osuObject is Slider slider)) + return; + + slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y)); + slider.NestedHitObjects.OfType().ForEach(h => h.Position = new Vector2(h.Position.X, OsuPlayfield.BASE_SIZE.Y - h.Position.Y)); + + var controlPoints = slider.Path.ControlPoints.Select(p => new PathControlPoint(p.Position.Value, p.Type.Value)).ToArray(); + foreach (var point in controlPoints) + point.Position.Value = new Vector2(point.Position.Value.X, -point.Position.Value.Y); + + slider.Path = new SliderPath(controlPoints, slider.Path.ExpectedDistance.Value); + } } } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index 6117ed1673..90dd733dfd 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.Extensions; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; @@ -31,10 +30,10 @@ namespace osu.Game.Rulesets.Taiko.Difficulty public override double Calculate(Dictionary categoryDifficulty = null) { mods = Score.Mods; - countGreat = Score.Statistics.GetOrDefault(HitResult.Great); - countOk = Score.Statistics.GetOrDefault(HitResult.Ok); - countMeh = Score.Statistics.GetOrDefault(HitResult.Meh); - countMiss = Score.Statistics.GetOrDefault(HitResult.Miss); + countGreat = Score.Statistics.GetValueOrDefault(HitResult.Great); + countOk = Score.Statistics.GetValueOrDefault(HitResult.Ok); + countMeh = Score.Statistics.GetValueOrDefault(HitResult.Meh); + countMiss = Score.Statistics.GetValueOrDefault(HitResult.Miss); // Custom multipliers for NoFail and SpunOut. double multiplier = 1.1; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs index 5a4d18be98..6520517039 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModClassic.cs @@ -2,10 +2,30 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Taiko.Mods { - public class TaikoModClassic : ModClassic + public class TaikoModClassic : ModClassic, IApplicableToDrawableRuleset, IUpdatableByPlayfield { + private DrawableTaikoRuleset drawableTaikoRuleset; + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + drawableTaikoRuleset = (DrawableTaikoRuleset)drawableRuleset; + } + + public void Update(Playfield playfield) + { + // Classic taiko scrolls at a constant 100px per 1000ms. More notes become visible as the playfield is lengthened. + const float scroll_rate = 10; + + // Since the time range will depend on a positional value, it is referenced to the x480 pixel space. + float ratio = drawableTaikoRuleset.DrawHeight / 480; + + drawableTaikoRuleset.TimeRange.Value = (playfield.HitObjectContainer.DrawWidth / ratio) * scroll_rate; + } } } diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs index 0fd3625a93..a6b3fe1cd9 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModHidden.cs @@ -12,23 +12,11 @@ using osu.Game.Rulesets.Taiko.Objects.Drawables; namespace osu.Game.Rulesets.Taiko.Mods { - public class TaikoModHidden : ModHidden, IApplicableToDifficulty + public class TaikoModHidden : ModHidden { public override string Description => @"Beats fade out before you hit them!"; public override double ScoreMultiplier => 1.06; - /// - /// In osu-stable, the hit position is 160, so the active playfield is essentially 160 pixels shorter - /// than the actual screen width. The normalized playfield height is 480, so on a 4:3 screen the - /// playfield ratio of the active area up to the hit position will actually be (640 - 160) / 480 = 1. - /// For custom resolutions/aspect ratios (x:y), the screen width given the normalized height becomes 480 * x / y instead, - /// and the playfield ratio becomes (480 * x / y - 160) / 480 = x / y - 1/3. - /// This constant is equal to the playfield ratio on 4:3 screens divided by the playfield ratio on 16:9 screens. - /// - private const double hd_sv_scale = (4.0 / 3.0 - 1.0 / 3.0) / (16.0 / 9.0 - 1.0 / 3.0); - - private double originalSliderMultiplier; - private ControlPointInfo controlPointInfo; protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state) @@ -41,7 +29,7 @@ namespace osu.Game.Rulesets.Taiko.Mods double beatLength = controlPointInfo.TimingPointAt(position).BeatLength; double speedMultiplier = controlPointInfo.DifficultyPointAt(position).SpeedMultiplier; - return originalSliderMultiplier * speedMultiplier * TimingControlPoint.DEFAULT_BEAT_LENGTH / beatLength; + return speedMultiplier * TimingControlPoint.DEFAULT_BEAT_LENGTH / beatLength; } protected override void ApplyNormalVisibilityState(DrawableHitObject hitObject, ArmedState state) @@ -69,22 +57,6 @@ namespace osu.Game.Rulesets.Taiko.Mods } } - public void ReadFromDifficulty(BeatmapDifficulty difficulty) - { - } - - public void ApplyToDifficulty(BeatmapDifficulty difficulty) - { - // needs to be read after all processing has been run (TaikoBeatmapConverter applies an adjustment which would otherwise be omitted). - originalSliderMultiplier = difficulty.SliderMultiplier; - - // osu-stable has an added playfield cover that essentially forces a 4:3 playfield ratio, by cutting off all objects past that size. - // This is not yet implemented; instead a playfield adjustment container is present which maintains a 16:9 ratio. - // For now, increase the slider multiplier proportionally so that the notes stay on the screen for the same amount of time as on stable. - // Note that this means that the notes will scroll faster as they have a longer distance to travel on the screen in that same amount of time. - difficulty.SliderMultiplier /= hd_sv_scale; - } - public override void ApplyToBeatmap(IBeatmap beatmap) { controlPointInfo = beatmap.ControlPointInfo; diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModMuted.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModMuted.cs new file mode 100644 index 0000000000..0f1e0b2885 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModMuted.cs @@ -0,0 +1,12 @@ +// 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.Mods; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Mods +{ + public class TaikoModMuted : ModMuted + { + } +} diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index ab5fcf6336..adc924ba38 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -149,7 +149,8 @@ namespace osu.Game.Rulesets.Taiko case ModType.Fun: return new Mod[] { - new MultiMod(new ModWindUp(), new ModWindDown()) + new MultiMod(new ModWindUp(), new ModWindDown()), + new TaikoModMuted(), }; default: diff --git a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs index ed8e6859a2..650ce1f5a3 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrawableTaikoRuleset.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects.Drawables; @@ -24,12 +25,14 @@ namespace osu.Game.Rulesets.Taiko.UI { public class DrawableTaikoRuleset : DrawableScrollingRuleset { - private SkinnableDrawable scroller; + public new BindableDouble TimeRange => base.TimeRange; protected override ScrollVisualisationMethod VisualisationMethod => ScrollVisualisationMethod.Overlapping; protected override bool UserScrollSpeedAdjustment => false; + private SkinnableDrawable scroller; + public DrawableTaikoRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) : base(ruleset, beatmap, mods) { diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs index 46dafc3a30..0d9e08b8b7 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs @@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Taiko.UI /// /// Default height of a when inside a . /// - public const float DEFAULT_HEIGHT = 178; + public const float DEFAULT_HEIGHT = 212; private Container hitExplosionContainer; private Container kiaiExplosionContainer; diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs index 0f82492e51..4fe1cf3790 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs @@ -323,12 +323,12 @@ namespace osu.Game.Tests.Beatmaps.Formats new OsuBeatmapProcessor(converted).PreProcess(); new OsuBeatmapProcessor(converted).PostProcess(); - Assert.AreEqual(4, ((IHasComboInformation)converted.HitObjects.ElementAt(0)).ComboIndex); - Assert.AreEqual(5, ((IHasComboInformation)converted.HitObjects.ElementAt(2)).ComboIndex); - Assert.AreEqual(5, ((IHasComboInformation)converted.HitObjects.ElementAt(4)).ComboIndex); - Assert.AreEqual(6, ((IHasComboInformation)converted.HitObjects.ElementAt(6)).ComboIndex); - Assert.AreEqual(11, ((IHasComboInformation)converted.HitObjects.ElementAt(8)).ComboIndex); - Assert.AreEqual(14, ((IHasComboInformation)converted.HitObjects.ElementAt(11)).ComboIndex); + Assert.AreEqual(4, ((IHasComboInformation)converted.HitObjects.ElementAt(0)).ComboIndexWithOffsets); + Assert.AreEqual(5, ((IHasComboInformation)converted.HitObjects.ElementAt(2)).ComboIndexWithOffsets); + Assert.AreEqual(5, ((IHasComboInformation)converted.HitObjects.ElementAt(4)).ComboIndexWithOffsets); + Assert.AreEqual(6, ((IHasComboInformation)converted.HitObjects.ElementAt(6)).ComboIndexWithOffsets); + Assert.AreEqual(11, ((IHasComboInformation)converted.HitObjects.ElementAt(8)).ComboIndexWithOffsets); + Assert.AreEqual(14, ((IHasComboInformation)converted.HitObjects.ElementAt(11)).ComboIndexWithOffsets); } } @@ -346,12 +346,12 @@ namespace osu.Game.Tests.Beatmaps.Formats new CatchBeatmapProcessor(converted).PreProcess(); new CatchBeatmapProcessor(converted).PostProcess(); - Assert.AreEqual(4, ((IHasComboInformation)converted.HitObjects.ElementAt(0)).ComboIndex); - Assert.AreEqual(5, ((IHasComboInformation)converted.HitObjects.ElementAt(2)).ComboIndex); - Assert.AreEqual(5, ((IHasComboInformation)converted.HitObjects.ElementAt(4)).ComboIndex); - Assert.AreEqual(6, ((IHasComboInformation)converted.HitObjects.ElementAt(6)).ComboIndex); - Assert.AreEqual(11, ((IHasComboInformation)converted.HitObjects.ElementAt(8)).ComboIndex); - Assert.AreEqual(14, ((IHasComboInformation)converted.HitObjects.ElementAt(11)).ComboIndex); + Assert.AreEqual(4, ((IHasComboInformation)converted.HitObjects.ElementAt(0)).ComboIndexWithOffsets); + Assert.AreEqual(5, ((IHasComboInformation)converted.HitObjects.ElementAt(2)).ComboIndexWithOffsets); + Assert.AreEqual(5, ((IHasComboInformation)converted.HitObjects.ElementAt(4)).ComboIndexWithOffsets); + Assert.AreEqual(6, ((IHasComboInformation)converted.HitObjects.ElementAt(6)).ComboIndexWithOffsets); + Assert.AreEqual(11, ((IHasComboInformation)converted.HitObjects.ElementAt(8)).ComboIndexWithOffsets); + Assert.AreEqual(14, ((IHasComboInformation)converted.HitObjects.ElementAt(11)).ComboIndexWithOffsets); } } diff --git a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs index 76e5437305..34f70659e3 100644 --- a/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs +++ b/osu.Game.Tests/Gameplay/TestSceneHitObjectAccentColour.cs @@ -100,6 +100,14 @@ namespace osu.Game.Tests.Gameplay set => ComboIndexBindable.Value = value; } + public Bindable ComboIndexWithOffsetsBindable { get; } = new Bindable(); + + public int ComboIndexWithOffsets + { + get => ComboIndexWithOffsetsBindable.Value; + set => ComboIndexWithOffsetsBindable.Value = value; + } + public Bindable LastInComboBindable { get; } = new Bindable(); public bool LastInCombo @@ -129,14 +137,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/NonVisual/ControlPointInfoTest.cs b/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs index b27c257795..240ae4a90c 100644 --- a/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs +++ b/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs @@ -248,13 +248,13 @@ namespace osu.Game.Tests.NonVisual } [Test] - public void TestCreateCopyIsDeepClone() + public void TestDeepClone() { var cpi = new ControlPointInfo(); cpi.Add(1000, new TimingControlPoint { BeatLength = 500 }); - var cpiCopy = cpi.CreateCopy(); + var cpiCopy = cpi.DeepClone(); cpiCopy.Add(2000, new TimingControlPoint { BeatLength = 500 }); diff --git a/osu.Game.Tests/NonVisual/FormatUtilsTest.cs b/osu.Game.Tests/NonVisual/FormatUtilsTest.cs index df095ddee3..d69822cdc5 100644 --- a/osu.Game.Tests/NonVisual/FormatUtilsTest.cs +++ b/osu.Game.Tests/NonVisual/FormatUtilsTest.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Globalization; using NUnit.Framework; using osu.Game.Utils; @@ -20,7 +19,7 @@ namespace osu.Game.Tests.NonVisual [TestCase(1, "100.00%")] public void TestAccuracyFormatting(double input, string expectedOutput) { - Assert.AreEqual(expectedOutput, input.FormatAccuracy(CultureInfo.InvariantCulture)); + Assert.AreEqual(expectedOutput, input.FormatAccuracy().ToString()); } } } diff --git a/osu.Game.Tests/NonVisual/ScoreInfoTest.cs b/osu.Game.Tests/NonVisual/ScoreInfoTest.cs new file mode 100644 index 0000000000..6e5718cd4c --- /dev/null +++ b/osu.Game.Tests/NonVisual/ScoreInfoTest.cs @@ -0,0 +1,33 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; + +namespace osu.Game.Tests.NonVisual +{ + [TestFixture] + public class ScoreInfoTest + { + [Test] + public void TestDeepClone() + { + var score = new ScoreInfo(); + + score.Statistics.Add(HitResult.Good, 10); + score.Rank = ScoreRank.B; + + var scoreCopy = score.DeepClone(); + + score.Statistics[HitResult.Good]++; + score.Rank = ScoreRank.X; + + Assert.That(scoreCopy.Statistics[HitResult.Good], Is.EqualTo(10)); + Assert.That(score.Statistics[HitResult.Good], Is.EqualTo(11)); + + Assert.That(scoreCopy.Rank, Is.EqualTo(ScoreRank.B)); + Assert.That(score.Rank, Is.EqualTo(ScoreRank.X)); + } + } +} diff --git a/osu.Game.Tests/NonVisual/TimeDisplayExtensionTest.cs b/osu.Game.Tests/NonVisual/TimeDisplayExtensionTest.cs new file mode 100644 index 0000000000..97d7880def --- /dev/null +++ b/osu.Game.Tests/NonVisual/TimeDisplayExtensionTest.cs @@ -0,0 +1,41 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Game.Extensions; + +namespace osu.Game.Tests.NonVisual +{ + [TestFixture] + public class TimeDisplayExtensionTest + { + private static readonly object[][] editor_formatted_duration_tests = + { + new object[] { new TimeSpan(0, 0, 0, 0, 50), "00:00:050" }, + new object[] { new TimeSpan(0, 0, 0, 10, 50), "00:10:050" }, + new object[] { new TimeSpan(0, 0, 5, 10), "05:10:000" }, + new object[] { new TimeSpan(0, 1, 5, 10), "65:10:000" }, + }; + + [TestCaseSource(nameof(editor_formatted_duration_tests))] + public void TestEditorFormat(TimeSpan input, string expectedOutput) + { + Assert.AreEqual(expectedOutput, input.ToEditorFormattedString()); + } + + private static readonly object[][] formatted_duration_tests = + { + new object[] { new TimeSpan(0, 0, 10), "00:10" }, + new object[] { new TimeSpan(0, 5, 10), "05:10" }, + new object[] { new TimeSpan(1, 5, 10), "01:05:10" }, + new object[] { new TimeSpan(1, 1, 5, 10), "01:01:05:10" }, + }; + + [TestCaseSource(nameof(formatted_duration_tests))] + public void TestFormattedDuration(TimeSpan input, string expectedOutput) + { + Assert.AreEqual(expectedOutput, input.ToFormattedDuration().ToString()); + } + } +} diff --git a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs index 42848ffc0c..7e7e5ebc45 100644 --- a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs @@ -168,8 +168,8 @@ namespace osu.Game.Tests.Online public override async Task Import(BeatmapSetInfo item, ArchiveReader archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) { - await AllowImport.Task; - return await (CurrentImportTask = base.Import(item, archive, lowPriority, cancellationToken)); + await AllowImport.Task.ConfigureAwait(false); + return await (CurrentImportTask = base.Import(item, archive, lowPriority, cancellationToken)).ConfigureAwait(false); } } 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/Editing/TestSceneEditorComposeRadioButtons.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs index 0b52ae2b95..028509ccd4 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorComposeRadioButtons.cs @@ -13,8 +13,8 @@ namespace osu.Game.Tests.Visual.Editing { public TestSceneEditorComposeRadioButtons() { - RadioButtonCollection collection; - Add(collection = new RadioButtonCollection + EditorRadioButtonCollection collection; + Add(collection = new EditorRadioButtonCollection { Anchor = Anchor.Centre, Origin = Anchor.Centre, diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs index 7ca24346aa..550896270a 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs @@ -2,17 +2,24 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.Timing; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Edit; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Components.RadioButtons; +using osu.Game.Screens.Edit.Compose.Components; using osuTK; namespace osu.Game.Tests.Visual.Editing @@ -20,37 +27,89 @@ namespace osu.Game.Tests.Visual.Editing [TestFixture] public class TestSceneHitObjectComposer : EditorClockTestScene { - [BackgroundDependencyLoader] - private void load() + private OsuHitObjectComposer hitObjectComposer; + private EditorBeatmapContainer editorBeatmapContainer; + + private EditorBeatmap editorBeatmap => editorBeatmapContainer.EditorBeatmap; + + [SetUpSteps] + public void SetUpSteps() { - Beatmap.Value = CreateWorkingBeatmap(new Beatmap + AddStep("create beatmap", () => { - HitObjects = new List + Beatmap.Value = CreateWorkingBeatmap(new Beatmap { - new HitCircle { Position = new Vector2(256, 192), Scale = 0.5f }, - new HitCircle { Position = new Vector2(344, 148), Scale = 0.5f }, - new Slider + HitObjects = new List { - Position = new Vector2(128, 256), - Path = new SliderPath(PathType.Linear, new[] + new HitCircle { Position = new Vector2(256, 192), Scale = 0.5f }, + new HitCircle { Position = new Vector2(344, 148), Scale = 0.5f }, + new Slider { - Vector2.Zero, - new Vector2(216, 0), - }), - Scale = 0.5f, - } - }, + Position = new Vector2(128, 256), + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(216, 0), + }), + Scale = 0.5f, + } + }, + }); }); - var editorBeatmap = new EditorBeatmap(Beatmap.Value.GetPlayableBeatmap(new OsuRuleset().RulesetInfo)); + AddStep("Create composer", () => + { + Child = editorBeatmapContainer = new EditorBeatmapContainer(Beatmap.Value) + { + Child = hitObjectComposer = new OsuHitObjectComposer(new OsuRuleset()) + }; + }); + } - var clock = new DecoupleableInterpolatingFramedClock { IsCoupled = false }; - Dependencies.CacheAs(clock); - Dependencies.CacheAs(clock); - Dependencies.CacheAs(editorBeatmap); - Dependencies.CacheAs(editorBeatmap); + [Test] + public void TestPlacementOnlyWorksWithTiming() + { + AddStep("clear all control points", () => editorBeatmap.ControlPointInfo.Clear()); - Child = new OsuHitObjectComposer(new OsuRuleset()); + AddAssert("Tool is selection", () => hitObjectComposer.ChildrenOfType().First().CurrentTool is SelectTool); + AddAssert("Hitcircle button not clickable", () => !hitObjectComposer.ChildrenOfType().First(d => d.Button.Label == "HitCircle").Enabled.Value); + AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint())); + AddAssert("Hitcircle button is clickable", () => hitObjectComposer.ChildrenOfType().First(d => d.Button.Label == "HitCircle").Enabled.Value); + AddStep("Change to hitcircle", () => hitObjectComposer.ChildrenOfType().First(d => d.Button.Label == "HitCircle").Click()); + AddAssert("Tool changed", () => hitObjectComposer.ChildrenOfType().First().CurrentTool is HitCircleCompositionTool); + } + + public class EditorBeatmapContainer : Container + { + private readonly WorkingBeatmap working; + + public EditorBeatmap EditorBeatmap { get; private set; } + + public EditorBeatmapContainer(WorkingBeatmap working) + { + this.working = working; + + RelativeSizeAxes = Axes.Both; + } + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + + EditorBeatmap = new EditorBeatmap(working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo)); + + dependencies.CacheAs(EditorBeatmap); + dependencies.CacheAs(EditorBeatmap); + + return dependencies; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Add(EditorBeatmap); + } } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs index 7584d67afe..21c5d89aca 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs @@ -13,6 +13,7 @@ using osu.Game.Online.Spectator; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.UI; +using osu.Game.Screens; using osu.Game.Screens.Play; using osu.Game.Tests.Beatmaps.IO; using osu.Game.Tests.Visual.Multiplayer; @@ -25,41 +26,43 @@ namespace osu.Game.Tests.Visual.Gameplay { private readonly User streamingUser = new User { Id = MultiplayerTestScene.PLAYER_1_ID, Username = "Test user" }; - [Cached(typeof(SpectatorClient))] - private TestSpectatorClient testSpectatorClient = new TestSpectatorClient(); - [Cached(typeof(UserLookupCache))] private UserLookupCache lookupCache = new TestUserLookupCache(); // used just to show beatmap card for the time being. protected override bool UseOnlineAPI => true; - private SoloSpectator spectatorScreen; - [Resolved] private OsuGameBase game { get; set; } - private BeatmapSetInfo importedBeatmap; + private TestSpectatorClient spectatorClient; + private SoloSpectator spectatorScreen; + private BeatmapSetInfo importedBeatmap; private int importedBeatmapId; - public override void SetUpSteps() + [SetUpSteps] + public void SetupSteps() { - base.SetUpSteps(); + DependenciesScreen dependenciesScreen = null; + + AddStep("load dependencies", () => + { + spectatorClient = new TestSpectatorClient(); + + // The screen gets suspended so it stops receiving updates. + Child = spectatorClient; + + LoadScreen(dependenciesScreen = new DependenciesScreen(spectatorClient)); + }); + + AddUntilStep("wait for dependencies to load", () => dependenciesScreen.IsLoaded); AddStep("import beatmap", () => { importedBeatmap = ImportBeatmapTest.LoadOszIntoOsu(game, virtualTrack: true).Result; importedBeatmapId = importedBeatmap.Beatmaps.First(b => b.RulesetID == 0).OnlineBeatmapID ?? -1; }); - - AddStep("add streaming client", () => - { - Remove(testSpectatorClient); - Add(testSpectatorClient); - }); - - finish(); } [Test] @@ -206,22 +209,36 @@ namespace osu.Game.Tests.Visual.Gameplay private void waitForPlayer() => AddUntilStep("wait for player", () => (Stack.CurrentScreen as Player)?.IsLoaded == true); - private void start(int? beatmapId = null) => AddStep("start play", () => testSpectatorClient.StartPlay(streamingUser.Id, beatmapId ?? importedBeatmapId)); + private void start(int? beatmapId = null) => AddStep("start play", () => spectatorClient.StartPlay(streamingUser.Id, beatmapId ?? importedBeatmapId)); - private void finish() => AddStep("end play", () => testSpectatorClient.EndPlay(streamingUser.Id)); + private void finish() => AddStep("end play", () => spectatorClient.EndPlay(streamingUser.Id)); private void checkPaused(bool state) => AddUntilStep($"game is {(state ? "paused" : "playing")}", () => player.ChildrenOfType().First().IsPaused.Value == state); private void sendFrames(int count = 10) { - AddStep("send frames", () => testSpectatorClient.SendFrames(streamingUser.Id, count)); + AddStep("send frames", () => spectatorClient.SendFrames(streamingUser.Id, count)); } private void loadSpectatingScreen() { - AddStep("load screen", () => LoadScreen(spectatorScreen = new SoloSpectator(streamingUser))); + AddStep("load spectator", () => LoadScreen(spectatorScreen = new SoloSpectator(streamingUser))); AddUntilStep("wait for screen load", () => spectatorScreen.LoadState == LoadState.Loaded); } + + /// + /// Used for the sole purpose of adding as a resolvable dependency. + /// + private class DependenciesScreen : OsuScreen + { + [Cached(typeof(SpectatorClient))] + public readonly TestSpectatorClient Client; + + public DependenciesScreen(TestSpectatorClient client) + { + Client = client; + } + } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs index ee84a6b6cc..c69890ba33 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs @@ -3,8 +3,11 @@ using System; using System.Linq; +using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Graphics.UserInterface; using osu.Game.Online.Rooms; using osu.Game.Online.Rooms.RoomStatuses; @@ -79,5 +82,27 @@ namespace osu.Game.Tests.Visual.Multiplayer return drawableRoom; } + + [Test] + public void TestEnableAndDisablePassword() + { + DrawableRoom drawableRoom = null; + Room room = null; + + AddStep("create room", () => Child = drawableRoom = new DrawableRoom(room = new Room + { + Name = { Value = "Room with password" }, + Status = { Value = new RoomStatusOpen() }, + Category = { Value = RoomCategory.Realtime }, + }) { MatchingFilter = true }); + + AddAssert("password icon hidden", () => Precision.AlmostEquals(0, drawableRoom.ChildrenOfType().Single().Alpha)); + + AddStep("set password", () => room.Password.Value = "password"); + AddAssert("password icon visible", () => Precision.AlmostEquals(1, drawableRoom.ChildrenOfType().Single().Alpha)); + + AddStep("unset password", () => room.Password.Value = string.Empty); + AddAssert("password icon hidden", () => Precision.AlmostEquals(0, drawableRoom.ChildrenOfType().Single().Alpha)); + } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs index 75cc687ee8..0a23550f5d 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs @@ -4,13 +4,13 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; -using osu.Game.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; using osu.Game.Online.Rooms; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Osu; using osu.Game.Screens.OnlinePlay.Lounge.Components; using osu.Game.Tests.Visual.OnlinePlay; -using osuTK.Graphics; using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer @@ -29,7 +29,6 @@ namespace osu.Game.Tests.Visual.Multiplayer Anchor = Anchor.Centre, Origin = Anchor.Centre, Width = 0.5f, - JoinRequested = joinRequested }; }); @@ -43,11 +42,8 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("has 2 rooms", () => container.Rooms.Count == 2); AddAssert("first room removed", () => container.Rooms.All(r => r.Room.RoomID.Value != 0)); - AddStep("select first room", () => container.Rooms.First().Action?.Invoke()); + AddStep("select first room", () => container.Rooms.First().Click()); AddAssert("first room selected", () => checkRoomSelected(RoomManager.Rooms.First())); - - AddStep("join first room", () => container.Rooms.First().Action?.Invoke()); - AddAssert("first room joined", () => RoomManager.Rooms.First().Status.Value is JoinedRoomStatus); } [Test] @@ -66,9 +62,31 @@ namespace osu.Game.Tests.Visual.Multiplayer press(Key.Down); press(Key.Down); AddAssert("last room selected", () => checkRoomSelected(RoomManager.Rooms.Last())); + } - press(Key.Enter); - AddAssert("last room joined", () => RoomManager.Rooms.Last().Status.Value is JoinedRoomStatus); + [Test] + public void TestKeyboardNavigationAfterOrderChange() + { + AddStep("add rooms", () => RoomManager.AddRooms(3)); + + AddStep("reorder rooms", () => + { + var room = RoomManager.Rooms[1]; + + RoomManager.RemoveRoom(room); + RoomManager.AddRoom(room); + }); + + AddAssert("no selection", () => checkRoomSelected(null)); + + press(Key.Down); + AddAssert("first room selected", () => checkRoomSelected(getRoomInFlow(0))); + + press(Key.Down); + AddAssert("second room selected", () => checkRoomSelected(getRoomInFlow(1))); + + press(Key.Down); + AddAssert("third room selected", () => checkRoomSelected(getRoomInFlow(2))); } [Test] @@ -123,15 +141,15 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("3 rooms visible", () => container.Rooms.Count(r => r.IsPresent) == 3); } + [Test] + public void TestPasswordProtectedRooms() + { + AddStep("add rooms", () => RoomManager.AddRooms(3, withPassword: true)); + } + private bool checkRoomSelected(Room room) => SelectedRoom.Value == room; - private void joinRequested(Room room) => room.Status.Value = new JoinedRoomStatus(); - - private class JoinedRoomStatus : RoomStatus - { - public override string Message => "Joined"; - - public override Color4 GetAppropriateColour(OsuColour colours) => colours.Yellow; - } + private Room getRoomInFlow(int index) => + (container.ChildrenOfType>().First().FlowingChildren.ElementAt(index) as DrawableRoom)?.Room; } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index 2808127bb8..6681703311 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -7,6 +7,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Testing; @@ -22,6 +23,7 @@ using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Lounge; +using osu.Game.Screens.OnlinePlay.Lounge.Components; using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; @@ -77,6 +79,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("load multiplayer", () => LoadScreen(multiplayerScreen)); AddUntilStep("wait for multiplayer to load", () => multiplayerScreen.IsLoaded); + AddUntilStep("wait for lounge to load", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); } [Test] @@ -85,6 +88,154 @@ namespace osu.Game.Tests.Visual.Multiplayer // used to test the flow of multiplayer from visual tests. } + [Test] + public void TestCreateRoomWithoutPassword() + { + createRoom(() => 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 }, + } + } + }); + } + + [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() + { + AddStep("create room", () => + { + multiplayerScreen.RoomManager.AddRoom(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", () => InputManager.Key(Key.Enter)); + + AddUntilStep("wait for room open", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); + AddUntilStep("wait for join", () => client.Room != null); + } + + [Test] + public void TestCreateRoomWithPassword() + { + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + Password = { Value = "password" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }); + + AddAssert("room has password", () => client.APIRoom?.Password.Value == "password"); + } + + [Test] + public void TestJoinRoomWithPassword() + { + AddStep("create room", () => + { + multiplayerScreen.RoomManager.AddRoom(new Room + { + Name = { Value = "Test Room" }, + Password = { Value = "password" }, + 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", () => InputManager.Key(Key.Enter)); + + DrawableRoom.PasswordEntryPopover passwordEntryPopover = null; + AddUntilStep("password prompt appeared", () => (passwordEntryPopover = InputManager.ChildrenOfType().FirstOrDefault()) != null); + AddStep("enter password in text box", () => passwordEntryPopover.ChildrenOfType().First().Text = "password"); + AddStep("press join room button", () => passwordEntryPopover.ChildrenOfType().First().Click()); + + AddUntilStep("wait for room open", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); + AddUntilStep("wait for join", () => client.Room != null); + } + + [Test] + public void TestLocalPasswordUpdatedWhenMultiplayerSettingsChange() + { + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + Password = { Value = "password" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }); + + AddStep("change password", () => client.ChangeSettings(password: "password2")); + AddUntilStep("local password changed", () => client.APIRoom?.Password.Value == "password2"); + } + [Test] public void TestUserSetToIdleWhenBeatmapDeleted() { @@ -280,9 +431,9 @@ namespace osu.Game.Tests.Visual.Multiplayer private class TestMultiplayer : Screens.OnlinePlay.Multiplayer.Multiplayer { - public new TestMultiplayerRoomManager RoomManager { get; private set; } + public new TestRequestHandlingMultiplayerRoomManager RoomManager { get; private set; } - protected override RoomManager CreateRoomManager() => RoomManager = new TestMultiplayerRoomManager(); + protected override RoomManager CreateRoomManager() => RoomManager = new TestRequestHandlingMultiplayerRoomManager(); } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs new file mode 100644 index 0000000000..4ea635fd3e --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerLoungeSubScreen.cs @@ -0,0 +1,105 @@ +// 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.UserInterface; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Lounge; +using osu.Game.Screens.OnlinePlay.Lounge.Components; +using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Tests.Visual.OnlinePlay; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneMultiplayerLoungeSubScreen : OnlinePlayTestScene + { + protected new BasicTestRoomManager RoomManager => (BasicTestRoomManager)base.RoomManager; + + private LoungeSubScreen loungeScreen; + + private Room lastJoinedRoom; + private string lastJoinedPassword; + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("push screen", () => LoadScreen(loungeScreen = new MultiplayerLoungeSubScreen())); + + AddUntilStep("wait for present", () => loungeScreen.IsCurrentScreen()); + + AddStep("bind to event", () => + { + lastJoinedRoom = null; + lastJoinedPassword = null; + RoomManager.JoinRoomRequested = onRoomJoined; + }); + } + + [Test] + public void TestJoinRoomWithoutPassword() + { + AddStep("add room", () => RoomManager.AddRooms(1, withPassword: false)); + AddStep("select room", () => InputManager.Key(Key.Down)); + AddStep("join room", () => InputManager.Key(Key.Enter)); + + AddAssert("room join requested", () => lastJoinedRoom == RoomManager.Rooms.First()); + AddAssert("room join password correct", () => lastJoinedPassword == null); + } + + [Test] + public void TestPopoverHidesOnLeavingScreen() + { + 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", () => InputManager.ChildrenOfType().Any()); + AddStep("exit screen", () => Stack.Exit()); + AddUntilStep("password prompt hidden", () => !InputManager.ChildrenOfType().Any()); + } + + [Test] + public void TestJoinRoomWithPassword() + { + 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 join room button", () => passwordEntryPopover.ChildrenOfType().First().Click()); + + AddAssert("room join requested", () => lastJoinedRoom == RoomManager.Rooms.First()); + 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; + lastJoinedPassword = password; + } + } +} diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 72d01ddd2a..fa2b2b363f 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -355,10 +355,10 @@ namespace osu.Game.Tests.Visual.Navigation public TestMultiplayer() { - Client = new TestMultiplayerClient((TestMultiplayerRoomManager)RoomManager); + Client = new TestMultiplayerClient((TestRequestHandlingMultiplayerRoomManager)RoomManager); } - protected override RoomManager CreateRoomManager() => new TestMultiplayerRoomManager(); + protected override RoomManager CreateRoomManager() => new TestRequestHandlingMultiplayerRoomManager(); } } } 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/Online/TestSceneCurrentlyPlayingDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs index 30785fd163..2f11fec6d1 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs @@ -1,13 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; -using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Database; using osu.Game.Online.Spectator; @@ -21,51 +20,44 @@ namespace osu.Game.Tests.Visual.Online { private readonly User streamingUser = new User { Id = 2, Username = "Test user" }; - [Cached(typeof(SpectatorClient))] - private TestSpectatorClient testSpectatorClient = new TestSpectatorClient(); - + private TestSpectatorClient spectatorClient; private CurrentlyPlayingDisplay currentlyPlaying; - [Cached(typeof(UserLookupCache))] - private UserLookupCache lookupCache = new TestUserLookupCache(); - - private Container nestedContainer; - [SetUpSteps] public void SetUpSteps() { AddStep("add streaming client", () => { - nestedContainer?.Remove(testSpectatorClient); - Remove(lookupCache); + spectatorClient = new TestSpectatorClient(); + var lookupCache = new TestUserLookupCache(); Children = new Drawable[] { lookupCache, - nestedContainer = new Container + spectatorClient, + new DependencyProvidingContainer { RelativeSizeAxes = Axes.Both, - Children = new Drawable[] + CachedDependencies = new (Type, object)[] { - testSpectatorClient, - currentlyPlaying = new CurrentlyPlayingDisplay - { - RelativeSizeAxes = Axes.Both, - } + (typeof(SpectatorClient), spectatorClient), + (typeof(UserLookupCache), lookupCache) + }, + Child = currentlyPlaying = new CurrentlyPlayingDisplay + { + RelativeSizeAxes = Axes.Both, } }, }; }); - - AddStep("Reset players", () => testSpectatorClient.EndPlay(streamingUser.Id)); } [Test] public void TestBasicDisplay() { - AddStep("Add playing user", () => testSpectatorClient.StartPlay(streamingUser.Id, 0)); + AddStep("Add playing user", () => spectatorClient.StartPlay(streamingUser.Id, 0)); AddUntilStep("Panel loaded", () => currentlyPlaying.ChildrenOfType()?.FirstOrDefault()?.User.Id == 2); - AddStep("Remove playing user", () => testSpectatorClient.EndPlay(streamingUser.Id)); + AddStep("Remove playing user", () => spectatorClient.EndPlay(streamingUser.Id)); AddUntilStep("Panel no longer present", () => !currentlyPlaying.ChildrenOfType().Any()); } diff --git a/osu.Game.Tests/Visual/Online/TestSceneDrawableComment.cs b/osu.Game.Tests/Visual/Online/TestSceneDrawableComment.cs new file mode 100644 index 0000000000..7b741accbb --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneDrawableComment.cs @@ -0,0 +1,87 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; +using osu.Game.Overlays.Comments; + +namespace osu.Game.Tests.Visual.Online +{ + public class TestSceneDrawableComment : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + + private Container container; + + [SetUp] + public void SetUp() => Schedule(() => + { + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + }, + container = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + }; + }); + + [TestCaseSource(nameof(comments))] + public void TestComment(string description, string text) + { + AddStep(description, () => + { + comment.Message = text; + container.Add(new DrawableComment(comment)); + }); + } + + private static readonly Comment comment = new Comment + { + Id = 1, + LegacyName = "Test User", + CreatedAt = DateTimeOffset.Now, + VotesCount = 0, + }; + + private static object[] comments = + { + new[] { "Plain", "This is plain comment" }, + new[] { "Link", "Please visit https://osu.ppy.sh" }, + + new[] + { + "Heading", @"# Heading 1 +## Heading 2 +### Heading 3 +#### Heading 4 +##### Heading 5 +###### Heading 6" + }, + + // Taken from https://github.com/ppy/osu/issues/13993#issuecomment-885994077 + new[] + { + "Problematic", @"My tablet doesn't work :( +It's a Huion 420 and it's apparently incompatible with OpenTablet Driver. The warning I get is: ""DeviceInUseException: Device is currently in use by another kernel module. To fix this issue, please follow the instructions from https://github.com/OpenTabletDriver/OpenTabletDriver/wiki/Linux-FAQ#arg umentoutofrangeexception-value-0-15"" and it repeats 4 times on the notification before logging subsequent warnings. +Checking the logs, it looks for other Huion tablets before sending the notification (e.g. + ""2021-07-23 03:52:33 [verbose]: Detect: Searching for tablet 'Huion WH1409 V2' + 20 2021-07-23 03:52:33 [error]: DeviceInUseException: Device is currently in use by another kernel module. To fix this issue, please follow the instructions from https://github.com/OpenTabletDriver/OpenTabletDriver/wiki/Linux-FAQ#arg umentoutofrangeexception-value-0-15"") +I use an Arch based installation of Linux and the tablet runs perfectly with Digimend kernel driver, with area configuration, pen pressure, etc. On osu!lazer the cursor disappears until I set it to ""Borderless"" instead of ""Fullscreen"" and even after it shows up, it goes to the bottom left corner as soon as a map starts. +I have honestly 0 idea of whats going on at this point." + } + }; + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneNewsHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneNewsHeader.cs index 78288bf6e4..994c4fce53 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneNewsHeader.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneNewsHeader.cs @@ -29,7 +29,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestControl() { - AddAssert("Front page selected", () => header.Current.Value == "frontpage"); + AddAssert("Front page selected", () => header.Current.Value == NewsHeader.FrontPageString); AddAssert("1 tab total", () => header.TabCount == 1); AddStep("Set article 1", () => header.SetArticle("1")); @@ -41,7 +41,7 @@ namespace osu.Game.Tests.Visual.Online AddAssert("2 tabs total", () => header.TabCount == 2); AddStep("Set front page", () => header.SetFrontPage()); - AddAssert("Front page selected", () => header.Current.Value == "frontpage"); + AddAssert("Front page selected", () => header.Current.Value == NewsHeader.FrontPageString); AddAssert("1 tab total", () => header.TabCount == 1); } diff --git a/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs index 863fa48ddf..08e61d19f4 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneWikiHeader.cs @@ -7,6 +7,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.Wiki; @@ -43,7 +44,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestWikiHeader() { - AddAssert("Current is index", () => checkCurrent("index")); + AddAssert("Current is index", () => checkCurrent(WikiHeader.IndexPageString)); AddStep("Change wiki page data", () => wikiPageData.Value = new APIWikiPage { @@ -53,8 +54,8 @@ namespace osu.Game.Tests.Visual.Online AddAssert("Current is welcome", () => checkCurrent("Welcome")); AddAssert("Check breadcrumb", checkBreadcrumb); - AddStep("Change current to index", () => header.Current.Value = "index"); - AddAssert("Current is index", () => checkCurrent("index")); + AddStep("Change current to index", () => header.Current.Value = WikiHeader.IndexPageString); + AddAssert("Current is index", () => checkCurrent(WikiHeader.IndexPageString)); AddStep("Change wiki page data", () => wikiPageData.Value = new APIWikiPage { @@ -70,7 +71,7 @@ namespace osu.Game.Tests.Visual.Online AddAssert("Check breadcrumb", checkBreadcrumb); } - private bool checkCurrent(string expectedCurrent) => header.Current.Value == expectedCurrent; + private bool checkCurrent(LocalisableString expectedCurrent) => header.Current.Value == expectedCurrent; private bool checkBreadcrumb() { @@ -96,7 +97,7 @@ namespace osu.Game.Tests.Visual.Online private class TestHeader : WikiHeader { - public IReadOnlyList TabControlItems => TabControl.Items; + public IReadOnlyList TabControlItems => TabControl.Items; } } } diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs index b16b61c5c7..79ba6d9660 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsLoungeSubScreen.cs @@ -10,6 +10,7 @@ using osu.Game.Screens.OnlinePlay.Lounge; using osu.Game.Screens.OnlinePlay.Lounge.Components; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Tests.Visual.OnlinePlay; +using osuTK.Input; namespace osu.Game.Tests.Visual.Playlists { @@ -30,17 +31,35 @@ namespace osu.Game.Tests.Visual.Playlists private RoomsContainer roomsContainer => loungeScreen.ChildrenOfType().First(); + [Test] + public void TestScrollByDraggingRooms() + { + AddStep("reset mouse", () => InputManager.ReleaseButton(MouseButton.Left)); + + AddStep("add rooms", () => RoomManager.AddRooms(30)); + + AddUntilStep("first room is not masked", () => checkRoomVisible(roomsContainer.Rooms[0])); + + AddStep("move mouse to third room", () => InputManager.MoveMouseTo(roomsContainer.Rooms[2])); + AddStep("hold down", () => InputManager.PressButton(MouseButton.Left)); + AddStep("drag to top", () => InputManager.MoveMouseTo(roomsContainer.Rooms[0])); + + AddAssert("first and second room masked", () + => !checkRoomVisible(roomsContainer.Rooms[0]) && + !checkRoomVisible(roomsContainer.Rooms[1])); + } + [Test] public void TestScrollSelectedIntoView() { AddStep("add rooms", () => RoomManager.AddRooms(30)); - AddUntilStep("first room is not masked", () => checkRoomVisible(roomsContainer.Rooms.First())); + AddUntilStep("first room is not masked", () => checkRoomVisible(roomsContainer.Rooms[0])); - AddStep("select last room", () => roomsContainer.Rooms.Last().Action?.Invoke()); + AddStep("select last room", () => roomsContainer.Rooms[^1].Click()); - AddUntilStep("first room is masked", () => !checkRoomVisible(roomsContainer.Rooms.First())); - AddUntilStep("last room is not masked", () => checkRoomVisible(roomsContainer.Rooms.Last())); + AddUntilStep("first room is masked", () => !checkRoomVisible(roomsContainer.Rooms[0])); + AddUntilStep("last room is not masked", () => checkRoomVisible(roomsContainer.Rooms[^1])); } private bool checkRoomVisible(DrawableRoom room) => diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs index a320cb240f..cdc655500d 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsMatchSettingsOverlay.cs @@ -152,7 +152,7 @@ namespace osu.Game.Tests.Visual.Playlists onSuccess?.Invoke(room); } - public void JoinRoom(Room room, Action onSuccess = null, Action onError = null) => throw new NotImplementedException(); + public void JoinRoom(Room room, string password, Action onSuccess = null, Action onError = null) => throw new NotImplementedException(); public void PartRoom() => throw new NotImplementedException(); } 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/SongSelect/TestSceneBeatmapMetadataDisplay.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs index 449401c0bf..66ac700c51 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs @@ -143,9 +143,9 @@ namespace osu.Game.Tests.Visual.SongSelect public override async Task GetDifficultyAsync(BeatmapInfo beatmapInfo, RulesetInfo rulesetInfo = null, IEnumerable mods = null, CancellationToken cancellationToken = default) { if (blockCalculation) - await calculationBlocker.Task; + await calculationBlocker.Task.ConfigureAwait(false); - return await base.GetDifficultyAsync(beatmapInfo, rulesetInfo, mods, cancellationToken); + return await base.GetDifficultyAsync(beatmapInfo, rulesetInfo, mods, cancellationToken).ConfigureAwait(false); } } } diff --git a/osu.Game.Tests/Visual/TestSceneOsuGame.cs b/osu.Game.Tests/Visual/TestSceneOsuGame.cs index 4e5e8517a4..c52d846a68 100644 --- a/osu.Game.Tests/Visual/TestSceneOsuGame.cs +++ b/osu.Game.Tests/Visual/TestSceneOsuGame.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Textures; using osu.Framework.Platform; +using osu.Framework.Testing; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -32,6 +33,7 @@ using osuTK.Graphics; namespace osu.Game.Tests.Visual { [TestFixture] + [HeadlessTest] public class TestSceneOsuGame : OsuTestScene { private IReadOnlyList requiredGameDependencies => new[] @@ -83,10 +85,15 @@ namespace osu.Game.Tests.Visual typeof(PreviewTrackManager), }; + private OsuGame game; + + [Resolved] + private OsuGameBase gameBase { get; set; } + [BackgroundDependencyLoader] - private void load(GameHost host, OsuGameBase gameBase) + private void load(GameHost host) { - OsuGame game = new OsuGame(); + game = new OsuGame(); game.SetHost(host); Children = new Drawable[] @@ -100,7 +107,39 @@ namespace osu.Game.Tests.Visual }; AddUntilStep("wait for load", () => game.IsLoaded); + } + [Test] + public void TestNullRulesetHandled() + { + RulesetInfo ruleset = null; + + AddStep("store current ruleset", () => ruleset = Ruleset.Value); + AddStep("set global ruleset to null value", () => Ruleset.Value = null); + + AddAssert("ruleset still valid", () => Ruleset.Value.Available); + AddAssert("ruleset unchanged", () => ReferenceEquals(Ruleset.Value, ruleset)); + } + + [Test] + public void TestUnavailableRulesetHandled() + { + RulesetInfo ruleset = null; + + AddStep("store current ruleset", () => ruleset = Ruleset.Value); + AddStep("set global ruleset to invalid value", () => Ruleset.Value = new RulesetInfo + { + Name = "unavailable", + Available = false, + }); + + AddAssert("ruleset still valid", () => Ruleset.Value.Available); + AddAssert("ruleset unchanged", () => ReferenceEquals(Ruleset.Value, ruleset)); + } + + [Test] + public void TestAvailableDependencies() + { AddAssert("check OsuGame DI members", () => { foreach (var type in requiredGameDependencies) @@ -111,6 +150,7 @@ namespace osu.Game.Tests.Visual return true; }); + AddAssert("check OsuGameBase DI members", () => { foreach (var type in requiredGameBaseDependencies) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs index 82b7e65c4f..e5bcc08924 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs @@ -5,18 +5,19 @@ using System; using System.Collections.Generic; using System.Linq; using NUnit.Framework; -using osu.Framework.Allocation; using osu.Framework.Audio.Track; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Framework.Timing; +using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; -using osu.Game.Overlays; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Play; using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface @@ -24,37 +25,125 @@ namespace osu.Game.Tests.Visual.UserInterface [TestFixture] public class TestSceneBeatSyncedContainer : OsuTestScene { - private readonly NowPlayingOverlay np; + private TestBeatSyncedContainer beatContainer; - public TestSceneBeatSyncedContainer() + private MasterGameplayClockContainer gameplayClockContainer; + + [SetUpSteps] + public void SetUpSteps() { - Clock = new FramedClock(); - Clock.ProcessFrame(); - - AddRange(new Drawable[] + AddStep("Set beatmap", () => { - new BeatContainer - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - }, - np = new NowPlayingOverlay - { - Origin = Anchor.TopRight, - Anchor = Anchor.TopRight, - } + Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo); }); + + AddStep("Create beat sync container", () => + { + Children = new Drawable[] + { + gameplayClockContainer = new MasterGameplayClockContainer(Beatmap.Value, 0) + { + Child = beatContainer = new TestBeatSyncedContainer + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + }, + } + }; + }); + + AddStep("Start playback", () => gameplayClockContainer.Start()); } - protected override void LoadComplete() + [TestCase(false)] + [TestCase(true)] + public void TestDisallowMistimedEventFiring(bool allowMistimed) { - base.LoadComplete(); - np.ToggleVisibility(); + int? lastBeatIndex = null; + double? lastActuationTime = null; + TimingControlPoint lastTimingPoint = null; + + AddStep($"set mistimed to {(allowMistimed ? "allowed" : "disallowed")}", () => beatContainer.AllowMistimedEventFiring = allowMistimed); + + AddStep("Set time before zero", () => + { + beatContainer.NewBeat = (i, timingControlPoint, effectControlPoint, channelAmplitudes) => + { + lastActuationTime = gameplayClockContainer.CurrentTime; + lastTimingPoint = timingControlPoint; + lastBeatIndex = i; + beatContainer.NewBeat = null; + }; + + gameplayClockContainer.Seek(-1000); + }); + + AddUntilStep("wait for trigger", () => lastBeatIndex != null); + + if (!allowMistimed) + { + AddAssert("trigger is near beat length", () => lastActuationTime != null && lastBeatIndex != null && Precision.AlmostEquals(lastTimingPoint.Time + lastBeatIndex.Value * lastTimingPoint.BeatLength, lastActuationTime.Value, BeatSyncedContainer.MISTIMED_ALLOWANCE)); + } + else + { + AddAssert("trigger is not near beat length", () => lastActuationTime != null && lastBeatIndex != null && !Precision.AlmostEquals(lastTimingPoint.Time + lastBeatIndex.Value * lastTimingPoint.BeatLength, lastActuationTime.Value, BeatSyncedContainer.MISTIMED_ALLOWANCE)); + } } - private class BeatContainer : BeatSyncedContainer + [Test] + public void TestNegativeBeatsStillUsingBeatmapTiming() { - private const int flash_layer_heigth = 150; + int? lastBeatIndex = null; + double? lastBpm = null; + + AddStep("Set time before zero", () => + { + beatContainer.NewBeat = (i, timingControlPoint, effectControlPoint, channelAmplitudes) => + { + lastBeatIndex = i; + lastBpm = timingControlPoint.BPM; + }; + + gameplayClockContainer.Seek(-1000); + }); + + AddUntilStep("wait for trigger", () => lastBpm != null); + AddAssert("bpm is from beatmap", () => lastBpm != null && Precision.AlmostEquals(lastBpm.Value, 128)); + AddAssert("beat index is less than zero", () => lastBeatIndex < 0); + } + + [Test] + public void TestIdleBeatOnPausedClock() + { + double? lastBpm = null; + + AddStep("bind event", () => + { + beatContainer.NewBeat = (i, timingControlPoint, effectControlPoint, channelAmplitudes) => lastBpm = timingControlPoint.BPM; + }); + + AddUntilStep("wait for trigger", () => lastBpm != null); + AddAssert("bpm is from beatmap", () => lastBpm != null && Precision.AlmostEquals(lastBpm.Value, 128)); + + AddStep("pause gameplay clock", () => + { + lastBpm = null; + gameplayClockContainer.Stop(); + }); + + AddUntilStep("wait for trigger", () => lastBpm != null); + AddAssert("bpm is default", () => lastBpm != null && Precision.AlmostEquals(lastBpm.Value, 60)); + } + + private class TestBeatSyncedContainer : BeatSyncedContainer + { + private const int flash_layer_height = 150; + + public new bool AllowMistimedEventFiring + { + get => base.AllowMistimedEventFiring; + set => base.AllowMistimedEventFiring = value; + } private readonly InfoString timingPointCount; private readonly InfoString currentTimingPoint; @@ -64,13 +153,11 @@ namespace osu.Game.Tests.Visual.UserInterface private readonly InfoString adjustedBeatLength; private readonly InfoString timeUntilNextBeat; private readonly InfoString timeSinceLastBeat; + private readonly InfoString currentTime; private readonly Box flashLayer; - [Resolved] - private MusicController musicController { get; set; } - - public BeatContainer() + public TestBeatSyncedContainer() { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -82,7 +169,7 @@ namespace osu.Game.Tests.Visual.UserInterface Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Bottom = flash_layer_heigth }, + Margin = new MarginPadding { Bottom = flash_layer_height }, Children = new Drawable[] { new Box @@ -98,6 +185,7 @@ namespace osu.Game.Tests.Visual.UserInterface Direction = FillDirection.Vertical, Children = new Drawable[] { + currentTime = new InfoString(@"Current time"), timingPointCount = new InfoString(@"Timing points amount"), currentTimingPoint = new InfoString(@"Current timing point"), beatCount = new InfoString(@"Beats amount (in the current timing point)"), @@ -116,7 +204,7 @@ namespace osu.Game.Tests.Visual.UserInterface Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, RelativeSizeAxes = Axes.X, - Height = flash_layer_heigth, + Height = flash_layer_height, Children = new Drawable[] { new Box @@ -133,8 +221,13 @@ namespace osu.Game.Tests.Visual.UserInterface } } }; + } - Beatmap.ValueChanged += delegate + protected override void LoadComplete() + { + base.LoadComplete(); + + Beatmap.BindValueChanged(_ => { timingPointCount.Value = 0; currentTimingPoint.Value = 0; @@ -144,7 +237,7 @@ namespace osu.Game.Tests.Visual.UserInterface adjustedBeatLength.Value = 0; timeUntilNextBeat.Value = 0; timeSinceLastBeat.Value = 0; - }; + }, true); } private List timingPoints => Beatmap.Value.Beatmap.ControlPointInfo.TimingPoints.ToList(); @@ -164,7 +257,7 @@ namespace osu.Game.Tests.Visual.UserInterface if (timingPoints.Count == 0) return 0; if (timingPoints[^1] == current) - return (int)Math.Ceiling((musicController.CurrentTrack.Length - current.Time) / current.BeatLength); + return (int)Math.Ceiling((BeatSyncClock.CurrentTime - current.Time) / current.BeatLength); return (int)Math.Ceiling((getNextTimingPoint(current).Time - current.Time) / current.BeatLength); } @@ -174,8 +267,11 @@ namespace osu.Game.Tests.Visual.UserInterface base.Update(); timeUntilNextBeat.Value = TimeUntilNextBeat; timeSinceLastBeat.Value = TimeSinceLastBeat; + currentTime.Value = BeatSyncClock.CurrentTime; } + public Action NewBeat; + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) { base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); @@ -187,7 +283,9 @@ namespace osu.Game.Tests.Visual.UserInterface beatsPerMinute.Value = 60000 / timingPoint.BeatLength; adjustedBeatLength.Value = timingPoint.BeatLength; - flashLayer.FadeOutFromOne(timingPoint.BeatLength); + flashLayer.FadeOutFromOne(timingPoint.BeatLength / 4); + + NewBeat?.Invoke(beatIndex, timingPoint, effectPoint, amplitudes); } } @@ -200,7 +298,7 @@ namespace osu.Game.Tests.Visual.UserInterface public double Value { - set => valueText.Text = $"{value:G}"; + set => valueText.Text = $"{value:0.##}"; } public InfoString(string header) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledColourPalette.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledColourPalette.cs index 826da17ca8..6fafb8f87a 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledColourPalette.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledColourPalette.cs @@ -1,16 +1,21 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; using osu.Framework.Utils; +using osu.Game.Graphics.Cursor; +using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osuTK.Graphics; +using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneLabelledColourPalette : OsuTestScene + public class TestSceneLabelledColourPalette : OsuManualInputManagerTestScene { private LabelledColourPalette component; @@ -30,21 +35,41 @@ namespace osu.Game.Tests.Visual.UserInterface }, 8); } + [Test] + public void TestUserInteractions() + { + createColourPalette(); + assertColourCount(4); + + clickAddColour(); + assertColourCount(5); + + deleteFirstColour(); + assertColourCount(4); + + clickFirstColour(); + AddAssert("colour picker spawned", () => this.ChildrenOfType().Any()); + } + private void createColourPalette(bool hasDescription = false) { AddStep("create component", () => { - Child = new Container + Child = new OsuContextMenuContainer { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 500, - AutoSizeAxes = Axes.Y, - Child = component = new LabelledColourPalette + RelativeSizeAxes = Axes.Both, + Child = new Container { Anchor = Anchor.Centre, Origin = Anchor.Centre, - ColourNamePrefix = "My colour #" + Width = 500, + AutoSizeAxes = Axes.Y, + Child = component = new LabelledColourPalette + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + ColourNamePrefix = "My colour #" + } } }; @@ -53,18 +78,49 @@ namespace osu.Game.Tests.Visual.UserInterface component.Colours.AddRange(new[] { - Color4.DarkRed, - Color4.Aquamarine, - Color4.Goldenrod, - Color4.Gainsboro + Colour4.DarkRed, + Colour4.Aquamarine, + Colour4.Goldenrod, + Colour4.Gainsboro }); }); } - private Color4 randomColour() => new Color4( + private Colour4 randomColour() => new Color4( RNG.NextSingle(), RNG.NextSingle(), RNG.NextSingle(), 1); + + private void assertColourCount(int count) => AddAssert($"colour count is {count}", () => component.Colours.Count == count); + + private void clickAddColour() => AddStep("click new colour button", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + private void clickFirstColour() => AddStep("click first colour", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + }); + + private void deleteFirstColour() + { + AddStep("right-click first colour", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().First()); + InputManager.Click(MouseButton.Right); + }); + + AddUntilStep("wait for menu", () => this.ChildrenOfType().Any()); + + AddStep("click delete", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + } } } 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.Tests/Visual/UserInterface/TestSceneModSettings.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs index bda1973354..65db2e9644 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs @@ -88,7 +88,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("create mods", () => { original = new OsuModDoubleTime(); - copy = (OsuModDoubleTime)original.CreateCopy(); + copy = (OsuModDoubleTime)original.DeepClone(); }); AddStep("change property", () => original.SpeedChange.Value = 2); @@ -106,7 +106,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("create mods", () => { original = new MultiMod(new OsuModDoubleTime()); - copy = (MultiMod)original.CreateCopy(); + copy = (MultiMod)original.DeepClone(); }); AddStep("change property", () => ((OsuModDoubleTime)original.Mods[0]).SpeedChange.Value = 2); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuPopover.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuPopover.cs new file mode 100644 index 0000000000..1848cf6a5e --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuPopover.cs @@ -0,0 +1,103 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneOsuPopover : OsuGridTestScene + { + public TestSceneOsuPopover() + : base(1, 2) + { + Cell(0, 0).Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new OsuSpriteText + { + Text = @"No OverlayColourProvider", + Font = OsuFont.Default.With(size: 40) + }, + new TriangleButtonWithPopover() + } + }; + + Cell(0, 1).Child = new ColourProvidingContainer(OverlayColourScheme.Orange) + { + RelativeSizeAxes = Axes.Both, + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new OsuSpriteText + { + Text = @"With OverlayColourProvider (orange)", + Font = OsuFont.Default.With(size: 40) + }, + new TriangleButtonWithPopover() + } + } + }; + } + + private class TriangleButtonWithPopover : TriangleButton, IHasPopover + { + public TriangleButtonWithPopover() + { + Width = 100; + Height = 30; + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + Text = @"open"; + Action = this.ShowPopover; + } + + public Popover GetPopover() => new OsuPopover + { + Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + Children = new Drawable[] + { + new OsuSpriteText + { + Text = @"sample text" + }, + new OsuTextBox + { + Width = 150, + Height = 30 + } + } + } + }; + } + + private class ColourProvidingContainer : Container + { + [Cached] + private OverlayColourProvider provider { get; } + + public ColourProvidingContainer(OverlayColourScheme colourScheme) + { + provider = new OverlayColourProvider(colourScheme); + } + } + } +} diff --git a/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs b/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs index d2369056e1..fcc9f44f0c 100644 --- a/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs +++ b/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs @@ -1,21 +1,18 @@ // 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.IO; -using System.Threading; -using System.Threading.Tasks; using NUnit.Framework; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Platform; -using osu.Game.Tournament.Configuration; using osu.Game.Tests; +using osu.Game.Tournament.Configuration; namespace osu.Game.Tournament.Tests.NonVisual { [TestFixture] - public class CustomTourneyDirectoryTest + public class CustomTourneyDirectoryTest : TournamentHostTest { [Test] public void TestDefaultDirectory() @@ -24,7 +21,7 @@ namespace osu.Game.Tournament.Tests.NonVisual { try { - var osu = loadOsu(host); + var osu = LoadTournament(host); var storage = osu.Dependencies.Get(); Assert.That(storage.GetFullPath("."), Is.EqualTo(Path.Combine(host.Storage.GetFullPath("."), "tournaments", "default"))); @@ -54,7 +51,7 @@ namespace osu.Game.Tournament.Tests.NonVisual try { - var osu = loadOsu(host); + var osu = LoadTournament(host); storage = osu.Dependencies.Get(); @@ -111,7 +108,7 @@ namespace osu.Game.Tournament.Tests.NonVisual try { - var osu = loadOsu(host); + var osu = LoadTournament(host); var storage = osu.Dependencies.Get(); @@ -151,25 +148,6 @@ namespace osu.Game.Tournament.Tests.NonVisual } } - private TournamentGameBase loadOsu(GameHost host) - { - var osu = new TournamentGameBase(); - Task.Run(() => host.Run(osu)) - .ContinueWith(t => Assert.Fail($"Host threw exception {t.Exception}"), TaskContinuationOptions.OnlyOnFaulted); - waitForOrAssert(() => osu.IsLoaded, @"osu! failed to start in a reasonable amount of time"); - return osu; - } - - private static void waitForOrAssert(Func result, string failureMessage, int timeout = 90000) - { - Task task = Task.Run(() => - { - while (!result()) Thread.Sleep(200); - }); - - Assert.IsTrue(task.Wait(timeout), failureMessage); - } - private string basePath(string testInstance) => Path.Combine(RuntimeInfo.StartupDirectory, "headless", testInstance); } } diff --git a/osu.Game.Tournament.Tests/NonVisual/DataLoadTest.cs b/osu.Game.Tournament.Tests/NonVisual/DataLoadTest.cs new file mode 100644 index 0000000000..692cb3870c --- /dev/null +++ b/osu.Game.Tournament.Tests/NonVisual/DataLoadTest.cs @@ -0,0 +1,45 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Platform; +using osu.Game.Rulesets; +using osu.Game.Tests; + +namespace osu.Game.Tournament.Tests.NonVisual +{ + public class DataLoadTest : TournamentHostTest + { + [Test] + public void TestUnavailableRuleset() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(nameof(TestUnavailableRuleset))) + { + try + { + var osu = new TestTournament(); + + LoadTournament(host, osu); + var storage = osu.Dependencies.Get(); + + Assert.That(storage.GetFullPath("."), Is.EqualTo(Path.Combine(host.Storage.GetFullPath("."), "tournaments", "default"))); + } + finally + { + host.Exit(); + } + } + } + + public class TestTournament : TournamentGameBase + { + [BackgroundDependencyLoader] + private void load() + { + Ruleset.Value = new RulesetInfo(); // not available + } + } + } +} diff --git a/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs b/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs index e4eb5a36fb..eaa009c180 100644 --- a/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs +++ b/osu.Game.Tournament.Tests/NonVisual/IPCLocationTest.cs @@ -1,10 +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.IO; -using System.Threading; -using System.Threading.Tasks; using NUnit.Framework; using osu.Framework; using osu.Framework.Allocation; @@ -15,7 +12,7 @@ using osu.Game.Tournament.IPC; namespace osu.Game.Tournament.Tests.NonVisual { [TestFixture] - public class IPCLocationTest + public class IPCLocationTest : TournamentHostTest { [Test] public void CheckIPCLocation() @@ -34,11 +31,11 @@ namespace osu.Game.Tournament.Tests.NonVisual try { - var osu = loadOsu(host); + var osu = LoadTournament(host); TournamentStorage storage = (TournamentStorage)osu.Dependencies.Get(); FileBasedIPC ipc = null; - waitForOrAssert(() => (ipc = osu.Dependencies.Get() as FileBasedIPC) != null, @"ipc could not be populated in a reasonable amount of time"); + WaitForOrAssert(() => (ipc = osu.Dependencies.Get() as FileBasedIPC) != null, @"ipc could not be populated in a reasonable amount of time"); Assert.True(ipc.SetIPCLocation(testStableInstallDirectory)); Assert.True(storage.AllTournaments.Exists("stable.json")); @@ -51,24 +48,5 @@ namespace osu.Game.Tournament.Tests.NonVisual } } } - - private TournamentGameBase loadOsu(GameHost host) - { - var osu = new TournamentGameBase(); - Task.Run(() => host.Run(osu)) - .ContinueWith(t => Assert.Fail($"Host threw exception {t.Exception}"), TaskContinuationOptions.OnlyOnFaulted); - waitForOrAssert(() => osu.IsLoaded, @"osu! failed to start in a reasonable amount of time"); - return osu; - } - - private static void waitForOrAssert(Func result, string failureMessage, int timeout = 90000) - { - Task task = Task.Run(() => - { - while (!result()) Thread.Sleep(200); - }); - - Assert.IsTrue(task.Wait(timeout), failureMessage); - } } } diff --git a/osu.Game.Tournament.Tests/NonVisual/TournamentHostTest.cs b/osu.Game.Tournament.Tests/NonVisual/TournamentHostTest.cs new file mode 100644 index 0000000000..b14684200f --- /dev/null +++ b/osu.Game.Tournament.Tests/NonVisual/TournamentHostTest.cs @@ -0,0 +1,33 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Platform; + +namespace osu.Game.Tournament.Tests.NonVisual +{ + public abstract class TournamentHostTest + { + public static TournamentGameBase LoadTournament(GameHost host, TournamentGameBase tournament = null) + { + tournament ??= new TournamentGameBase(); + Task.Run(() => host.Run(tournament)) + .ContinueWith(t => Assert.Fail($"Host threw exception {t.Exception}"), TaskContinuationOptions.OnlyOnFaulted); + WaitForOrAssert(() => tournament.IsLoaded, @"osu! failed to start in a reasonable amount of time"); + return tournament; + } + + public static void WaitForOrAssert(Func result, string failureMessage, int timeout = 90000) + { + Task task = Task.Run(() => + { + while (!result()) Thread.Sleep(200); + }); + + Assert.IsTrue(task.Wait(timeout), failureMessage); + } + } +} diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs index 0da8d1eb4a..bd1bacd549 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneScheduleScreen.cs @@ -28,6 +28,12 @@ namespace osu.Game.Tournament.Tests.Screens setMatchDate(TimeSpan.FromHours(3)); } + [Test] + public void TestNoCurrentMatch() + { + AddStep("Set null current match", () => Ladder.CurrentMatch.Value = null); + } + private void setMatchDate(TimeSpan relativeTime) // Humanizer cannot handle negative timespans. => AddStep($"start time is {relativeTime}", () => diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.cs index d414d8e36e..a18e73e38f 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneSeedingScreen.cs @@ -1,9 +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.Linq; +using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Testing; using osu.Game.Tournament.Models; +using osu.Game.Tournament.Screens.Ladder.Components; using osu.Game.Tournament.Screens.TeamIntro; namespace osu.Game.Tournament.Tests.Screens @@ -11,16 +15,41 @@ namespace osu.Game.Tournament.Tests.Screens public class TestSceneSeedingScreen : TournamentTestScene { [Cached] - private readonly LadderInfo ladder = new LadderInfo(); - - [BackgroundDependencyLoader] - private void load() + private readonly LadderInfo ladder = new LadderInfo { - Add(new SeedingScreen + Teams = + { + new TournamentTeam + { + FullName = { Value = @"Japan" }, + Acronym = { Value = "JPN" }, + SeedingResults = + { + new SeedingResult + { + // Mod intentionally left blank. + Seed = { Value = 4 } + }, + new SeedingResult + { + Mod = { Value = "DT" }, + Seed = { Value = 8 } + } + } + } + } + }; + + [Test] + public void TestBasic() + { + AddStep("create seeding screen", () => Add(new SeedingScreen { FillMode = FillMode.Fit, FillAspectRatio = 16 / 9f - }); + })); + + AddStep("set team to Japan", () => this.ChildrenOfType().Single().Current.Value = ladder.Teams.Single()); } } } diff --git a/osu.Game.Tournament/Components/SongBar.cs b/osu.Game.Tournament/Components/SongBar.cs index cafec0a88b..6080f7b636 100644 --- a/osu.Game.Tournament/Components/SongBar.cs +++ b/osu.Game.Tournament/Components/SongBar.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -11,6 +10,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Legacy; +using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Rulesets; using osu.Game.Screens.Menu; @@ -198,8 +198,8 @@ namespace osu.Game.Tournament.Components Direction = FillDirection.Vertical, Children = new Drawable[] { - new DiffPiece(("Length", TimeSpan.FromMilliseconds(length).ToString(@"mm\:ss"))), - new DiffPiece(("BPM", $"{bpm:0.#}")) + new DiffPiece(("Length", length.ToFormattedDuration().ToString())), + new DiffPiece(("BPM", $"{bpm:0.#}")), } }, new Container diff --git a/osu.Game.Tournament/Screens/BeatmapInfoScreen.cs b/osu.Game.Tournament/Screens/BeatmapInfoScreen.cs index 0a3163ef43..50498304ca 100644 --- a/osu.Game.Tournament/Screens/BeatmapInfoScreen.cs +++ b/osu.Game.Tournament/Screens/BeatmapInfoScreen.cs @@ -11,7 +11,7 @@ using osu.Game.Tournament.IPC; namespace osu.Game.Tournament.Screens { - public abstract class BeatmapInfoScreen : TournamentScreen + public abstract class BeatmapInfoScreen : TournamentMatchScreen { protected readonly SongBar SongBar; diff --git a/osu.Game.Tournament/Screens/Drawings/Components/StorageBackedTeamList.cs b/osu.Game.Tournament/Screens/Drawings/Components/StorageBackedTeamList.cs index f96ec01cbb..5d035a4028 100644 --- a/osu.Game.Tournament/Screens/Drawings/Components/StorageBackedTeamList.cs +++ b/osu.Game.Tournament/Screens/Drawings/Components/StorageBackedTeamList.cs @@ -27,6 +27,9 @@ namespace osu.Game.Tournament.Screens.Drawings.Components { var teams = new List(); + if (!storage.Exists(teams_filename)) + return teams; + try { using (Stream stream = storage.GetStream(teams_filename, FileAccess.Read, FileMode.Open)) diff --git a/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs b/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs index 4c3adeae76..d02e0ebf86 100644 --- a/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs +++ b/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs @@ -9,11 +9,13 @@ using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Tournament.Components; using osu.Game.Tournament.Models; using osu.Game.Tournament.Screens.Drawings.Components; @@ -51,6 +53,29 @@ namespace osu.Game.Tournament.Screens.Drawings if (!TeamList.Teams.Any()) { + LinkFlowContainer links; + + InternalChildren = new Drawable[] + { + new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Height = 0.3f, + }, + new WarningBox("No drawings.txt file found. Please create one and restart the client."), + links = new LinkFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Y = 60, + AutoSizeAxes = Axes.Both + } + }; + + links.AddLink("Click for details on the file format", "https://osu.ppy.sh/wiki/en/Tournament_Drawings", t => t.Colour = Color4.White); return; } diff --git a/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs b/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs index 6e4c6784c8..f61506d7f2 100644 --- a/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs +++ b/osu.Game.Tournament/Screens/Gameplay/GameplayScreen.cs @@ -24,8 +24,6 @@ namespace osu.Game.Tournament.Screens.Gameplay { private readonly BindableBool warmup = new BindableBool(); - private readonly Bindable currentMatch = new Bindable(); - public readonly Bindable State = new Bindable(); private OsuButton warmupButton; private MatchIPCInfo ipc; @@ -131,14 +129,6 @@ namespace osu.Game.Tournament.Screens.Gameplay ladder.ChromaKeyWidth.BindValueChanged(width => chroma.Width = width.NewValue, true); - currentMatch.BindValueChanged(m => - { - warmup.Value = m.NewValue.Team1Score.Value + m.NewValue.Team2Score.Value == 0; - scheduledOperation?.Cancel(); - }); - - currentMatch.BindTo(ladder.CurrentMatch); - warmup.BindValueChanged(w => { warmupButton.Alpha = !w.NewValue ? 0.5f : 1; @@ -146,6 +136,17 @@ namespace osu.Game.Tournament.Screens.Gameplay }, true); } + protected override void CurrentMatchChanged(ValueChangedEvent match) + { + base.CurrentMatchChanged(match); + + if (match.NewValue == null) + return; + + warmup.Value = match.NewValue.Team1Score.Value + match.NewValue.Team2Score.Value == 0; + scheduledOperation?.Cancel(); + } + private ScheduledDelegate scheduledOperation; private MatchScoreDisplay scoreDisplay; @@ -161,9 +162,9 @@ namespace osu.Game.Tournament.Screens.Gameplay if (warmup.Value) return; if (ipc.Score1.Value > ipc.Score2.Value) - currentMatch.Value.Team1Score.Value++; + CurrentMatch.Value.Team1Score.Value++; else - currentMatch.Value.Team2Score.Value++; + CurrentMatch.Value.Team2Score.Value++; } scheduledOperation?.Cancel(); @@ -198,9 +199,9 @@ namespace osu.Game.Tournament.Screens.Gameplay // we should automatically proceed after a short delay if (lastState == TourneyState.Ranking && !warmup.Value) { - if (currentMatch.Value?.Completed.Value == true) + if (CurrentMatch.Value?.Completed.Value == true) scheduledOperation = Scheduler.AddDelayed(() => { sceneManager?.SetScreen(typeof(TeamWinScreen)); }, delay_before_progression); - else if (currentMatch.Value?.Completed.Value == false) + else if (CurrentMatch.Value?.Completed.Value == false) scheduledOperation = Scheduler.AddDelayed(() => { sceneManager?.SetScreen(typeof(MapPoolScreen)); }, delay_before_progression); } diff --git a/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs b/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs index 1c805bb42e..6937c69dbf 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/DrawableTournamentMatch.cs @@ -303,6 +303,15 @@ namespace osu.Game.Tournament.Screens.Ladder.Components Match.LosersProgression.Value = null; ladderInfo.Matches.Remove(Match); + + foreach (var m in ladderInfo.Matches) + { + if (m.Progression.Value == Match) + m.Progression.Value = null; + + if (m.LosersProgression.Value == Match) + m.LosersProgression.Value = null; + } } } } diff --git a/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs b/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs index 2c4fed8d86..d4292c5492 100644 --- a/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs +++ b/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs @@ -21,12 +21,10 @@ using osuTK.Input; namespace osu.Game.Tournament.Screens.MapPool { - public class MapPoolScreen : TournamentScreen + public class MapPoolScreen : TournamentMatchScreen { private readonly FillFlowContainer> mapFlows; - private readonly Bindable currentMatch = new Bindable(); - [Resolved(canBeNull: true)] private TournamentSceneManager sceneManager { get; set; } @@ -96,7 +94,7 @@ namespace osu.Game.Tournament.Screens.MapPool Action = reset }, new ControlPanel.Spacer(), - } + }, } }; } @@ -104,15 +102,12 @@ namespace osu.Game.Tournament.Screens.MapPool [BackgroundDependencyLoader] private void load(MatchIPCInfo ipc) { - currentMatch.BindValueChanged(matchChanged); - currentMatch.BindTo(LadderInfo.CurrentMatch); - ipc.Beatmap.BindValueChanged(beatmapChanged); } private void beatmapChanged(ValueChangedEvent beatmap) { - if (currentMatch.Value == null || currentMatch.Value.PicksBans.Count(p => p.Type == ChoiceType.Ban) < 2) + if (CurrentMatch.Value == null || CurrentMatch.Value.PicksBans.Count(p => p.Type == ChoiceType.Ban) < 2) return; // if bans have already been placed, beatmap changes result in a selection being made autoamtically @@ -137,12 +132,12 @@ namespace osu.Game.Tournament.Screens.MapPool { const TeamColour roll_winner = TeamColour.Red; //todo: draw from match - var nextColour = (currentMatch.Value.PicksBans.LastOrDefault()?.Team ?? roll_winner) == TeamColour.Red ? TeamColour.Blue : TeamColour.Red; + var nextColour = (CurrentMatch.Value.PicksBans.LastOrDefault()?.Team ?? roll_winner) == TeamColour.Red ? TeamColour.Blue : TeamColour.Red; - if (pickType == ChoiceType.Ban && currentMatch.Value.PicksBans.Count(p => p.Type == ChoiceType.Ban) >= 2) + if (pickType == ChoiceType.Ban && CurrentMatch.Value.PicksBans.Count(p => p.Type == ChoiceType.Ban) >= 2) setMode(pickColour, ChoiceType.Pick); else - setMode(nextColour, currentMatch.Value.PicksBans.Count(p => p.Type == ChoiceType.Ban) >= 2 ? ChoiceType.Pick : ChoiceType.Ban); + setMode(nextColour, CurrentMatch.Value.PicksBans.Count(p => p.Type == ChoiceType.Ban) >= 2 ? ChoiceType.Pick : ChoiceType.Ban); } protected override bool OnMouseDown(MouseDownEvent e) @@ -156,11 +151,11 @@ namespace osu.Game.Tournament.Screens.MapPool addForBeatmap(map.Beatmap.OnlineBeatmapID.Value); else { - var existing = currentMatch.Value.PicksBans.FirstOrDefault(p => p.BeatmapID == map.Beatmap.OnlineBeatmapID); + var existing = CurrentMatch.Value.PicksBans.FirstOrDefault(p => p.BeatmapID == map.Beatmap.OnlineBeatmapID); if (existing != null) { - currentMatch.Value.PicksBans.Remove(existing); + CurrentMatch.Value.PicksBans.Remove(existing); setNextMode(); } } @@ -173,7 +168,7 @@ namespace osu.Game.Tournament.Screens.MapPool private void reset() { - currentMatch.Value.PicksBans.Clear(); + CurrentMatch.Value.PicksBans.Clear(); setNextMode(); } @@ -181,18 +176,18 @@ namespace osu.Game.Tournament.Screens.MapPool private void addForBeatmap(int beatmapId) { - if (currentMatch.Value == null) + if (CurrentMatch.Value == null) return; - if (currentMatch.Value.Round.Value.Beatmaps.All(b => b.BeatmapInfo.OnlineBeatmapID != beatmapId)) + if (CurrentMatch.Value.Round.Value.Beatmaps.All(b => b.BeatmapInfo.OnlineBeatmapID != beatmapId)) // don't attempt to add if the beatmap isn't in our pool return; - if (currentMatch.Value.PicksBans.Any(p => p.BeatmapID == beatmapId)) + if (CurrentMatch.Value.PicksBans.Any(p => p.BeatmapID == beatmapId)) // don't attempt to add if already exists. return; - currentMatch.Value.PicksBans.Add(new BeatmapChoice + CurrentMatch.Value.PicksBans.Add(new BeatmapChoice { Team = pickColour, Type = pickType, @@ -201,17 +196,22 @@ namespace osu.Game.Tournament.Screens.MapPool setNextMode(); - if (pickType == ChoiceType.Pick && currentMatch.Value.PicksBans.Any(i => i.Type == ChoiceType.Pick)) + if (pickType == ChoiceType.Pick && CurrentMatch.Value.PicksBans.Any(i => i.Type == ChoiceType.Pick)) { scheduledChange?.Cancel(); scheduledChange = Scheduler.AddDelayed(() => { sceneManager?.SetScreen(typeof(GameplayScreen)); }, 10000); } } - private void matchChanged(ValueChangedEvent match) + protected override void CurrentMatchChanged(ValueChangedEvent match) { + base.CurrentMatchChanged(match); + mapFlows.Clear(); + if (match.NewValue == null) + return; + int totalRows = 0; if (match.NewValue.Round.Value != null) diff --git a/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs b/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs index c1d8c8ddd3..e08be65465 100644 --- a/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs +++ b/osu.Game.Tournament/Screens/Schedule/ScheduleScreen.cs @@ -96,19 +96,18 @@ namespace osu.Game.Tournament.Screens.Schedule } }, }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); - currentMatch.BindValueChanged(matchChanged); currentMatch.BindTo(ladder.CurrentMatch); + currentMatch.BindValueChanged(matchChanged, true); } private void matchChanged(ValueChangedEvent match) { - if (match.NewValue == null) - { - mainContainer.Clear(); - return; - } - var upcoming = ladder.Matches.Where(p => !p.Completed.Value && p.Team1.Value != null && p.Team2.Value != null && Math.Abs(p.Date.Value.DayOfYear - DateTimeOffset.UtcNow.DayOfYear) < 4); var conditionals = ladder .Matches.Where(p => !p.Completed.Value && (p.Team1.Value == null || p.Team2.Value == null) && Math.Abs(p.Date.Value.DayOfYear - DateTimeOffset.UtcNow.DayOfYear) < 4) @@ -117,6 +116,8 @@ namespace osu.Game.Tournament.Screens.Schedule upcoming = upcoming.Concat(conditionals); upcoming = upcoming.OrderBy(p => p.Date.Value).Take(8); + ScheduleContainer comingUpNext; + mainContainer.Child = new FillFlowContainer { RelativeSizeAxes = Axes.Both, @@ -153,57 +154,58 @@ namespace osu.Game.Tournament.Screens.Schedule } } }, - new ScheduleContainer("coming up next") + comingUpNext = new ScheduleContainer("coming up next") { RelativeSizeAxes = Axes.Both, Height = 0.25f, - Children = new Drawable[] - { - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(30), - Children = new Drawable[] - { - new ScheduleMatch(match.NewValue, false) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - }, - new TournamentSpriteTextWithBackground(match.NewValue.Round.Value?.Name.Value) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Scale = new Vector2(0.5f) - }, - new TournamentSpriteText - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Text = match.NewValue.Team1.Value?.FullName + " vs " + match.NewValue.Team2.Value?.FullName, - Font = OsuFont.Torus.With(size: 24, weight: FontWeight.SemiBold) - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Children = new Drawable[] - { - new ScheduleMatchDate(match.NewValue.Date.Value) - { - Font = OsuFont.Torus.With(size: 24, weight: FontWeight.Regular) - } - } - }, - } - }, - } } } }; + + if (match.NewValue != null) + { + comingUpNext.Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(30), + Children = new Drawable[] + { + new ScheduleMatch(match.NewValue, false) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + new TournamentSpriteTextWithBackground(match.NewValue.Round.Value?.Name.Value) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Scale = new Vector2(0.5f) + }, + new TournamentSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = match.NewValue.Team1.Value?.FullName + " vs " + match.NewValue.Team2.Value?.FullName, + Font = OsuFont.Torus.With(size: 24, weight: FontWeight.SemiBold) + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Children = new Drawable[] + { + new ScheduleMatchDate(match.NewValue.Date.Value) + { + Font = OsuFont.Torus.With(size: 24, weight: FontWeight.Regular) + } + } + }, + } + }; + } } public class ScheduleMatch : DrawableTournamentMatch diff --git a/osu.Game.Tournament/Screens/Showcase/ShowcaseScreen.cs b/osu.Game.Tournament/Screens/Showcase/ShowcaseScreen.cs index 9785b7e647..32d458e191 100644 --- a/osu.Game.Tournament/Screens/Showcase/ShowcaseScreen.cs +++ b/osu.Game.Tournament/Screens/Showcase/ShowcaseScreen.cs @@ -2,10 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Tournament.Components; using osu.Framework.Graphics.Shapes; +using osu.Game.Tournament.Models; using osuTK.Graphics; namespace osu.Game.Tournament.Screens.Showcase @@ -39,5 +41,11 @@ namespace osu.Game.Tournament.Screens.Showcase } }); } + + protected override void CurrentMatchChanged(ValueChangedEvent match) + { + // showcase screen doesn't care about a match being selected. + // base call intentionally omitted to not show match warning. + } } } diff --git a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs index 4f66d89b7f..3a0bd232b0 100644 --- a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs +++ b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs @@ -18,12 +18,10 @@ using osuTK; namespace osu.Game.Tournament.Screens.TeamIntro { - public class SeedingScreen : TournamentScreen, IProvideVideo + public class SeedingScreen : TournamentMatchScreen, IProvideVideo { private Container mainContainer; - private readonly Bindable currentMatch = new Bindable(); - private readonly Bindable currentTeam = new Bindable(); [BackgroundDependencyLoader] @@ -50,13 +48,13 @@ namespace osu.Game.Tournament.Screens.TeamIntro { RelativeSizeAxes = Axes.X, Text = "Show first team", - Action = () => currentTeam.Value = currentMatch.Value.Team1.Value, + Action = () => currentTeam.Value = CurrentMatch.Value.Team1.Value, }, new TourneyButton { RelativeSizeAxes = Axes.X, Text = "Show second team", - Action = () => currentTeam.Value = currentMatch.Value.Team2.Value, + Action = () => currentTeam.Value = CurrentMatch.Value.Team2.Value, }, new SettingsTeamDropdown(LadderInfo.Teams) { @@ -67,9 +65,6 @@ namespace osu.Game.Tournament.Screens.TeamIntro } }; - currentMatch.BindValueChanged(matchChanged); - currentMatch.BindTo(LadderInfo.CurrentMatch); - currentTeam.BindValueChanged(teamChanged, true); } @@ -84,8 +79,15 @@ namespace osu.Game.Tournament.Screens.TeamIntro showTeam(team.NewValue); } - private void matchChanged(ValueChangedEvent match) => - currentTeam.Value = currentMatch.Value.Team1.Value; + protected override void CurrentMatchChanged(ValueChangedEvent match) + { + base.CurrentMatchChanged(match); + + if (match.NewValue == null) + return; + + currentTeam.Value = match.NewValue.Team1.Value; + } private void showTeam(TournamentTeam team) { @@ -179,44 +181,48 @@ namespace osu.Game.Tournament.Screens.TeamIntro [BackgroundDependencyLoader] private void load(TextureStore textures) { + FillFlowContainer row; + InternalChildren = new Drawable[] { - new FillFlowContainer + row = new FillFlowContainer { AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(5), - Children = new Drawable[] - { - new Sprite - { - Texture = textures.Get($"mods/{mods.ToLower()}"), - Scale = new Vector2(0.5f) - }, - new Container - { - Size = new Vector2(50, 16), - CornerRadius = 10, - Masking = true, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = TournamentGame.ELEMENT_BACKGROUND_COLOUR, - }, - new TournamentSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Text = seeding.ToString("#,0"), - Colour = TournamentGame.ELEMENT_FOREGROUND_COLOUR - }, - } - }, - } }, }; + + if (!string.IsNullOrEmpty(mods)) + { + row.Add(new Sprite + { + Texture = textures.Get($"mods/{mods.ToLower()}"), + Scale = new Vector2(0.5f) + }); + } + + row.Add(new Container + { + Size = new Vector2(50, 16), + CornerRadius = 10, + Masking = true, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = TournamentGame.ELEMENT_BACKGROUND_COLOUR, + }, + new TournamentSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Text = seeding.ToString("#,0"), + Colour = TournamentGame.ELEMENT_FOREGROUND_COLOUR + }, + } + }); } } } diff --git a/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs b/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs index 6c2848897b..74957cbca5 100644 --- a/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs +++ b/osu.Game.Tournament/Screens/TeamIntro/TeamIntroScreen.cs @@ -12,12 +12,10 @@ using osuTK; namespace osu.Game.Tournament.Screens.TeamIntro { - public class TeamIntroScreen : TournamentScreen, IProvideVideo + public class TeamIntroScreen : TournamentMatchScreen, IProvideVideo { private Container mainContainer; - private readonly Bindable currentMatch = new Bindable(); - [BackgroundDependencyLoader] private void load(Storage storage) { @@ -35,18 +33,16 @@ namespace osu.Game.Tournament.Screens.TeamIntro RelativeSizeAxes = Axes.Both, } }; - - currentMatch.BindValueChanged(matchChanged); - currentMatch.BindTo(LadderInfo.CurrentMatch); } - private void matchChanged(ValueChangedEvent match) + protected override void CurrentMatchChanged(ValueChangedEvent match) { + base.CurrentMatchChanged(match); + + mainContainer.Clear(); + if (match.NewValue == null) - { - mainContainer.Clear(); return; - } const float y_flag_offset = 292; diff --git a/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs b/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs index 7ca262a2e8..ebe2908b74 100644 --- a/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs +++ b/osu.Game.Tournament/Screens/TeamWin/TeamWinScreen.cs @@ -13,11 +13,10 @@ using osuTK; namespace osu.Game.Tournament.Screens.TeamWin { - public class TeamWinScreen : TournamentScreen, IProvideVideo + public class TeamWinScreen : TournamentMatchScreen, IProvideVideo { private Container mainContainer; - private readonly Bindable currentMatch = new Bindable(); private readonly Bindable currentCompleted = new Bindable(); private TourneyVideo blueWinVideo; @@ -48,17 +47,19 @@ namespace osu.Game.Tournament.Screens.TeamWin } }; - currentMatch.BindValueChanged(matchChanged); - currentMatch.BindTo(ladder.CurrentMatch); - currentCompleted.BindValueChanged(_ => update()); } - private void matchChanged(ValueChangedEvent match) + protected override void CurrentMatchChanged(ValueChangedEvent match) { - currentCompleted.UnbindBindings(); - currentCompleted.BindTo(match.NewValue.Completed); + base.CurrentMatchChanged(match); + currentCompleted.UnbindBindings(); + + if (match.NewValue == null) + return; + + currentCompleted.BindTo(match.NewValue.Completed); update(); } @@ -66,7 +67,7 @@ namespace osu.Game.Tournament.Screens.TeamWin private void update() => Schedule(() => { - var match = currentMatch.Value; + var match = CurrentMatch.Value; if (match.Winner == null) { diff --git a/osu.Game.Tournament/Screens/TournamentMatchScreen.cs b/osu.Game.Tournament/Screens/TournamentMatchScreen.cs new file mode 100644 index 0000000000..5f00036653 --- /dev/null +++ b/osu.Game.Tournament/Screens/TournamentMatchScreen.cs @@ -0,0 +1,34 @@ +// 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.Bindables; +using osu.Game.Tournament.Models; + +namespace osu.Game.Tournament.Screens +{ + public abstract class TournamentMatchScreen : TournamentScreen + { + protected readonly Bindable CurrentMatch = new Bindable(); + private WarningBox noMatchWarning; + + protected override void LoadComplete() + { + base.LoadComplete(); + + CurrentMatch.BindTo(LadderInfo.CurrentMatch); + CurrentMatch.BindValueChanged(CurrentMatchChanged, true); + } + + protected virtual void CurrentMatchChanged(ValueChangedEvent match) + { + if (match.NewValue == null) + { + AddInternal(noMatchWarning = new WarningBox("Choose a match first from the brackets screen")); + return; + } + + noMatchWarning?.Expire(); + noMatchWarning = null; + } + } +} diff --git a/osu.Game.Tournament/TournamentGame.cs b/osu.Game.Tournament/TournamentGame.cs index 87e23e3404..cd0e601a2f 100644 --- a/osu.Game.Tournament/TournamentGame.cs +++ b/osu.Game.Tournament/TournamentGame.cs @@ -97,7 +97,12 @@ namespace osu.Game.Tournament }, } }, - heightWarning = new WarningBox("Please make the window wider"), + heightWarning = new WarningBox("Please make the window wider") + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Margin = new MarginPadding(20), + }, new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, diff --git a/osu.Game.Tournament/TournamentGameBase.cs b/osu.Game.Tournament/TournamentGameBase.cs index 92eb7ac713..531da00faf 100644 --- a/osu.Game.Tournament/TournamentGameBase.cs +++ b/osu.Game.Tournament/TournamentGameBase.cs @@ -66,7 +66,9 @@ namespace osu.Game.Tournament } ladder ??= new LadderInfo(); - ladder.Ruleset.Value ??= RulesetStore.AvailableRulesets.First(); + + ladder.Ruleset.Value = RulesetStore.GetRuleset(ladder.Ruleset.Value?.ShortName) + ?? RulesetStore.AvailableRulesets.First(); bool addedInfo = false; diff --git a/osu.Game/.editorconfig b/osu.Game/.editorconfig index 46a3dafd04..4107d1bb35 100644 --- a/osu.Game/.editorconfig +++ b/osu.Game/.editorconfig @@ -1,2 +1,3 @@ [*.cs] -dotnet_diagnostic.OLOC001.prefix_namespace = osu.Game.Resources.Localisation \ No newline at end of file +dotnet_diagnostic.OLOC001.prefix_namespace = osu.Game.Resources.Localisation +dotnet_diagnostic.OLOC001.license_header = // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.\n// See the LICENCE file in the repository root for full licence text. \ No newline at end of file diff --git a/osu.Game/Beatmaps/BeatmapProcessor.cs b/osu.Game/Beatmaps/BeatmapProcessor.cs index b7b5adc52e..cdeaab06ed 100644 --- a/osu.Game/Beatmaps/BeatmapProcessor.cs +++ b/osu.Game/Beatmaps/BeatmapProcessor.cs @@ -34,19 +34,19 @@ namespace osu.Game.Beatmaps isFirst = false; } + obj.ComboIndex = lastObj?.ComboIndex ?? 0; + obj.ComboIndexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0; + obj.IndexInCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0; + if (obj.NewCombo) { obj.IndexInCurrentCombo = 0; - obj.ComboIndex = (lastObj?.ComboIndex ?? 0) + obj.ComboOffset + 1; + obj.ComboIndex++; + obj.ComboIndexWithOffsets += obj.ComboOffset + 1; if (lastObj != null) lastObj.LastInCombo = true; } - else if (lastObj != null) - { - obj.IndexInCurrentCombo = lastObj.IndexInCurrentCombo + 1; - obj.ComboIndex = lastObj.ComboIndex; - } lastObj = obj; } diff --git a/osu.Game/Beatmaps/BeatmapSetOnlineStatus.cs b/osu.Game/Beatmaps/BeatmapSetOnlineStatus.cs index ae5a44cfcd..edaf044466 100644 --- a/osu.Game/Beatmaps/BeatmapSetOnlineStatus.cs +++ b/osu.Game/Beatmaps/BeatmapSetOnlineStatus.cs @@ -1,17 +1,34 @@ // 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.Localisation; +using osu.Game.Resources.Localisation.Web; + namespace osu.Game.Beatmaps { public enum BeatmapSetOnlineStatus { None = -3, + + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowStatusGraveyard))] Graveyard = -2, + + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowStatusWip))] WIP = -1, + + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowStatusPending))] Pending = 0, + + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowStatusRanked))] Ranked = 1, + + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowStatusApproved))] Approved = 2, + + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowStatusQualified))] Qualified = 3, + + [LocalisableDescription(typeof(BeatmapsetsStrings), nameof(BeatmapsetsStrings.ShowStatusLoved))] Loved = 4, } diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs index e8dc623ddb..643c5d9adb 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs @@ -3,11 +3,12 @@ using System; using osu.Game.Graphics; +using osu.Game.Utils; using osuTK.Graphics; namespace osu.Game.Beatmaps.ControlPoints { - public abstract class ControlPoint : IComparable + public abstract class ControlPoint : IComparable, IDeepCloneable { /// /// The time at which the control point takes effect. @@ -32,7 +33,7 @@ namespace osu.Game.Beatmaps.ControlPoints /// /// Create an unbound copy of this control point. /// - public ControlPoint CreateCopy() + public ControlPoint DeepClone() { var copy = (ControlPoint)Activator.CreateInstance(GetType()); diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs index 25d0843a71..2d0fc17a7b 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs @@ -10,11 +10,12 @@ using osu.Framework.Bindables; using osu.Framework.Lists; using osu.Framework.Utils; using osu.Game.Screens.Edit; +using osu.Game.Utils; namespace osu.Game.Beatmaps.ControlPoints { [Serializable] - public class ControlPointInfo + public class ControlPointInfo : IDeepCloneable { /// /// All control points grouped by time. @@ -350,12 +351,12 @@ namespace osu.Game.Beatmaps.ControlPoints } } - public ControlPointInfo CreateCopy() + public ControlPointInfo DeepClone() { var controlPointInfo = new ControlPointInfo(); foreach (var point in AllControlPoints) - controlPointInfo.Add(point.Time, point.CreateCopy()); + controlPointInfo.Add(point.Time, point.DeepClone()); return controlPointInfo; } 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/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs index acbf57d25f..f14f6ec10c 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs @@ -251,11 +251,8 @@ namespace osu.Game.Beatmaps.Formats switch (beatmap.BeatmapInfo.RulesetID) { case 0: - position = ((IHasPosition)hitObject).Position; - break; - case 2: - position.X = ((IHasXPosition)hitObject).X; + position = ((IHasPosition)hitObject).Position; break; case 3: diff --git a/osu.Game/Extensions/EditorDisplayExtensions.cs b/osu.Game/Extensions/EditorDisplayExtensions.cs deleted file mode 100644 index f749b88b46..0000000000 --- a/osu.Game/Extensions/EditorDisplayExtensions.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; - -namespace osu.Game.Extensions -{ - public static class EditorDisplayExtensions - { - /// - /// Get an editor formatted string (mm:ss:mss) - /// - /// A time value in milliseconds. - /// An editor formatted display string. - public static string ToEditorFormattedString(this double milliseconds) => - ToEditorFormattedString(TimeSpan.FromMilliseconds(milliseconds)); - - /// - /// Get an editor formatted string (mm:ss:mss) - /// - /// A time value. - /// An editor formatted display string. - public static string ToEditorFormattedString(this TimeSpan timeSpan) => - $"{(timeSpan < TimeSpan.Zero ? "-" : string.Empty)}{timeSpan:mm\\:ss\\:fff}"; - } -} diff --git a/osu.Game/Extensions/TimeDisplayExtensions.cs b/osu.Game/Extensions/TimeDisplayExtensions.cs new file mode 100644 index 0000000000..dc05482a05 --- /dev/null +++ b/osu.Game/Extensions/TimeDisplayExtensions.cs @@ -0,0 +1,51 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Localisation; + +namespace osu.Game.Extensions +{ + public static class TimeDisplayExtensions + { + /// + /// Get an editor formatted string (mm:ss:mss) + /// + /// A time value in milliseconds. + /// An editor formatted display string. + public static string ToEditorFormattedString(this double milliseconds) => + ToEditorFormattedString(TimeSpan.FromMilliseconds(milliseconds)); + + /// + /// Get an editor formatted string (mm:ss:mss) + /// + /// A time value. + /// An editor formatted display string. + public static string ToEditorFormattedString(this TimeSpan timeSpan) => + $"{(timeSpan < TimeSpan.Zero ? "-" : string.Empty)}{(int)timeSpan.TotalMinutes:00}:{timeSpan:ss\\:fff}"; + + /// + /// Get a formatted duration (dd:hh:mm:ss with days/hours omitted if zero). + /// + /// A duration in milliseconds. + /// A formatted duration string. + public static LocalisableString ToFormattedDuration(this double milliseconds) => + ToFormattedDuration(TimeSpan.FromMilliseconds(milliseconds)); + + /// + /// Get a formatted duration (dd:hh:mm:ss with days/hours omitted if zero). + /// + /// A duration value. + /// A formatted duration string. + public static LocalisableString ToFormattedDuration(this TimeSpan timeSpan) + { + if (timeSpan.TotalDays >= 1) + return new LocalisableFormattableString(timeSpan, @"dd\:hh\:mm\:ss"); + + if (timeSpan.TotalHours >= 1) + return new LocalisableFormattableString(timeSpan, @"hh\:mm\:ss"); + + return new LocalisableFormattableString(timeSpan, @"mm\:ss"); + } + } +} diff --git a/osu.Game/Graphics/Containers/BeatSyncedContainer.cs b/osu.Game/Graphics/Containers/BeatSyncedContainer.cs index e2a0c09a6b..6e4901ab1a 100644 --- a/osu.Game/Graphics/Containers/BeatSyncedContainer.cs +++ b/osu.Game/Graphics/Containers/BeatSyncedContainer.cs @@ -1,19 +1,32 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Audio.Track; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; +using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Screens.Play; namespace osu.Game.Graphics.Containers { + /// + /// A container which fires a callback when a new beat is reached. + /// Consumes a parent or (whichever is first available). + /// + /// + /// This container does not set its own clock to the source used for beat matching. + /// This means that if the beat source clock is playing faster or slower, animations may unexpectedly overlap. + /// Make sure this container's Clock is also set to the expected source (or within a parent element which provides this). + /// + /// This container will also trigger beat events when the beat matching clock is paused at 's BPM. + /// public class BeatSyncedContainer : Container { - protected readonly IBindable Beatmap = new Bindable(); - private int lastBeat; private TimingControlPoint lastTimingPoint; @@ -23,6 +36,19 @@ namespace osu.Game.Graphics.Containers /// protected double EarlyActivationMilliseconds; + /// + /// While this container automatically applied an animation delay (meaning any animations inside a implementation will + /// always be correctly timed), the event itself can potentially fire away from the related beat. + /// + /// By setting this to false, cases where the event is to be fired more than from the related beat will be skipped. + /// + protected bool AllowMistimedEventFiring = true; + + /// + /// The maximum deviance from the actual beat that an can fire when is set to false. + /// + public const double MISTIMED_ALLOWANCE = 16; + /// /// The time in milliseconds until the next beat. /// @@ -43,16 +69,49 @@ namespace osu.Game.Graphics.Containers /// public double MinimumBeatLength { get; set; } + /// + /// Whether this container is currently tracking a beatmap's timing data. + /// protected bool IsBeatSyncedWithTrack { get; private set; } + protected virtual void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + } + + [Resolved] + protected IBindable Beatmap { get; private set; } + + [Resolved(canBeNull: true)] + protected GameplayClock GameplayClock { get; private set; } + + protected IClock BeatSyncClock + { + get + { + if (GameplayClock != null) + return GameplayClock; + + if (Beatmap.Value.TrackLoaded) + return Beatmap.Value.Track; + + return null; + } + } + protected override void Update() { ITrack track = null; IBeatmap beatmap = null; - double currentTrackTime = 0; - TimingControlPoint timingPoint = null; - EffectControlPoint effectPoint = null; + TimingControlPoint timingPoint; + EffectControlPoint effectPoint; + + IClock clock = BeatSyncClock; + + if (clock == null) + return; + + double currentTrackTime = clock.CurrentTime; if (Beatmap.Value.TrackLoaded && Beatmap.Value.BeatmapLoaded) { @@ -60,23 +119,26 @@ namespace osu.Game.Graphics.Containers beatmap = Beatmap.Value.Beatmap; } - if (track != null && beatmap != null && track.IsRunning && track.Length > 0) + IsBeatSyncedWithTrack = beatmap != null && clock.IsRunning && track?.Length > 0; + + if (IsBeatSyncedWithTrack) { - currentTrackTime = track.CurrentTime + EarlyActivationMilliseconds; + Debug.Assert(beatmap != null); timingPoint = beatmap.ControlPointInfo.TimingPointAt(currentTrackTime); effectPoint = beatmap.ControlPointInfo.EffectPointAt(currentTrackTime); } - - IsBeatSyncedWithTrack = timingPoint?.BeatLength > 0; - - if (timingPoint == null || !IsBeatSyncedWithTrack) + else { + // this may be the case where the beat syncing clock has been paused. + // we still want to show an idle animation, so use this container's time instead. currentTrackTime = Clock.CurrentTime; timingPoint = TimingControlPoint.DEFAULT; effectPoint = EffectControlPoint.DEFAULT; } + currentTrackTime += EarlyActivationMilliseconds; + double beatLength = timingPoint.BeatLength / Divisor; while (beatLength < MinimumBeatLength) @@ -89,7 +151,7 @@ namespace osu.Game.Graphics.Containers beatIndex--; TimeUntilNextBeat = (timingPoint.Time - currentTrackTime) % beatLength; - if (TimeUntilNextBeat < 0) + if (TimeUntilNextBeat <= 0) TimeUntilNextBeat += beatLength; TimeSinceLastBeat = beatLength - TimeUntilNextBeat; @@ -97,21 +159,16 @@ namespace osu.Game.Graphics.Containers if (timingPoint == lastTimingPoint && beatIndex == lastBeat) return; - using (BeginDelayedSequence(-TimeSinceLastBeat)) - OnNewBeat(beatIndex, timingPoint, effectPoint, track?.CurrentAmplitudes ?? ChannelAmplitudes.Empty); + // as this event is sometimes used for sound triggers where `BeginDelayedSequence` has no effect, avoid firing it if too far away from the beat. + // this can happen after a seek operation. + if (AllowMistimedEventFiring || Math.Abs(TimeSinceLastBeat) < MISTIMED_ALLOWANCE) + { + using (BeginDelayedSequence(-TimeSinceLastBeat)) + OnNewBeat(beatIndex, timingPoint, effectPoint, track?.CurrentAmplitudes ?? ChannelAmplitudes.Empty); + } lastBeat = beatIndex; lastTimingPoint = timingPoint; } - - [BackgroundDependencyLoader] - private void load(IBindable beatmap) - { - Beatmap.BindTo(beatmap); - } - - protected virtual void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) - { - } } } diff --git a/osu.Game/Graphics/Containers/LinkFlowContainer.cs b/osu.Game/Graphics/Containers/LinkFlowContainer.cs index 5ff2fdf6b2..85ef779e48 100644 --- a/osu.Game/Graphics/Containers/LinkFlowContainer.cs +++ b/osu.Game/Graphics/Containers/LinkFlowContainer.cs @@ -10,6 +10,7 @@ using System.Collections.Generic; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Localisation; +using osu.Framework.Platform; using osu.Game.Graphics.Sprites; using osu.Game.Users; @@ -25,6 +26,9 @@ namespace osu.Game.Graphics.Containers [Resolved(CanBeNull = true)] private OsuGame game { get; set; } + [Resolved] + private GameHost host { get; set; } + public void AddLinks(string text, List links) { if (string.IsNullOrEmpty(text) || links == null) @@ -91,8 +95,11 @@ namespace osu.Game.Graphics.Containers { if (action != null) action(); - else - game?.HandleLink(link); + else if (game != null) + game.HandleLink(link); + // fallback to handle cases where OsuGame is not available, ie. tournament client. + else if (link.Action == LinkAction.External) + host.OpenUrlExternally(link.Argument); }, }); } diff --git a/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs b/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs index 911d47704a..d43c3a608b 100644 --- a/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs +++ b/osu.Game/Graphics/Containers/OsuRearrangeableListItem.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; using osuTK; using osuTK.Graphics; @@ -59,33 +60,37 @@ namespace osu.Game.Graphics.Containers [BackgroundDependencyLoader] private void load() { - InternalChild = new GridContainer + InternalChildren = new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Content = new[] + new GridContainer { - new[] + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] { - handleContainer = new Container + new[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, - Padding = new MarginPadding { Horizontal = 5 }, - Child = handle = new PlaylistItemHandle + handleContainer = new Container { - Size = new Vector2(12), - Colour = HandleColour, - AlwaysPresent = true, - Alpha = 0 - } - }, - CreateContent() - } + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = 5 }, + Child = handle = new PlaylistItemHandle + { + Size = new Vector2(12), + Colour = HandleColour, + AlwaysPresent = true, + Alpha = 0 + } + }, + CreateContent() + } + }, + ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) } }, - ColumnDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, - RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) } + new HoverClickSounds() }; } diff --git a/osu.Game/Graphics/UserInterface/BackButton.cs b/osu.Game/Graphics/UserInterface/BackButton.cs index b941e5fcbd..b8196c6360 100644 --- a/osu.Game/Graphics/UserInterface/BackButton.cs +++ b/osu.Game/Graphics/UserInterface/BackButton.cs @@ -20,7 +20,7 @@ namespace osu.Game.Graphics.UserInterface { Size = TwoLayerButton.SIZE_EXTENDED; - Child = button = new TwoLayerButton + Child = button = new TwoLayerButton(HoverSampleSet.Submit) { Anchor = Anchor.TopLeft, Origin = Anchor.TopLeft, diff --git a/osu.Game/Graphics/UserInterface/DialogButton.cs b/osu.Game/Graphics/UserInterface/DialogButton.cs index 2d75dad828..2f9e4dae51 100644 --- a/osu.Game/Graphics/UserInterface/DialogButton.cs +++ b/osu.Game/Graphics/UserInterface/DialogButton.cs @@ -56,6 +56,7 @@ namespace osu.Game.Graphics.UserInterface private Vector2 hoverSpacing => new Vector2(3f, 0f); public DialogButton() + : base(HoverSampleSet.Submit) { RelativeSizeAxes = Axes.X; diff --git a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs index 6ad88eaaba..0df69a5b54 100644 --- a/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs +++ b/osu.Game/Graphics/UserInterface/ExternalLinkButton.cs @@ -23,14 +23,20 @@ namespace osu.Game.Graphics.UserInterface [Resolved] private GameHost host { get; set; } + private readonly SpriteIcon linkIcon; + public ExternalLinkButton(string link = null) { Link = link; Size = new Vector2(12); - InternalChild = new SpriteIcon + InternalChildren = new Drawable[] { - Icon = FontAwesome.Solid.ExternalLinkAlt, - RelativeSizeAxes = Axes.Both + linkIcon = new SpriteIcon + { + Icon = FontAwesome.Solid.ExternalLinkAlt, + RelativeSizeAxes = Axes.Both + }, + new HoverClickSounds(HoverSampleSet.Submit) }; } @@ -42,13 +48,13 @@ namespace osu.Game.Graphics.UserInterface protected override bool OnHover(HoverEvent e) { - InternalChild.FadeColour(hoverColour, 500, Easing.OutQuint); + linkIcon.FadeColour(hoverColour, 500, Easing.OutQuint); return base.OnHover(e); } protected override void OnHoverLost(HoverLostEvent e) { - InternalChild.FadeColour(Color4.White, 500, Easing.OutQuint); + linkIcon.FadeColour(Color4.White, 500, Easing.OutQuint); base.OnHoverLost(e); } diff --git a/osu.Game/Graphics/UserInterface/HoverSampleSet.cs b/osu.Game/Graphics/UserInterface/HoverSampleSet.cs index b88f81a143..a5ea6fcfbf 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("submit")] + Submit, + [Description("button")] Button, diff --git a/osu.Game/Graphics/UserInterface/OsuTabControl.cs b/osu.Game/Graphics/UserInterface/OsuTabControl.cs index c447d7f609..3572ea5c31 100644 --- a/osu.Game/Graphics/UserInterface/OsuTabControl.cs +++ b/osu.Game/Graphics/UserInterface/OsuTabControl.cs @@ -14,6 +14,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Graphics.Sprites; @@ -153,6 +154,27 @@ namespace osu.Game.Graphics.UserInterface AutoSizeAxes = Axes.X; RelativeSizeAxes = Axes.Y; + LocalisableString text; + + switch (value) + { + case IHasDescription hasDescription: + text = hasDescription.GetDescription(); + break; + + case Enum e: + text = e.GetLocalisableDescription(); + break; + + case LocalisableString l: + text = l; + break; + + default: + text = value.ToString(); + break; + } + Children = new Drawable[] { Text = new OsuSpriteText @@ -160,7 +182,7 @@ namespace osu.Game.Graphics.UserInterface Margin = new MarginPadding { Top = 5, Bottom = 5 }, Origin = Anchor.BottomLeft, Anchor = Anchor.BottomLeft, - Text = (value as IHasDescription)?.Description ?? (value as Enum)?.GetLocalisableDescription() ?? value.ToString(), + Text = text, Font = OsuFont.GetFont(size: 14) }, Bar = new Box diff --git a/osu.Game/Graphics/UserInterface/PercentageCounter.cs b/osu.Game/Graphics/UserInterface/PercentageCounter.cs index 2d53ec066b..0ebf2849fe 100644 --- a/osu.Game/Graphics/UserInterface/PercentageCounter.cs +++ b/osu.Game/Graphics/UserInterface/PercentageCounter.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; using osu.Game.Utils; @@ -27,7 +28,7 @@ namespace osu.Game.Graphics.UserInterface Current.Value = DisplayedCount = 1.0f; } - protected override string FormatCount(double count) => count.FormatAccuracy(); + protected override LocalisableString FormatCount(double count) => count.FormatAccuracy(); protected override double GetProportionalDuration(double currentValue, double newValue) { diff --git a/osu.Game/Graphics/UserInterface/RollingCounter.cs b/osu.Game/Graphics/UserInterface/RollingCounter.cs index b96181416d..244658b75e 100644 --- a/osu.Game/Graphics/UserInterface/RollingCounter.cs +++ b/osu.Game/Graphics/UserInterface/RollingCounter.cs @@ -10,6 +10,7 @@ using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; namespace osu.Game.Graphics.UserInterface { @@ -137,8 +138,8 @@ namespace osu.Game.Graphics.UserInterface /// Used to format counts. /// /// Count to format. - /// Count formatted as a string. - protected virtual string FormatCount(T count) + /// Count formatted as a localisable string. + protected virtual LocalisableString FormatCount(T count) { return count.ToString(); } diff --git a/osu.Game/Graphics/UserInterface/ScoreCounter.cs b/osu.Game/Graphics/UserInterface/ScoreCounter.cs index 5747c846eb..7ebf3819e4 100644 --- a/osu.Game/Graphics/UserInterface/ScoreCounter.cs +++ b/osu.Game/Graphics/UserInterface/ScoreCounter.cs @@ -3,6 +3,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; namespace osu.Game.Graphics.UserInterface @@ -37,7 +38,7 @@ namespace osu.Game.Graphics.UserInterface return currentValue > newValue ? currentValue - newValue : newValue - currentValue; } - protected override string FormatCount(double count) + protected override LocalisableString FormatCount(double count) { string format = new string('0', RequiredDisplayDigits.Value); 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/UserInterface/TwoLayerButton.cs b/osu.Game/Graphics/UserInterface/TwoLayerButton.cs index 8f03c7073c..969309bc79 100644 --- a/osu.Game/Graphics/UserInterface/TwoLayerButton.cs +++ b/osu.Game/Graphics/UserInterface/TwoLayerButton.cs @@ -71,7 +71,8 @@ namespace osu.Game.Graphics.UserInterface } } - public TwoLayerButton() + public TwoLayerButton(HoverSampleSet sampleSet = HoverSampleSet.Default) + : base(sampleSet) { Size = SIZE_RETRACTED; Shear = shear; diff --git a/osu.Game/Graphics/UserInterfaceV2/ColourDisplay.cs b/osu.Game/Graphics/UserInterfaceV2/ColourDisplay.cs index 01d91f7cfd..5240df74a2 100644 --- a/osu.Game/Graphics/UserInterfaceV2/ColourDisplay.cs +++ b/osu.Game/Graphics/UserInterfaceV2/ColourDisplay.cs @@ -1,32 +1,39 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osuTK; -using osuTK.Graphics; namespace osu.Game.Graphics.UserInterfaceV2 { /// /// A component which displays a colour along with related description text. /// - public class ColourDisplay : CompositeDrawable, IHasCurrentValue + public class ColourDisplay : CompositeDrawable, IHasCurrentValue { - private readonly BindableWithCurrent current = new BindableWithCurrent(); + /// + /// Invoked when the user has requested the colour corresponding to this + /// to be removed from its palette. + /// + public event Action DeleteRequested; + + private readonly BindableWithCurrent current = new BindableWithCurrent(); - private Box fill; - private OsuSpriteText colourHexCode; private OsuSpriteText colourName; - public Bindable Current + public Bindable Current { get => current.Current; set => current.Current = value; @@ -62,24 +69,10 @@ namespace osu.Game.Graphics.UserInterfaceV2 Spacing = new Vector2(0, 10), Children = new Drawable[] { - new CircularContainer + new ColourCircle { - RelativeSizeAxes = Axes.X, - Height = 100, - Masking = true, - Children = new Drawable[] - { - fill = new Box - { - RelativeSizeAxes = Axes.Both - }, - colourHexCode = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.Default.With(size: 12) - } - } + Current = { BindTarget = Current }, + DeleteRequested = () => DeleteRequested?.Invoke(this) }, colourName = new OsuSpriteText { @@ -90,18 +83,64 @@ namespace osu.Game.Graphics.UserInterfaceV2 }; } - protected override void LoadComplete() + private class ColourCircle : OsuClickableContainer, IHasPopover, IHasContextMenu { - base.LoadComplete(); + public Bindable Current { get; } = new Bindable(); - current.BindValueChanged(_ => updateColour(), true); - } + public Action DeleteRequested { get; set; } - private void updateColour() - { - fill.Colour = current.Value; - colourHexCode.Text = current.Value.ToHex(); - colourHexCode.Colour = OsuColour.ForegroundTextColourFor(current.Value); + private readonly Box fill; + private readonly OsuSpriteText colourHexCode; + + public ColourCircle() + { + RelativeSizeAxes = Axes.X; + Height = 100; + CornerRadius = 50; + Masking = true; + Action = this.ShowPopover; + + Children = new Drawable[] + { + fill = new Box + { + RelativeSizeAxes = Axes.Both + }, + colourHexCode = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Default.With(size: 12) + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(_ => updateColour(), true); + } + + private void updateColour() + { + fill.Colour = Current.Value; + colourHexCode.Text = Current.Value.ToHex(); + colourHexCode.Colour = OsuColour.ForegroundTextColourFor(Current.Value); + } + + public Popover GetPopover() => new OsuPopover(false) + { + Child = new OsuColourPicker + { + Current = { BindTarget = Current } + } + }; + + public MenuItem[] ContextMenuItems => new MenuItem[] + { + new OsuMenuItem("Delete", MenuItemType.Destructive, () => DeleteRequested?.Invoke()) + }; } } } diff --git a/osu.Game/Graphics/UserInterfaceV2/ColourPalette.cs b/osu.Game/Graphics/UserInterfaceV2/ColourPalette.cs index ba950048dc..a966f61b74 100644 --- a/osu.Game/Graphics/UserInterfaceV2/ColourPalette.cs +++ b/osu.Game/Graphics/UserInterfaceV2/ColourPalette.cs @@ -1,14 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Collections.Generic; +using System.Collections.Specialized; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osuTK; -using osuTK.Graphics; namespace osu.Game.Graphics.UserInterfaceV2 { @@ -17,7 +22,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 /// public class ColourPalette : CompositeDrawable { - public BindableList Colours { get; } = new BindableList(); + public BindableList Colours { get; } = new BindableList(); private string colourNamePrefix = "Colour"; @@ -36,36 +41,24 @@ namespace osu.Game.Graphics.UserInterfaceV2 } } - private FillFlowContainer palette; - private Container placeholder; + private FillFlowContainer palette; + + private IEnumerable colourDisplays => palette.OfType(); [BackgroundDependencyLoader] private void load() { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; + AutoSizeDuration = fade_duration; + AutoSizeEasing = Easing.OutQuint; - InternalChildren = new Drawable[] + InternalChild = palette = new FillFlowContainer { - palette = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(10), - Direction = FillDirection.Full - }, - placeholder = new Container - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Child = new OsuSpriteText - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Text = "(none)", - Font = OsuFont.Default.With(weight: FontWeight.Bold) - } - } + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(10), + Direction = FillDirection.Full }; } @@ -73,7 +66,11 @@ namespace osu.Game.Graphics.UserInterfaceV2 { base.LoadComplete(); - Colours.BindCollectionChanged((_, __) => updatePalette(), true); + Colours.BindCollectionChanged((_, args) => + { + if (args.Action != NotifyCollectionChangedAction.Replace) + updatePalette(); + }, true); FinishTransforms(true); } @@ -83,37 +80,103 @@ namespace osu.Game.Graphics.UserInterfaceV2 { palette.Clear(); - if (Colours.Any()) + for (int i = 0; i < Colours.Count; ++i) { - palette.FadeIn(fade_duration, Easing.OutQuint); - placeholder.FadeOut(fade_duration, Easing.OutQuint); - } - else - { - palette.FadeOut(fade_duration, Easing.OutQuint); - placeholder.FadeIn(fade_duration, Easing.OutQuint); + // copy to avoid accesses to modified closure. + int colourIndex = i; + ColourDisplay display; + + palette.Add(display = new ColourDisplay + { + Current = { Value = Colours[colourIndex] } + }); + + display.Current.BindValueChanged(colour => Colours[colourIndex] = colour.NewValue); + display.DeleteRequested += colourDeletionRequested; } - foreach (var item in Colours) + palette.Add(new AddColourButton { - palette.Add(new ColourDisplay - { - Current = { Value = item } - }); - } + Action = () => Colours.Add(Colour4.White) + }); reindexItems(); } + private void colourDeletionRequested(ColourDisplay display) => Colours.RemoveAt(palette.IndexOf(display)); + private void reindexItems() { int index = 1; - foreach (var colour in palette) + foreach (var colourDisplay in colourDisplays) { - colour.ColourName = $"{colourNamePrefix} {index}"; + colourDisplay.ColourName = $"{colourNamePrefix} {index}"; index += 1; } } + + internal class AddColourButton : CompositeDrawable + { + public Action Action + { + set => circularButton.Action = value; + } + + private readonly OsuClickableContainer circularButton; + + public AddColourButton() + { + AutoSizeAxes = Axes.Y; + Width = 100; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + Children = new Drawable[] + { + circularButton = new OsuClickableContainer + { + RelativeSizeAxes = Axes.X, + Height = 100, + CornerRadius = 50, + Masking = true, + BorderThickness = 5, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Transparent, + AlwaysPresent = true + }, + new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(20), + Icon = FontAwesome.Solid.Plus + } + } + }, + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = "New" + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + circularButton.BorderColour = colours.BlueDarker; + } + } } } diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledColourPalette.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledColourPalette.cs index 58443953bc..8970ef1115 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledColourPalette.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledColourPalette.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; -using osuTK.Graphics; +using osu.Framework.Graphics; namespace osu.Game.Graphics.UserInterfaceV2 { @@ -13,7 +13,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 { } - public BindableList Colours => Component.Colours; + public BindableList Colours => Component.Colours; public string ColourNamePrefix { diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.cs b/osu.Game/Graphics/UserInterfaceV2/OsuDirectorySelectorDirectory.cs index 8a420cdcfb..618c7dabfa 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() }); } diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs b/osu.Game/Graphics/UserInterfaceV2/OsuFileSelector.cs index b9fb642cbe..3d09d09833 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() }); } diff --git a/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs b/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs new file mode 100644 index 0000000000..c07a5de1e4 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/OsuPopover.cs @@ -0,0 +1,55 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public class OsuPopover : Popover + { + private const float fade_duration = 250; + private const double scale_duration = 500; + + public OsuPopover(bool withPadding = true) + { + Content.Padding = withPadding ? new MarginPadding(20) : new MarginPadding(); + + Body.Masking = true; + Body.CornerRadius = 10; + Body.Margin = new MarginPadding(10); + Body.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Offset = new Vector2(0, 2), + Radius = 5, + Colour = Colour4.Black.Opacity(0.3f) + }; + } + + [BackgroundDependencyLoader(true)] + private void load([CanBeNull] OverlayColourProvider colourProvider, OsuColour colours) + { + Background.Colour = Arrow.Colour = colourProvider?.Background4 ?? colours.GreySeafoamDarker; + } + + protected override Drawable CreateArrow() => Empty(); + + protected override void PopIn() + { + this.ScaleTo(1, scale_duration, Easing.OutElasticHalf); + this.FadeIn(fade_duration, Easing.OutQuint); + } + + protected override void PopOut() + { + this.ScaleTo(0.7f, scale_duration, Easing.OutQuint); + this.FadeOut(fade_duration, Easing.OutQuint); + } + } +} diff --git a/osu.Game/Localisation/BindingSettingsStrings.cs b/osu.Game/Localisation/BindingSettingsStrings.cs new file mode 100644 index 0000000000..ad4a650a1f --- /dev/null +++ b/osu.Game/Localisation/BindingSettingsStrings.cs @@ -0,0 +1,29 @@ +// 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.Localisation; + +namespace osu.Game.Localisation +{ + public static class BindingSettingsStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.BindingSettings"; + + /// + /// "Shortcut and gameplay bindings" + /// + public static LocalisableString ShortcutAndGameplayBindings => new TranslatableString(getKey(@"shortcut_and_gameplay_bindings"), @"Shortcut and gameplay bindings"); + + /// + /// "Configure" + /// + public static LocalisableString Configure => new TranslatableString(getKey(@"configure"), @"Configure"); + + /// + /// "change global shortcut keys and gameplay bindings" + /// + public static LocalisableString ChangeBindingsButton => new TranslatableString(getKey(@"change_bindings_button"), @"change global shortcut keys and gameplay bindings"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/CommonStrings.cs b/osu.Game/Localisation/CommonStrings.cs index 50e01f06fc..bf488d2590 100644 --- a/osu.Game/Localisation/CommonStrings.cs +++ b/osu.Game/Localisation/CommonStrings.cs @@ -14,6 +14,21 @@ namespace osu.Game.Localisation /// public static LocalisableString Cancel => new TranslatableString(getKey(@"cancel"), @"Cancel"); - private static string getKey(string key) => $"{prefix}:{key}"; + /// + /// "Enabled" + /// + public static LocalisableString Enabled => new TranslatableString(getKey(@"enabled"), @"Enabled"); + + /// + /// "Width" + /// + public static LocalisableString Width => new TranslatableString(getKey(@"width"), @"Width"); + + /// + /// "Height" + /// + public static LocalisableString Height => new TranslatableString(getKey(@"height"), @"Height"); + + private static string getKey(string key) => $@"{prefix}:{key}"; } -} +} \ No newline at end of file diff --git a/osu.Game/Localisation/MouseSettingsStrings.cs b/osu.Game/Localisation/MouseSettingsStrings.cs new file mode 100644 index 0000000000..5e894c4e0b --- /dev/null +++ b/osu.Game/Localisation/MouseSettingsStrings.cs @@ -0,0 +1,64 @@ +// 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.Localisation; + +namespace osu.Game.Localisation +{ + public static class MouseSettingsStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.MouseSettings"; + + /// + /// "Mouse" + /// + public static LocalisableString Mouse => new TranslatableString(getKey(@"mouse"), @"Mouse"); + + /// + /// "Not applicable in full screen mode" + /// + public static LocalisableString NotApplicableFullscreen => new TranslatableString(getKey(@"not_applicable_full_screen"), @"Not applicable in full screen mode"); + + /// + /// "High precision mouse" + /// + public static LocalisableString HighPrecisionMouse => new TranslatableString(getKey(@"high_precision_mouse"), @"High precision mouse"); + + /// + /// "Attempts to bypass any operation system mouse acceleration. On windows, this is equivalent to what used to be known as "Raw Input"." + /// + public static LocalisableString HighPrecisionMouseTooltip => new TranslatableString(getKey(@"high_precision_mouse_tooltip"), @"Attempts to bypass any operation system mouse acceleration. On windows, this is equivalent to what used to be known as ""Raw Input""."); + + /// + /// "Confine mouse cursor to window" + /// + public static LocalisableString ConfineMouseMode => new TranslatableString(getKey(@"confine_mouse_mode"), @"Confine mouse cursor to window"); + + /// + /// "Disable mouse wheel during gameplay" + /// + public static LocalisableString DisableMouseWheel => new TranslatableString(getKey(@"disable_mouse_wheel"), @"Disable mouse wheel during gameplay"); + + /// + /// "Disable mouse buttons during gameplay" + /// + public static LocalisableString DisableMouseButtons => new TranslatableString(getKey(@"disable_mouse_buttons"), @"Disable mouse buttons during gameplay"); + + /// + /// "Enable high precision mouse to adjust sensitivity" + /// + public static LocalisableString EnableHighPrecisionForSensitivityAdjust => new TranslatableString(getKey(@"enable_high_precision_for_sensitivity_adjust"), @"Enable high precision mouse to adjust sensitivity"); + + /// + /// "Cursor sensitivity" + /// + public static LocalisableString CursorSensitivity => new TranslatableString(getKey(@"cursor_sensitivity"), @"Cursor sensitivity"); + + /// + /// "This setting has known issues on your platform. If you encounter problems, it is recommended to adjust sensitivity externally and keep this disabled for now." + /// + public static LocalisableString HighPrecisionPlatformWarning => new TranslatableString(getKey(@"high_precision_platform_warning"), @"This setting has known issues on your platform. If you encounter problems, it is recommended to adjust sensitivity externally and keep this disabled for now."); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/NamedOverlayComponentStrings.cs b/osu.Game/Localisation/NamedOverlayComponentStrings.cs new file mode 100644 index 0000000000..475bea2a4a --- /dev/null +++ b/osu.Game/Localisation/NamedOverlayComponentStrings.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class NamedOverlayComponentStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.NamedOverlayComponent"; + + /// + /// "browse for new beatmaps" + /// + public static LocalisableString BeatmapListingDescription => new TranslatableString(getKey(@"beatmap_listing_description"), @"browse for new beatmaps"); + + /// + /// "track recent dev updates in the osu! ecosystem" + /// + public static LocalisableString ChangelogDescription => new TranslatableString(getKey(@"changelog_description"), @"track recent dev updates in the osu! ecosystem"); + + /// + /// "view your friends and other information" + /// + public static LocalisableString DashboardDescription => new TranslatableString(getKey(@"dashboard_description"), @"view your friends and other information"); + + /// + /// "find out who's the best right now" + /// + public static LocalisableString RankingsDescription => new TranslatableString(getKey(@"rankings_description"), @"find out who's the best right now"); + + /// + /// "get up-to-date on community happenings" + /// + public static LocalisableString NewsDescription => new TranslatableString(getKey(@"news_description"), @"get up-to-date on community happenings"); + + /// + /// "knowledge base" + /// + public static LocalisableString WikiDescription => new TranslatableString(getKey(@"wiki_description"), @"knowledge base"); + + private static string getKey(string key) => $"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/TabletSettingsStrings.cs b/osu.Game/Localisation/TabletSettingsStrings.cs new file mode 100644 index 0000000000..5bdca09e4a --- /dev/null +++ b/osu.Game/Localisation/TabletSettingsStrings.cs @@ -0,0 +1,59 @@ +// 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.Localisation; + +namespace osu.Game.Localisation +{ + public static class TabletSettingsStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.TabletSettings"; + + /// + /// "Tablet" + /// + public static LocalisableString Tablet => new TranslatableString(getKey(@"tablet"), @"Tablet"); + + /// + /// "No tablet detected!" + /// + public static LocalisableString NoTabletDetected => new TranslatableString(getKey(@"no_tablet_detected"), @"No tablet detected!"); + + /// + /// "Reset to full area" + /// + public static LocalisableString ResetToFullArea => new TranslatableString(getKey(@"reset_to_full_area"), @"Reset to full area"); + + /// + /// "Conform to current game aspect ratio" + /// + public static LocalisableString ConformToCurrentGameAspectRatio => new TranslatableString(getKey(@"conform_to_current_game_aspect_ratio"), @"Conform to current game aspect ratio"); + + /// + /// "X Offset" + /// + public static LocalisableString XOffset => new TranslatableString(getKey(@"x_offset"), @"X Offset"); + + /// + /// "Y Offset" + /// + public static LocalisableString YOffset => new TranslatableString(getKey(@"y_offset"), @"Y Offset"); + + /// + /// "Rotation" + /// + public static LocalisableString Rotation => new TranslatableString(getKey(@"rotation"), @"Rotation"); + + /// + /// "Aspect Ratio" + /// + public static LocalisableString AspectRatio => new TranslatableString(getKey(@"aspect_ratio"), @"Aspect Ratio"); + + /// + /// "Lock aspect ratio" + /// + public static LocalisableString LockAspectRatio => new TranslatableString(getKey(@"lock_aspect_ratio"), @"Lock aspect ratio"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} \ No newline at end of file diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 1686595512..b35dfa11cb 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -148,6 +148,16 @@ namespace osu.Game.Online.API var userReq = new GetUserRequest(); + userReq.Failure += ex => + { + if (ex is WebException webException && webException.Message == @"Unauthorized") + { + log.Add(@"Login no longer valid"); + Logout(); + } + else + failConnectionProcess(); + }; userReq.Success += u => { localUser.Value = u; @@ -167,6 +177,7 @@ namespace osu.Game.Online.API // getting user's friends is considered part of the connection process. var friendsReq = new GetFriendsRequest(); + friendsReq.Failure += _ => failConnectionProcess(); friendsReq.Success += res => { friends.AddRange(res); 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..e117293ce6 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,9 +78,13 @@ namespace osu.Game.Online.API /// public event APIFailureHandler Failure; - private bool cancelled; + private readonly object completionStateLock = new object(); - private Action pendingFailure; + /// + /// The state of this request, from an outside perspective. + /// This is used to ensure correct notification events are fired. + /// + private APIRequestCompletionState completionState; public void Perform(IAPIProvider api) { @@ -94,34 +97,23 @@ namespace osu.Game.Online.API API = apiAccess; User = apiAccess.LocalUser.Value; - if (checkAndScheduleFailure()) - return; + if (isFailing) return; WebRequest = CreateWebRequest(); WebRequest.Failed += Fail; WebRequest.AllowRetryOnTimeout = false; WebRequest.AddHeader("Authorization", $"Bearer {API.AccessToken}"); - if (checkAndScheduleFailure()) - return; + if (isFailing) return; - if (!WebRequest.Aborted) // could have been aborted by a Cancel() call - { - Logger.Log($@"Performing request {this}", LoggingTarget.Network); - WebRequest.Perform(); - } + Logger.Log($@"Performing request {this}", LoggingTarget.Network); + WebRequest.Perform(); - if (checkAndScheduleFailure()) - return; + if (isFailing) return; PostProcess(); - API.Schedule(delegate - { - if (cancelled) return; - - TriggerSuccess(); - }); + TriggerSuccess(); } /// @@ -131,65 +123,85 @@ namespace osu.Game.Online.API { } - private bool succeeded; - - internal virtual void TriggerSuccess() + internal void TriggerSuccess() { - succeeded = true; - Success?.Invoke(); + lock (completionStateLock) + { + if (completionState != APIRequestCompletionState.Waiting) + return; + + completionState = APIRequestCompletionState.Completed; + } + + if (API == null) + Success?.Invoke(); + else + API.Schedule(() => Success?.Invoke()); } internal void TriggerFailure(Exception e) { - Failure?.Invoke(e); + lock (completionStateLock) + { + if (completionState != APIRequestCompletionState.Waiting) + return; + + completionState = APIRequestCompletionState.Failed; + } + + if (API == null) + Failure?.Invoke(e); + else + API.Schedule(() => Failure?.Invoke(e)); } public void Cancel() => Fail(new OperationCanceledException(@"Request cancelled")); public void Fail(Exception e) { - if (succeeded || cancelled) - return; - - cancelled = true; - WebRequest?.Abort(); - - string responseString = WebRequest?.GetResponseString(); - - if (!string.IsNullOrEmpty(responseString)) + lock (completionStateLock) { - try - { - // attempt to decode a displayable error string. - var error = JsonConvert.DeserializeObject(responseString); - if (error != null) - e = new APIException(error.ErrorMessage, e); - } - catch - { - } - } + if (completionState != APIRequestCompletionState.Waiting) + return; - Logger.Log($@"Failing request {this} ({e})", LoggingTarget.Network); - pendingFailure = () => TriggerFailure(e); - checkAndScheduleFailure(); + WebRequest?.Abort(); + + // in the case of a cancellation we don't care about whether there's an error in the response. + if (!(e is OperationCanceledException)) + { + string responseString = WebRequest?.GetResponseString(); + + // naive check whether there's an error in the response to avoid unnecessary JSON deserialisation. + if (!string.IsNullOrEmpty(responseString) && responseString.Contains(@"""error""")) + { + try + { + // attempt to decode a displayable error string. + var error = JsonConvert.DeserializeObject(responseString); + if (error != null) + e = new APIException(error.ErrorMessage, e); + } + catch + { + } + } + } + + Logger.Log($@"Failing request {this} ({e})", LoggingTarget.Network); + TriggerFailure(e); + } } /// - /// Checked for cancellation or error. Also queues up the Failed event if we can. + /// Whether this request is in a failing or failed state. /// - /// Whether we are in a failed or cancelled state. - private bool checkAndScheduleFailure() + private bool isFailing { - if (pendingFailure == null) return cancelled; - - if (API == null) - pendingFailure(); - else - API.Schedule(pendingFailure); - - pendingFailure = null; - return true; + get + { + lock (completionStateLock) + return completionState == APIRequestCompletionState.Failed; + } } private class DisplayableError @@ -199,14 +211,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/API/Requests/SearchBeatmapSetsRequest.cs b/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs index f1cb02fb10..8ce495e274 100644 --- a/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs +++ b/osu.Game/Online/API/Requests/SearchBeatmapSetsRequest.cs @@ -59,7 +59,7 @@ namespace osu.Game.Online.API.Requests SearchPlayed played = SearchPlayed.Any, SearchExplicit explicitContent = SearchExplicit.Hide) { - this.query = string.IsNullOrEmpty(query) ? string.Empty : System.Uri.EscapeDataString(query); + this.query = query; this.ruleset = ruleset; this.cursor = cursor; @@ -78,7 +78,9 @@ namespace osu.Game.Online.API.Requests protected override WebRequest CreateWebRequest() { var req = base.CreateWebRequest(); - req.AddParameter("q", query); + + if (query != null) + req.AddParameter("q", query); if (General != null && General.Any()) req.AddParameter("c", string.Join('.', General.Select(e => e.ToString().ToLowerInvariant()))); diff --git a/osu.Game/Online/Chat/DrawableLinkCompiler.cs b/osu.Game/Online/Chat/DrawableLinkCompiler.cs index 53ea1d6f99..4df60eba69 100644 --- a/osu.Game/Online/Chat/DrawableLinkCompiler.cs +++ b/osu.Game/Online/Chat/DrawableLinkCompiler.cs @@ -31,6 +31,7 @@ namespace osu.Game.Online.Chat protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new LinkHoverSounds(sampleSet, Parts); public DrawableLinkCompiler(IEnumerable parts) + : base(HoverSampleSet.Submit) { Parts = parts.ToList(); } diff --git a/osu.Game/Online/HubClientConnector.cs b/osu.Game/Online/HubClientConnector.cs index 3839762e46..90049a6501 100644 --- a/osu.Game/Online/HubClientConnector.cs +++ b/osu.Game/Online/HubClientConnector.cs @@ -116,10 +116,7 @@ namespace osu.Game.Online } catch (Exception e) { - Logger.Log($"{clientName} connection error: {e}", LoggingTarget.Network); - - // retry on any failure. - await Task.Delay(5000, cancellationToken).ConfigureAwait(false); + await handleErrorAndDelay(e, cancellationToken).ConfigureAwait(false); } } } @@ -129,6 +126,15 @@ namespace osu.Game.Online } } + /// + /// Handles an exception and delays an async flow. + /// + private async Task handleErrorAndDelay(Exception exception, CancellationToken cancellationToken) + { + Logger.Log($"{clientName} connection error: {exception}", LoggingTarget.Network); + await Task.Delay(5000, cancellationToken).ConfigureAwait(false); + } + private HubConnection buildConnection(CancellationToken cancellationToken) { var builder = new HubConnectionBuilder() @@ -155,17 +161,18 @@ namespace osu.Game.Online return newConnection; } - private Task onConnectionClosed(Exception? ex, CancellationToken cancellationToken) + private async Task onConnectionClosed(Exception? ex, CancellationToken cancellationToken) { isConnected.Value = false; - Logger.Log(ex != null ? $"{clientName} lost connection: {ex}" : $"{clientName} disconnected", LoggingTarget.Network); + if (ex != null) + await handleErrorAndDelay(ex, cancellationToken).ConfigureAwait(false); + else + Logger.Log($"{clientName} disconnected", LoggingTarget.Network); // make sure a disconnect wasn't triggered (and this is still the active connection). if (!cancellationToken.IsCancellationRequested) - Task.Run(connect, default); - - return Task.CompletedTask; + await Task.Run(connect, default).ConfigureAwait(false); } private async Task disconnect(bool takeLock) diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs index 7108a23e44..934b905a1a 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs @@ -372,10 +372,10 @@ namespace osu.Game.Online.Leaderboards public class LeaderboardScoreStatistic { public IconUsage Icon; - public string Value; + public LocalisableString Value; public string Name; - public LeaderboardScoreStatistic(IconUsage icon, string name, string value) + public LeaderboardScoreStatistic(IconUsage icon, string name, LocalisableString value) { Icon = icon; Name = name; diff --git a/osu.Game/Online/Multiplayer/IMultiplayerLoungeServer.cs b/osu.Game/Online/Multiplayer/IMultiplayerLoungeServer.cs index 4640640c5f..0a618c8f5c 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerLoungeServer.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerLoungeServer.cs @@ -15,6 +15,16 @@ namespace osu.Game.Online.Multiplayer /// /// The databased room ID. /// If the user is already in the requested (or another) room. + /// If the room required a password. Task JoinRoom(long roomId); + + /// + /// Request to join a multiplayer room with a provided password. + /// + /// The databased room ID. + /// The password for the join request. + /// If the user is already in the requested (or another) room. + /// If the room provided password was incorrect. + Task JoinRoomWithPassword(long roomId, string password); } } diff --git a/osu.Game/Online/Multiplayer/InvalidPasswordException.cs b/osu.Game/Online/Multiplayer/InvalidPasswordException.cs new file mode 100644 index 0000000000..0441aea287 --- /dev/null +++ b/osu.Game/Online/Multiplayer/InvalidPasswordException.cs @@ -0,0 +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; +using System.Runtime.Serialization; +using Microsoft.AspNetCore.SignalR; + +namespace osu.Game.Online.Multiplayer +{ + [Serializable] + public class InvalidPasswordException : HubException + { + public InvalidPasswordException() + { + } + + protected InvalidPasswordException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + } +} diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 2e65f7cf1c..9972d7e88d 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -92,7 +92,7 @@ namespace osu.Game.Online.Multiplayer [Resolved] private UserLookupCache userLookupCache { get; set; } = null!; - private Room? apiRoom; + protected Room? APIRoom { get; private set; } [BackgroundDependencyLoader] private void load() @@ -115,7 +115,8 @@ namespace osu.Game.Online.Multiplayer /// Joins the for a given API . /// /// The API . - public async Task JoinRoom(Room room) + /// An optional password to use for the join operation. + public async Task JoinRoom(Room room, string? password = null) { var cancellationSource = joinCancellationSource = new CancellationTokenSource(); @@ -127,7 +128,7 @@ namespace osu.Game.Online.Multiplayer Debug.Assert(room.RoomID.Value != null); // Join the server-side room. - var joinedRoom = await JoinRoom(room.RoomID.Value.Value).ConfigureAwait(false); + var joinedRoom = await JoinRoom(room.RoomID.Value.Value, password ?? room.Password.Value).ConfigureAwait(false); Debug.Assert(joinedRoom != null); // Populate users. @@ -138,7 +139,7 @@ namespace osu.Game.Online.Multiplayer await scheduleAsync(() => { Room = joinedRoom; - apiRoom = room; + APIRoom = room; foreach (var user in joinedRoom.Users) updateUserPlayingState(user.UserID, user.State); }, cancellationSource.Token).ConfigureAwait(false); @@ -152,8 +153,9 @@ namespace osu.Game.Online.Multiplayer /// Joins the with a given ID. /// /// The room ID. + /// An optional password to use when joining the room. /// The joined . - protected abstract Task JoinRoom(long roomId); + protected abstract Task JoinRoom(long roomId, string? password = null); public Task LeaveRoom() { @@ -166,7 +168,7 @@ namespace osu.Game.Online.Multiplayer // For example, if a room was left and the user immediately pressed the "create room" button, then the user could be taken into the lobby if the value of Room is not reset in time. var scheduledReset = scheduleAsync(() => { - apiRoom = null; + APIRoom = null; Room = null; CurrentMatchPlayingUserIds.Clear(); @@ -189,8 +191,9 @@ namespace osu.Game.Online.Multiplayer /// A room must be joined for this to have any effect. /// /// The new room name, if any. + /// The new password, if any. /// The new room playlist item, if any. - public Task ChangeSettings(Optional name = default, Optional item = default) + public Task ChangeSettings(Optional name = default, Optional password = default, Optional item = default) { if (Room == null) throw new InvalidOperationException("Must be joined to a match to change settings."); @@ -212,6 +215,7 @@ namespace osu.Game.Online.Multiplayer return ChangeSettings(new MultiplayerRoomSettings { Name = name.GetOr(Room.Settings.Name), + Password = password.GetOr(Room.Settings.Password), BeatmapID = item.GetOr(existingPlaylistItem).BeatmapID, BeatmapChecksum = item.GetOr(existingPlaylistItem).Beatmap.Value.MD5Hash, RulesetID = item.GetOr(existingPlaylistItem).RulesetID, @@ -301,22 +305,22 @@ namespace osu.Game.Online.Multiplayer if (Room == null) return; - Debug.Assert(apiRoom != null); + Debug.Assert(APIRoom != null); Room.State = state; switch (state) { case MultiplayerRoomState.Open: - apiRoom.Status.Value = new RoomStatusOpen(); + APIRoom.Status.Value = new RoomStatusOpen(); break; case MultiplayerRoomState.Playing: - apiRoom.Status.Value = new RoomStatusPlaying(); + APIRoom.Status.Value = new RoomStatusPlaying(); break; case MultiplayerRoomState.Closed: - apiRoom.Status.Value = new RoomStatusEnded(); + APIRoom.Status.Value = new RoomStatusEnded(); break; } @@ -377,12 +381,12 @@ namespace osu.Game.Online.Multiplayer if (Room == null) return; - Debug.Assert(apiRoom != null); + Debug.Assert(APIRoom != null); var user = Room.Users.FirstOrDefault(u => u.UserID == userId); Room.Host = user; - apiRoom.Host.Value = user?.User; + APIRoom.Host.Value = user?.User; RoomUpdated?.Invoke(); }, false); @@ -525,11 +529,12 @@ namespace osu.Game.Online.Multiplayer if (Room == null) return; - Debug.Assert(apiRoom != null); + Debug.Assert(APIRoom != null); // Update a few properties of the room instantaneously. Room.Settings = settings; - apiRoom.Name.Value = Room.Settings.Name; + APIRoom.Name.Value = Room.Settings.Name; + APIRoom.Password.Value = Room.Settings.Password; // The current item update is delayed until an online beatmap lookup (below) succeeds. // In-order for the client to not display an outdated beatmap, the current item is forcefully cleared here. @@ -551,7 +556,7 @@ namespace osu.Game.Online.Multiplayer if (Room == null || !Room.Settings.Equals(settings)) return; - Debug.Assert(apiRoom != null); + Debug.Assert(APIRoom != null); var beatmap = beatmapSet.Beatmaps.Single(b => b.OnlineBeatmapID == settings.BeatmapID); beatmap.MD5Hash = settings.BeatmapChecksum; @@ -561,7 +566,7 @@ namespace osu.Game.Online.Multiplayer var allowedMods = settings.AllowedMods.Select(m => m.ToMod(ruleset)); // Try to retrieve the existing playlist item from the API room. - var playlistItem = apiRoom.Playlist.FirstOrDefault(i => i.ID == settings.PlaylistItemId); + var playlistItem = APIRoom.Playlist.FirstOrDefault(i => i.ID == settings.PlaylistItemId); if (playlistItem != null) updateItem(playlistItem); @@ -569,7 +574,7 @@ namespace osu.Game.Online.Multiplayer { // An existing playlist item does not exist, so append a new one. updateItem(playlistItem = new PlaylistItem()); - apiRoom.Playlist.Add(playlistItem); + APIRoom.Playlist.Add(playlistItem); } CurrentMatchPlayingItem.Value = playlistItem; diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs index ee72df4c10..4e94c5982f 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomSettings.cs @@ -36,12 +36,16 @@ namespace osu.Game.Online.Multiplayer [Key(6)] public long PlaylistItemId { get; set; } + [Key(7)] + public string Password { get; set; } = string.Empty; + public bool Equals(MultiplayerRoomSettings other) => BeatmapID == other.BeatmapID && BeatmapChecksum == other.BeatmapChecksum && RequiredMods.SequenceEqual(other.RequiredMods) && AllowedMods.SequenceEqual(other.AllowedMods) && RulesetID == other.RulesetID + && Password.Equals(other.Password, StringComparison.Ordinal) && Name.Equals(other.Name, StringComparison.Ordinal) && PlaylistItemId == other.PlaylistItemId; @@ -49,6 +53,7 @@ namespace osu.Game.Online.Multiplayer + $" Beatmap:{BeatmapID} ({BeatmapChecksum})" + $" RequiredMods:{string.Join(',', RequiredMods)}" + $" AllowedMods:{string.Join(',', AllowedMods)}" + + $" Password:{(string.IsNullOrEmpty(Password) ? "no" : "yes")}" + $" Ruleset:{RulesetID}" + $" Item:{PlaylistItemId}"; } diff --git a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs index cf1e18e059..726e26ebe1 100644 --- a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs @@ -62,12 +62,12 @@ namespace osu.Game.Online.Multiplayer } } - protected override Task JoinRoom(long roomId) + protected override Task JoinRoom(long roomId, string? password = null) { if (!IsConnected.Value) return Task.FromCanceled(new CancellationToken(true)); - return connection.InvokeAsync(nameof(IMultiplayerServer.JoinRoom), roomId); + return connection.InvokeAsync(nameof(IMultiplayerServer.JoinRoomWithPassword), roomId, password ?? string.Empty); } protected override Task LeaveRoomInternal() 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/Online/Rooms/JoinRoomRequest.cs b/osu.Game/Online/Rooms/JoinRoomRequest.cs index faa20a3e6c..2a3480c992 100644 --- a/osu.Game/Online/Rooms/JoinRoomRequest.cs +++ b/osu.Game/Online/Rooms/JoinRoomRequest.cs @@ -9,20 +9,24 @@ namespace osu.Game.Online.Rooms { public class JoinRoomRequest : APIRequest { - private readonly Room room; + public readonly Room Room; + public readonly string Password; - public JoinRoomRequest(Room room) + public JoinRoomRequest(Room room, string password) { - this.room = room; + Room = room; + Password = password; } protected override WebRequest CreateWebRequest() { var req = base.CreateWebRequest(); req.Method = HttpMethod.Put; + if (!string.IsNullOrEmpty(Password)) + req.AddParameter(@"password", Password, RequestParameterType.Query); return req; } - protected override string Target => $"rooms/{room.RoomID.Value}/users/{User.Id}"; + protected override string Target => $@"rooms/{Room.RoomID.Value}/users/{User.Id}"; } } diff --git a/osu.Game/Online/Rooms/Room.cs b/osu.Game/Online/Rooms/Room.cs index b28680ffef..4c506e26a8 100644 --- a/osu.Game/Online/Rooms/Room.cs +++ b/osu.Game/Online/Rooms/Room.cs @@ -10,10 +10,11 @@ using osu.Game.IO.Serialization.Converters; using osu.Game.Online.Rooms.GameTypes; using osu.Game.Online.Rooms.RoomStatuses; using osu.Game.Users; +using osu.Game.Utils; namespace osu.Game.Online.Rooms { - public class Room + public class Room : IDeepCloneable { [Cached] [JsonProperty("id")] @@ -48,10 +49,6 @@ namespace osu.Game.Online.Rooms set => Category.Value = value; } - [Cached] - [JsonIgnore] - public readonly Bindable Duration = new Bindable(); - [Cached] [JsonIgnore] public readonly Bindable MaxAttempts = new Bindable(); @@ -76,6 +73,9 @@ namespace osu.Game.Online.Rooms [JsonProperty("current_user_score")] public readonly Bindable UserScore = new Bindable(); + [JsonProperty("has_password")] + public readonly BindableBool HasPassword = new BindableBool(); + [Cached] [JsonProperty("recent_participants")] public readonly BindableList RecentParticipants = new BindableList(); @@ -84,6 +84,16 @@ namespace osu.Game.Online.Rooms [JsonProperty("participant_count")] public readonly Bindable ParticipantCount = new Bindable(); + #region Properties only used for room creation request + + [Cached(Name = nameof(Password))] + [JsonProperty("password")] + public readonly Bindable Password = new Bindable(); + + [Cached] + [JsonIgnore] + public readonly Bindable Duration = new Bindable(); + [JsonProperty("duration")] private int? duration { @@ -97,6 +107,8 @@ namespace osu.Game.Online.Rooms } } + #endregion + // Only supports retrieval for now [Cached] [JsonProperty("ends_at")] @@ -116,11 +128,16 @@ namespace osu.Game.Online.Rooms [JsonIgnore] public readonly Bindable Position = new Bindable(-1); + public Room() + { + Password.BindValueChanged(p => HasPassword.Value = !string.IsNullOrEmpty(p.NewValue)); + } + /// /// Create a copy of this room without online information. /// Should be used to create a local copy of a room for submitting in the future. /// - public Room CreateCopy() + public Room DeepClone() { var copy = new Room(); @@ -144,6 +161,7 @@ namespace osu.Game.Online.Rooms ChannelId.Value = other.ChannelId.Value; Status.Value = other.Status.Value; Availability.Value = other.Availability.Value; + HasPassword.Value = other.HasPassword.Value; Type.Value = other.Type.Value; MaxParticipants.Value = other.MaxParticipants.Value; ParticipantCount.Value = other.ParticipantCount.Value; diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 8119df43ac..3cfa2cc755 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -757,7 +757,7 @@ namespace osu.Game loadComponentSingleFile(userProfile = new UserProfileOverlay(), overlayContent.Add, true); loadComponentSingleFile(beatmapSetOverlay = new BeatmapSetOverlay(), overlayContent.Add, true); loadComponentSingleFile(wikiOverlay = new WikiOverlay(), overlayContent.Add, true); - loadComponentSingleFile(skinEditor = new SkinEditorOverlay(screenContainer), overlayContent.Add); + loadComponentSingleFile(skinEditor = new SkinEditorOverlay(screenContainer), overlayContent.Add, true); loadComponentSingleFile(new LoginOverlay { @@ -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) { @@ -1058,7 +1058,7 @@ namespace osu.Game OverlayActivationMode.BindTo(newOsuScreen.OverlayActivationMode); API.Activity.BindTo(newOsuScreen.Activity); - MusicController.AllowRateAdjustments = newOsuScreen.AllowRateAdjustments; + MusicController.AllowTrackAdjustments = newOsuScreen.AllowTrackAdjustments; if (newOsuScreen.HideOverlaysOnEnter) CloseAllOverlays(); diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 5878727ad8..f2d575550a 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -13,6 +13,7 @@ using osu.Framework.Development; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.IO.Stores; using osu.Framework.Platform; using osu.Game.Beatmaps; @@ -341,7 +342,11 @@ namespace osu.Game globalBindings = new GlobalActionContainer(this) }; - MenuCursorContainer.Child = content = new OsuTooltipContainer(MenuCursorContainer.Cursor) { RelativeSizeAxes = Axes.Both }; + MenuCursorContainer.Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = content = new OsuTooltipContainer(MenuCursorContainer.Cursor) { RelativeSizeAxes = Axes.Both } + }; base.Content.Add(CreateScalingContainer().WithChildren(mainContent)); @@ -471,13 +476,17 @@ namespace osu.Game private void onRulesetChanged(ValueChangedEvent r) { + if (r.NewValue?.Available != true) + { + // reject the change if the ruleset is not available. + Ruleset.Value = r.OldValue?.Available == true ? r.OldValue : RulesetStore.AvailableRulesets.First(); + return; + } + var dict = new Dictionary>(); - if (r.NewValue?.Available == true) - { - foreach (ModType type in Enum.GetValues(typeof(ModType))) - dict[type] = r.NewValue.CreateInstance().GetModsFor(type).ToList(); - } + foreach (ModType type in Enum.GetValues(typeof(ModType))) + dict[type] = r.NewValue.CreateInstance().GetModsFor(type).ToList(); if (!SelectedMods.Disabled) SelectedMods.Value = Array.Empty(); 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/BeatmapListing/BeatmapListingHeader.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.cs index 6a9a71210a..3568fe9e4f 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingHeader.cs @@ -1,6 +1,9 @@ // 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.Localisation; +using osu.Game.Resources.Localisation.Web; + namespace osu.Game.Overlays.BeatmapListing { public class BeatmapListingHeader : OverlayHeader @@ -11,8 +14,8 @@ namespace osu.Game.Overlays.BeatmapListing { public BeatmapListingTitle() { - Title = "beatmap listing"; - Description = "browse for new beatmaps"; + Title = PageTitleStrings.MainBeatmapsetsControllerIndex; + Description = NamedOverlayComponentStrings.BeatmapListingDescription; IconTexture = "Icons/Hexacons/beatmap"; } } diff --git a/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanel.cs b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanel.cs index afb5eeda36..9ff39ce1dd 100644 --- a/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanel.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanel.cs @@ -50,6 +50,7 @@ namespace osu.Game.Overlays.BeatmapListing.Panels protected Action ViewBeatmap; protected BeatmapPanel(BeatmapSetInfo setInfo) + : base(HoverSampleSet.Submit) { Debug.Assert(setInfo.OnlineBeatmapSetID != null); diff --git a/osu.Game/Overlays/BeatmapListing/SearchCategory.cs b/osu.Game/Overlays/BeatmapListing/SearchCategory.cs index 8a9df76af3..d6ae41aba1 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchCategory.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchCategory.cs @@ -1,69 +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; using System.ComponentModel; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapListing { - [LocalisableEnum(typeof(SearchCategoryEnumLocalisationMapper))] public enum SearchCategory { + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.StatusAny))] Any, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.StatusLeaderboard))] [Description("Has Leaderboard")] Leaderboard, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.StatusRanked))] Ranked, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.StatusQualified))] Qualified, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.StatusLoved))] Loved, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.StatusFavourites))] Favourites, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.StatusPending))] [Description("Pending & WIP")] Pending, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.StatusGraveyard))] Graveyard, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.StatusMine))] [Description("My Maps")] Mine, } - - public class SearchCategoryEnumLocalisationMapper : EnumLocalisationMapper - { - public override LocalisableString Map(SearchCategory value) - { - switch (value) - { - case SearchCategory.Any: - return BeatmapsStrings.StatusAny; - - case SearchCategory.Leaderboard: - return BeatmapsStrings.StatusLeaderboard; - - case SearchCategory.Ranked: - return BeatmapsStrings.StatusRanked; - - case SearchCategory.Qualified: - return BeatmapsStrings.StatusQualified; - - case SearchCategory.Loved: - return BeatmapsStrings.StatusLoved; - - case SearchCategory.Favourites: - return BeatmapsStrings.StatusFavourites; - - case SearchCategory.Pending: - return BeatmapsStrings.StatusPending; - - case SearchCategory.Graveyard: - return BeatmapsStrings.StatusGraveyard; - - case SearchCategory.Mine: - return BeatmapsStrings.StatusMine; - - default: - throw new ArgumentOutOfRangeException(nameof(value), value, null); - } - } - } } diff --git a/osu.Game/Overlays/BeatmapListing/SearchExplicit.cs b/osu.Game/Overlays/BeatmapListing/SearchExplicit.cs index 78e6a4e094..80482b32a0 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchExplicit.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchExplicit.cs @@ -1,34 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapListing { - [LocalisableEnum(typeof(SearchExplicitEnumLocalisationMapper))] public enum SearchExplicit { + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.NsfwExclude))] Hide, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.NsfwInclude))] Show } - - public class SearchExplicitEnumLocalisationMapper : EnumLocalisationMapper - { - public override LocalisableString Map(SearchExplicit value) - { - switch (value) - { - case SearchExplicit.Hide: - return BeatmapsStrings.NsfwExclude; - - case SearchExplicit.Show: - return BeatmapsStrings.NsfwInclude; - - default: - throw new ArgumentOutOfRangeException(nameof(value), value, null); - } - } - } } diff --git a/osu.Game/Overlays/BeatmapListing/SearchExtra.cs b/osu.Game/Overlays/BeatmapListing/SearchExtra.cs index 4b3fb6e833..e54632acd8 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchExtra.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchExtra.cs @@ -1,38 +1,20 @@ // 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.ComponentModel; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapListing { - [LocalisableEnum(typeof(SearchExtraEnumLocalisationMapper))] public enum SearchExtra { + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ExtraVideo))] [Description("Has Video")] Video, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ExtraStoryboard))] [Description("Has Storyboard")] Storyboard } - - public class SearchExtraEnumLocalisationMapper : EnumLocalisationMapper - { - public override LocalisableString Map(SearchExtra value) - { - switch (value) - { - case SearchExtra.Video: - return BeatmapsStrings.ExtraVideo; - - case SearchExtra.Storyboard: - return BeatmapsStrings.ExtraStoryboard; - - default: - throw new ArgumentOutOfRangeException(nameof(value), value, null); - } - } - } } diff --git a/osu.Game/Overlays/BeatmapListing/SearchGeneral.cs b/osu.Game/Overlays/BeatmapListing/SearchGeneral.cs index b4c629f7fa..d334b82e88 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchGeneral.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchGeneral.cs @@ -1,44 +1,24 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.ComponentModel; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapListing { - [LocalisableEnum(typeof(SearchGeneralEnumLocalisationMapper))] public enum SearchGeneral { + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GeneralRecommended))] [Description("Recommended difficulty")] Recommended, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GeneralConverts))] [Description("Include converted beatmaps")] Converts, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GeneralFollows))] [Description("Subscribed mappers")] Follows } - - public class SearchGeneralEnumLocalisationMapper : EnumLocalisationMapper - { - public override LocalisableString Map(SearchGeneral value) - { - switch (value) - { - case SearchGeneral.Recommended: - return BeatmapsStrings.GeneralRecommended; - - case SearchGeneral.Converts: - return BeatmapsStrings.GeneralConverts; - - case SearchGeneral.Follows: - return BeatmapsStrings.GeneralFollows; - - default: - throw new ArgumentOutOfRangeException(nameof(value), value, null); - } - } - } } diff --git a/osu.Game/Overlays/BeatmapListing/SearchGenre.cs b/osu.Game/Overlays/BeatmapListing/SearchGenre.cs index b2709ecd2e..08855284cb 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchGenre.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchGenre.cs @@ -1,87 +1,56 @@ // 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.ComponentModel; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapListing { - [LocalisableEnum(typeof(SearchGenreEnumLocalisationMapper))] public enum SearchGenre { + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreAny))] Any = 0, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreUnspecified))] Unspecified = 1, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreVideoGame))] [Description("Video Game")] VideoGame = 2, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreAnime))] Anime = 3, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreRock))] Rock = 4, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenrePop))] Pop = 5, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreOther))] Other = 6, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreNovelty))] Novelty = 7, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreHipHop))] [Description("Hip Hop")] HipHop = 9, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreElectronic))] Electronic = 10, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreMetal))] Metal = 11, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreClassical))] Classical = 12, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreFolk))] Folk = 13, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.GenreJazz))] Jazz = 14 } - - public class SearchGenreEnumLocalisationMapper : EnumLocalisationMapper - { - public override LocalisableString Map(SearchGenre value) - { - switch (value) - { - case SearchGenre.Any: - return BeatmapsStrings.GenreAny; - - case SearchGenre.Unspecified: - return BeatmapsStrings.GenreUnspecified; - - case SearchGenre.VideoGame: - return BeatmapsStrings.GenreVideoGame; - - case SearchGenre.Anime: - return BeatmapsStrings.GenreAnime; - - case SearchGenre.Rock: - return BeatmapsStrings.GenreRock; - - case SearchGenre.Pop: - return BeatmapsStrings.GenrePop; - - case SearchGenre.Other: - return BeatmapsStrings.GenreOther; - - case SearchGenre.Novelty: - return BeatmapsStrings.GenreNovelty; - - case SearchGenre.HipHop: - return BeatmapsStrings.GenreHipHop; - - case SearchGenre.Electronic: - return BeatmapsStrings.GenreElectronic; - - case SearchGenre.Metal: - return BeatmapsStrings.GenreMetal; - - case SearchGenre.Classical: - return BeatmapsStrings.GenreClassical; - - case SearchGenre.Folk: - return BeatmapsStrings.GenreFolk; - - case SearchGenre.Jazz: - return BeatmapsStrings.GenreJazz; - - default: - throw new ArgumentOutOfRangeException(nameof(value), value, null); - } - } - } } diff --git a/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs b/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs index fc176c305a..7ffa0282b7 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchLanguage.cs @@ -1,117 +1,73 @@ // 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.Framework.Utils; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.BeatmapListing { - [LocalisableEnum(typeof(SearchLanguageEnumLocalisationMapper))] [HasOrderedElements] public enum SearchLanguage { + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageAny))] [Order(0)] Any, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageUnspecified))] [Order(14)] Unspecified, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageEnglish))] [Order(1)] English, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageJapanese))] [Order(6)] Japanese, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageChinese))] [Order(2)] Chinese, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageInstrumental))] [Order(12)] Instrumental, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageKorean))] [Order(7)] Korean, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageFrench))] [Order(3)] French, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageGerman))] [Order(4)] German, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageSwedish))] [Order(9)] Swedish, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageSpanish))] [Order(8)] Spanish, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageItalian))] [Order(5)] Italian, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageRussian))] [Order(10)] Russian, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguagePolish))] [Order(11)] Polish, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.LanguageOther))] [Order(13)] Other } - - public class SearchLanguageEnumLocalisationMapper : EnumLocalisationMapper - { - public override LocalisableString Map(SearchLanguage value) - { - switch (value) - { - case SearchLanguage.Any: - return BeatmapsStrings.LanguageAny; - - case SearchLanguage.Unspecified: - return BeatmapsStrings.LanguageUnspecified; - - case SearchLanguage.English: - return BeatmapsStrings.LanguageEnglish; - - case SearchLanguage.Japanese: - return BeatmapsStrings.LanguageJapanese; - - case SearchLanguage.Chinese: - return BeatmapsStrings.LanguageChinese; - - case SearchLanguage.Instrumental: - return BeatmapsStrings.LanguageInstrumental; - - case SearchLanguage.Korean: - return BeatmapsStrings.LanguageKorean; - - case SearchLanguage.French: - return BeatmapsStrings.LanguageFrench; - - case SearchLanguage.German: - return BeatmapsStrings.LanguageGerman; - - case SearchLanguage.Swedish: - return BeatmapsStrings.LanguageSwedish; - - case SearchLanguage.Spanish: - return BeatmapsStrings.LanguageSpanish; - - case SearchLanguage.Italian: - return BeatmapsStrings.LanguageItalian; - - case SearchLanguage.Russian: - return BeatmapsStrings.LanguageRussian; - - case SearchLanguage.Polish: - return BeatmapsStrings.LanguagePolish; - - case SearchLanguage.Other: - return BeatmapsStrings.LanguageOther; - - default: - throw new ArgumentOutOfRangeException(nameof(value), value, null); - } - } - } } diff --git a/osu.Game/Overlays/BeatmapListing/SearchPlayed.cs b/osu.Game/Overlays/BeatmapListing/SearchPlayed.cs index f24cf46c2d..3b04ac01ca 100644 --- a/osu.Game/Overlays/BeatmapListing/SearchPlayed.cs +++ b/osu.Game/Overlays/BeatmapListing/SearchPlayed.cs @@ -1,38 +1,20 @@ // 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.Overlays.BeatmapListing { - [LocalisableEnum(typeof(SearchPlayedEnumLocalisationMapper))] public enum SearchPlayed { + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.PlayedAny))] Any, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.PlayedPlayed))] Played, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.PlayedUnplayed))] Unplayed } - - public class SearchPlayedEnumLocalisationMapper : EnumLocalisationMapper - { - public override LocalisableString Map(SearchPlayed value) - { - switch (value) - { - case SearchPlayed.Any: - return BeatmapsStrings.PlayedAny; - - case SearchPlayed.Played: - return BeatmapsStrings.PlayedPlayed; - - case SearchPlayed.Unplayed: - return BeatmapsStrings.PlayedUnplayed; - - default: - throw new ArgumentOutOfRangeException(nameof(value), value, null); - } - } - } } diff --git a/osu.Game/Overlays/BeatmapListing/SortCriteria.cs b/osu.Game/Overlays/BeatmapListing/SortCriteria.cs index 5ea885eecc..871b3c162b 100644 --- a/osu.Game/Overlays/BeatmapListing/SortCriteria.cs +++ b/osu.Game/Overlays/BeatmapListing/SortCriteria.cs @@ -1,58 +1,35 @@ // 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.Overlays.BeatmapListing { - [LocalisableEnum(typeof(SortCriteriaLocalisationMapper))] public enum SortCriteria { + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingTitle))] Title, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingArtist))] Artist, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingDifficulty))] Difficulty, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingRanked))] Ranked, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingRating))] Rating, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingPlays))] Plays, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingFavourites))] Favourites, + + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.ListingSearchSortingRelevance))] Relevance } - - public class SortCriteriaLocalisationMapper : EnumLocalisationMapper - { - public override LocalisableString Map(SortCriteria value) - { - switch (value) - { - case SortCriteria.Title: - return BeatmapsStrings.ListingSearchSortingTitle; - - case SortCriteria.Artist: - return BeatmapsStrings.ListingSearchSortingArtist; - - case SortCriteria.Difficulty: - return BeatmapsStrings.ListingSearchSortingDifficulty; - - case SortCriteria.Ranked: - return BeatmapsStrings.ListingSearchSortingRanked; - - case SortCriteria.Rating: - return BeatmapsStrings.ListingSearchSortingRating; - - case SortCriteria.Plays: - return BeatmapsStrings.ListingSearchSortingPlays; - - case SortCriteria.Favourites: - return BeatmapsStrings.ListingSearchSortingFavourites; - - case SortCriteria.Relevance: - return BeatmapsStrings.ListingSearchSortingRelevance; - - default: - throw new ArgumentOutOfRangeException(nameof(value), value, null); - } - } - } } diff --git a/osu.Game/Overlays/BeatmapSet/BasicStats.cs b/osu.Game/Overlays/BeatmapSet/BasicStats.cs index b81c60a5b9..3f1034759e 100644 --- a/osu.Game/Overlays/BeatmapSet/BasicStats.cs +++ b/osu.Game/Overlays/BeatmapSet/BasicStats.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Beatmaps; +using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK; @@ -62,7 +63,7 @@ namespace osu.Game.Overlays.BeatmapSet } else { - length.Value = TimeSpan.FromMilliseconds(beatmap.Length).ToString(@"m\:ss"); + length.Value = TimeSpan.FromMilliseconds(beatmap.Length).ToFormattedDuration(); circleCount.Value = beatmap.OnlineInfo.CircleCount.ToString(); sliderCount.Value = beatmap.OnlineInfo.SliderCount.ToString(); } diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeader.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeader.cs index 4b26b02a8e..4a0c0e9f75 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeader.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeader.cs @@ -7,6 +7,7 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Effects; using osu.Game.Beatmaps; +using osu.Game.Resources.Localisation.Web; using osu.Game.Rulesets; using osuTK; using osuTK.Graphics; @@ -54,7 +55,7 @@ namespace osu.Game.Overlays.BeatmapSet { public BeatmapHeaderTitle() { - Title = "beatmap info"; + Title = PageTitleStrings.MainBeatmapsetsControllerShow; IconTexture = "Icons/Hexacons/beatmap"; } } 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/BeatmapSet/LeaderboardModSelector.cs b/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs index 98662e5dea..5b903372fd 100644 --- a/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs +++ b/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs @@ -26,12 +26,14 @@ namespace osu.Game.Overlays.BeatmapSet public LeaderboardModSelector() { - AutoSizeAxes = Axes.Both; + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; InternalChild = modsContainer = new FillFlowContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, - AutoSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, Direction = FillDirection.Full, Spacing = new Vector2(4), }; @@ -54,7 +56,12 @@ namespace osu.Game.Overlays.BeatmapSet modsContainer.Add(new ModButton(new ModNoMod())); modsContainer.AddRange(ruleset.NewValue.CreateInstance().GetAllMods().Where(m => m.UserPlayable).Select(m => new ModButton(m))); - modsContainer.ForEach(button => button.OnSelectionChanged = selectionChanged); + modsContainer.ForEach(button => + { + button.Anchor = Anchor.TopCentre; + button.Origin = Anchor.TopCentre; + button.OnSelectionChanged = selectionChanged; + }); } protected override bool OnHover(HoverEvent e) diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs index ddd1dfa6cd..fee0e62315 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs @@ -18,6 +18,8 @@ using osu.Game.Scoring; using osu.Game.Users.Drawables; using osuTK; using osuTK.Graphics; +using osu.Framework.Localisation; +using osu.Framework.Extensions.LocalisationExtensions; namespace osu.Game.Overlays.BeatmapSet.Scores { @@ -211,11 +213,11 @@ namespace osu.Game.Overlays.BeatmapSet.Scores return content.ToArray(); } - protected override Drawable CreateHeader(int index, TableColumn column) => new HeaderText(column?.Header ?? string.Empty); + protected override Drawable CreateHeader(int index, TableColumn column) => new HeaderText(column?.Header ?? default); private class HeaderText : OsuSpriteText { - public HeaderText(string text) + public HeaderText(LocalisableString text) { Text = text.ToUpper(); Font = OsuFont.GetFont(size: 10, weight: FontWeight.Bold); diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs index 262f321598..3d5f3f595c 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -204,7 +205,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores this.text = text; } - public string Text + public LocalisableString Text { set => text.Text = value; } diff --git a/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs b/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs index 443b3dcf01..0d383c374f 100644 --- a/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs +++ b/osu.Game/Overlays/BreadcrumbControlOverlayHeader.cs @@ -4,15 +4,16 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays { - public abstract class BreadcrumbControlOverlayHeader : TabControlOverlayHeader + public abstract class BreadcrumbControlOverlayHeader : TabControlOverlayHeader { - protected override OsuTabControl CreateTabControl() => new OverlayHeaderBreadcrumbControl(); + protected override OsuTabControl CreateTabControl() => new OverlayHeaderBreadcrumbControl(); - public class OverlayHeaderBreadcrumbControl : BreadcrumbControl + public class OverlayHeaderBreadcrumbControl : BreadcrumbControl { public OverlayHeaderBreadcrumbControl() { @@ -26,7 +27,7 @@ namespace osu.Game.Overlays AccentColour = colourProvider.Light2; } - protected override TabItem CreateTabItem(string value) => new ControlTabItem(value) + protected override TabItem CreateTabItem(LocalisableString? value) => new ControlTabItem(value) { AccentColour = AccentColour, }; @@ -35,7 +36,7 @@ namespace osu.Game.Overlays { protected override float ChevronSize => 8; - public ControlTabItem(string value) + public ControlTabItem(LocalisableString? value) : base(value) { RelativeSizeAxes = Axes.Y; diff --git a/osu.Game/Overlays/Changelog/ChangelogHeader.cs b/osu.Game/Overlays/Changelog/ChangelogHeader.cs index f4be4328e7..52dea63ab7 100644 --- a/osu.Game/Overlays/Changelog/ChangelogHeader.cs +++ b/osu.Game/Overlays/Changelog/ChangelogHeader.cs @@ -9,7 +9,10 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Localisation; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Changelog { @@ -21,16 +24,16 @@ namespace osu.Game.Overlays.Changelog public ChangelogUpdateStreamControl Streams; - private const string listing_string = "listing"; + public static LocalisableString ListingString => LayoutStrings.HeaderChangelogIndex; private Box streamsBackground; public ChangelogHeader() { - TabControl.AddItem(listing_string); + TabControl.AddItem(ListingString); Current.ValueChanged += e => { - if (e.NewValue == listing_string) + if (e.NewValue == ListingString) ListingSelected?.Invoke(); }; @@ -63,7 +66,7 @@ namespace osu.Game.Overlays.Changelog } else { - Current.Value = listing_string; + Current.Value = ListingString; Streams.Current.Value = null; } } @@ -114,8 +117,8 @@ namespace osu.Game.Overlays.Changelog { public ChangelogHeaderTitle() { - Title = "changelog"; - Description = "track recent dev updates in the osu! ecosystem"; + Title = PageTitleStrings.MainChangelogControllerDefault; + Description = NamedOverlayComponentStrings.ChangelogDescription; IconTexture = "Icons/Hexacons/devtools"; } } diff --git a/osu.Game/Overlays/Chat/Tabs/ChannelTabItem.cs b/osu.Game/Overlays/Chat/Tabs/ChannelTabItem.cs index cca4dc33e5..58b402c164 100644 --- a/osu.Game/Overlays/Chat/Tabs/ChannelTabItem.cs +++ b/osu.Game/Overlays/Chat/Tabs/ChannelTabItem.cs @@ -3,6 +3,9 @@ using System; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; +using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -13,6 +16,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.Chat; using osuTK; using osuTK.Graphics; @@ -39,6 +43,8 @@ namespace osu.Game.Overlays.Chat.Tabs protected override Container Content => content; + private Sample sampleTabSwitched; + public ChannelTabItem(Channel value) : base(value) { @@ -112,6 +118,7 @@ namespace osu.Game.Overlays.Chat.Tabs }, }, }, + new HoverSounds() }; } @@ -152,11 +159,12 @@ namespace osu.Game.Overlays.Chat.Tabs } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, AudioManager audio) { BackgroundActive = colours.ChatBlue; BackgroundInactive = colours.Gray4; backgroundHover = colours.Gray7; + sampleTabSwitched = audio.Samples.Get($@"UI/{HoverSampleSet.Default.GetDescription()}-select"); highlightBox.Colour = colours.Yellow; } @@ -217,7 +225,14 @@ namespace osu.Game.Overlays.Chat.Tabs Text.Font = Text.Font.With(weight: FontWeight.Medium); } - protected override void OnActivated() => updateState(); + protected override void OnActivated() + { + if (IsLoaded) + sampleTabSwitched?.Play(); + + updateState(); + } + protected override void OnDeactivated() => updateState(); } } diff --git a/osu.Game/Overlays/Chat/Tabs/PrivateChannelTabItem.cs b/osu.Game/Overlays/Chat/Tabs/PrivateChannelTabItem.cs index 7c82420e08..d01aec630e 100644 --- a/osu.Game/Overlays/Chat/Tabs/PrivateChannelTabItem.cs +++ b/osu.Game/Overlays/Chat/Tabs/PrivateChannelTabItem.cs @@ -25,7 +25,7 @@ namespace osu.Game.Overlays.Chat.Tabs if (value.Type != ChannelType.PM) throw new ArgumentException("Argument value needs to have the targettype user!"); - ClickableAvatar avatar; + DrawableAvatar avatar; AddRange(new Drawable[] { @@ -48,10 +48,9 @@ namespace osu.Game.Overlays.Chat.Tabs Anchor = Anchor.Centre, Origin = Anchor.Centre, Masking = true, - Child = new DelayedLoadWrapper(avatar = new ClickableAvatar(value.Users.First()) + Child = new DelayedLoadWrapper(avatar = new DrawableAvatar(value.Users.First()) { - RelativeSizeAxes = Axes.Both, - OpenOnClick = false, + RelativeSizeAxes = Axes.Both }) { RelativeSizeAxes = Axes.Both, 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/Comments/CommentMarkdownContainer.cs b/osu.Game/Overlays/Comments/CommentMarkdownContainer.cs new file mode 100644 index 0000000000..aeab292b0d --- /dev/null +++ b/osu.Game/Overlays/Comments/CommentMarkdownContainer.cs @@ -0,0 +1,48 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Markdig.Syntax; +using Markdig.Syntax.Inlines; +using osu.Framework.Graphics.Containers.Markdown; +using osu.Game.Graphics.Containers.Markdown; + +namespace osu.Game.Overlays.Comments +{ + public class CommentMarkdownContainer : OsuMarkdownContainer + { + public override MarkdownTextFlowContainer CreateTextFlow() => new CommentMarkdownTextFlowContainer(); + + protected override MarkdownHeading CreateHeading(HeadingBlock headingBlock) => new CommentMarkdownHeading(headingBlock); + + private class CommentMarkdownTextFlowContainer : OsuMarkdownTextFlowContainer + { + // Don't render image in comment for now + protected override void AddImage(LinkInline linkInline) { } + } + + private class CommentMarkdownHeading : OsuMarkdownHeading + { + public CommentMarkdownHeading(HeadingBlock headingBlock) + : base(headingBlock) + { + } + + protected override float GetFontSizeByLevel(int level) + { + var defaultFontSize = base.GetFontSizeByLevel(6); + + switch (level) + { + case 1: + return 1.2f * defaultFontSize; + + case 2: + return 1.1f * defaultFontSize; + + default: + return defaultFontSize; + } + } + } + } +} diff --git a/osu.Game/Overlays/Comments/DrawableComment.cs b/osu.Game/Overlays/Comments/DrawableComment.cs index d94f8c4b8b..3520b15b1e 100644 --- a/osu.Game/Overlays/Comments/DrawableComment.cs +++ b/osu.Game/Overlays/Comments/DrawableComment.cs @@ -13,7 +13,6 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Bindables; using System.Linq; using osu.Game.Graphics.Sprites; -using osu.Game.Online.Chat; using osu.Framework.Allocation; using System.Collections.Generic; using System; @@ -61,7 +60,7 @@ namespace osu.Game.Overlays.Comments { LinkFlowContainer username; FillFlowContainer info; - LinkFlowContainer message; + CommentMarkdownContainer message; GridContainer content; VotePill votePill; @@ -153,10 +152,12 @@ namespace osu.Game.Overlays.Comments } } }, - message = new LinkFlowContainer(s => s.Font = OsuFont.GetFont(size: 14)) + message = new CommentMarkdownContainer { RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y + AutoSizeAxes = Axes.Y, + DocumentMargin = new MarginPadding(0), + DocumentPadding = new MarginPadding(0), }, info = new FillFlowContainer { @@ -275,10 +276,7 @@ namespace osu.Game.Overlays.Comments } if (Comment.HasMessage) - { - var formattedSource = MessageFormatter.FormatText(Comment.Message); - message.AddLinks(formattedSource.Text, formattedSource.Links); - } + message.Text = Comment.Message; if (Comment.IsDeleted) { diff --git a/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs b/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs index 056d4ad6f7..2c8db14950 100644 --- a/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs +++ b/osu.Game/Overlays/Dashboard/DashboardOverlayHeader.cs @@ -1,9 +1,9 @@ // 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.ComponentModel; using osu.Framework.Localisation; +using osu.Game.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Dashboard @@ -16,37 +16,19 @@ namespace osu.Game.Overlays.Dashboard { public DashboardTitle() { - Title = HomeStrings.UserTitle; - Description = "view your friends and other information"; + Title = PageTitleStrings.MainHomeControllerIndex; + Description = NamedOverlayComponentStrings.DashboardDescription; IconTexture = "Icons/Hexacons/social"; } } } - [LocalisableEnum(typeof(DashboardOverlayTabsEnumLocalisationMapper))] public enum DashboardOverlayTabs { + [LocalisableDescription(typeof(FriendsStrings), nameof(FriendsStrings.TitleCompact))] Friends, [Description("Currently Playing")] CurrentlyPlaying } - - public class DashboardOverlayTabsEnumLocalisationMapper : EnumLocalisationMapper - { - public override LocalisableString Map(DashboardOverlayTabs value) - { - switch (value) - { - case DashboardOverlayTabs.Friends: - return FriendsStrings.TitleCompact; - - case DashboardOverlayTabs.CurrentlyPlaying: - return @"Currently Playing"; - - default: - throw new ArgumentOutOfRangeException(nameof(value), value, null); - } - } - } } diff --git a/osu.Game/Overlays/Dashboard/Friends/OnlineStatus.cs b/osu.Game/Overlays/Dashboard/Friends/OnlineStatus.cs index 4b5a7ef066..853c94d8ae 100644 --- a/osu.Game/Overlays/Dashboard/Friends/OnlineStatus.cs +++ b/osu.Game/Overlays/Dashboard/Friends/OnlineStatus.cs @@ -1,38 +1,20 @@ // 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.Overlays.Dashboard.Friends { - [LocalisableEnum(typeof(OnlineStatusEnumLocalisationMapper))] public enum OnlineStatus { + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.StatusAll))] All, + + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.StatusOnline))] Online, + + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.StatusOffline))] Offline } - - public class OnlineStatusEnumLocalisationMapper : EnumLocalisationMapper - { - public override LocalisableString Map(OnlineStatus value) - { - switch (value) - { - case OnlineStatus.All: - return SortStrings.All; - - case OnlineStatus.Online: - return UsersStrings.StatusOnline; - - case OnlineStatus.Offline: - return UsersStrings.StatusOffline; - - default: - throw new ArgumentOutOfRangeException(nameof(value), value, null); - } - } - } } diff --git a/osu.Game/Overlays/Dashboard/Friends/UserSortTabControl.cs b/osu.Game/Overlays/Dashboard/Friends/UserSortTabControl.cs index dc756e2957..7fee5f4668 100644 --- a/osu.Game/Overlays/Dashboard/Friends/UserSortTabControl.cs +++ b/osu.Game/Overlays/Dashboard/Friends/UserSortTabControl.cs @@ -1,7 +1,6 @@ // 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.ComponentModel; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; @@ -12,33 +11,16 @@ namespace osu.Game.Overlays.Dashboard.Friends { } - [LocalisableEnum(typeof(UserSortCriteriaEnumLocalisationMappper))] public enum UserSortCriteria { + [LocalisableDescription(typeof(SortStrings), nameof(SortStrings.LastVisit))] [Description(@"Recently Active")] LastVisit, + + [LocalisableDescription(typeof(SortStrings), nameof(SortStrings.Rank))] Rank, + + [LocalisableDescription(typeof(SortStrings), nameof(SortStrings.Username))] Username } - - public class UserSortCriteriaEnumLocalisationMappper : EnumLocalisationMapper - { - public override LocalisableString Map(UserSortCriteria value) - { - switch (value) - { - case UserSortCriteria.LastVisit: - return SortStrings.LastVisit; - - case UserSortCriteria.Rank: - return SortStrings.Rank; - - case UserSortCriteria.Username: - return SortStrings.Username; - - default: - throw new ArgumentOutOfRangeException(nameof(value), value, null); - } - } - } } diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index 98a79a62c8..793bb79318 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -429,7 +429,7 @@ namespace osu.Game.Overlays.Mods if (!Stacked) modEnumeration = ModUtils.FlattenMods(modEnumeration); - section.Mods = modEnumeration.Select(getValidModOrNull).Where(m => m != null).Select(m => m.CreateCopy()); + section.Mods = modEnumeration.Select(getValidModOrNull).Where(m => m != null).Select(m => m.DeepClone()); } updateSelectedButtons(); diff --git a/osu.Game/Overlays/MusicController.cs b/osu.Game/Overlays/MusicController.cs index a15f80ca21..8fd50c3df2 100644 --- a/osu.Game/Overlays/MusicController.cs +++ b/osu.Game/Overlays/MusicController.cs @@ -400,35 +400,38 @@ namespace osu.Game.Overlays NextTrack(); } - private bool allowRateAdjustments; + private bool allowTrackAdjustments; /// - /// Whether mod rate adjustments are allowed to be applied. + /// Whether mod track adjustments are allowed to be applied. /// - public bool AllowRateAdjustments + public bool AllowTrackAdjustments { - get => allowRateAdjustments; + get => allowTrackAdjustments; set { - if (allowRateAdjustments == value) + if (allowTrackAdjustments == value) return; - allowRateAdjustments = value; + allowTrackAdjustments = value; ResetTrackAdjustments(); } } /// - /// Resets the speed adjustments currently applied on and applies the mod adjustments if is true. + /// Resets the adjustments currently applied on and applies the mod adjustments if is true. /// /// - /// Does not reset speed adjustments applied directly to the beatmap track. + /// Does not reset any adjustments applied directly to the beatmap track. /// public void ResetTrackAdjustments() { - CurrentTrack.ResetSpeedAdjustments(); + CurrentTrack.RemoveAllAdjustments(AdjustableProperty.Balance); + CurrentTrack.RemoveAllAdjustments(AdjustableProperty.Frequency); + CurrentTrack.RemoveAllAdjustments(AdjustableProperty.Tempo); + CurrentTrack.RemoveAllAdjustments(AdjustableProperty.Volume); - if (allowRateAdjustments) + if (allowTrackAdjustments) { foreach (var mod in mods.Value.OfType()) mod.ApplyToTrack(CurrentTrack); diff --git a/osu.Game/Overlays/News/NewsCard.cs b/osu.Game/Overlays/News/NewsCard.cs index 599b45fa78..cc2fa7e1e1 100644 --- a/osu.Game/Overlays/News/NewsCard.cs +++ b/osu.Game/Overlays/News/NewsCard.cs @@ -14,6 +14,7 @@ using osu.Framework.Platform; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Overlays.News @@ -28,6 +29,7 @@ namespace osu.Game.Overlays.News private TextFlowContainer main; public NewsCard(APINewsPost post) + : base(HoverSampleSet.Submit) { this.post = post; diff --git a/osu.Game/Overlays/News/NewsHeader.cs b/osu.Game/Overlays/News/NewsHeader.cs index 56c54425bd..35e3c7755d 100644 --- a/osu.Game/Overlays/News/NewsHeader.cs +++ b/osu.Game/Overlays/News/NewsHeader.cs @@ -4,12 +4,15 @@ using System; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Localisation; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.News { public class NewsHeader : BreadcrumbControlOverlayHeader { - private const string front_page_string = "frontpage"; + public static LocalisableString FrontPageString => NewsStrings.IndexTitleInfo; public Action ShowFrontPage; @@ -17,7 +20,7 @@ namespace osu.Game.Overlays.News public NewsHeader() { - TabControl.AddItem(front_page_string); + TabControl.AddItem(FrontPageString); article.BindValueChanged(onArticleChanged, true); } @@ -28,7 +31,7 @@ namespace osu.Game.Overlays.News Current.BindValueChanged(e => { - if (e.NewValue == front_page_string) + if (e.NewValue == FrontPageString) ShowFrontPage?.Invoke(); }); } @@ -49,7 +52,7 @@ namespace osu.Game.Overlays.News } else { - Current.Value = front_page_string; + Current.Value = FrontPageString; } } @@ -61,8 +64,8 @@ namespace osu.Game.Overlays.News { public NewsHeaderTitle() { - Title = "news"; - Description = "get up-to-date on community happenings"; + Title = PageTitleStrings.MainNewsControllerDefault; + Description = NamedOverlayComponentStrings.NewsDescription; IconTexture = "Icons/Hexacons/news"; } } diff --git a/osu.Game/Overlays/News/Sidebar/MonthSection.cs b/osu.Game/Overlays/News/Sidebar/MonthSection.cs index b300a755f9..cd6ab224a9 100644 --- a/osu.Game/Overlays/News/Sidebar/MonthSection.cs +++ b/osu.Game/Overlays/News/Sidebar/MonthSection.cs @@ -15,13 +15,18 @@ using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; using System.Diagnostics; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Platform; +using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.News.Sidebar { public class MonthSection : CompositeDrawable { private const int animation_duration = 250; + private Sample sampleOpen; + private Sample sampleClose; public readonly BindableBool Expanded = new BindableBool(); @@ -51,6 +56,21 @@ namespace osu.Game.Overlays.News.Sidebar } } }; + + Expanded.ValueChanged += expanded => + { + if (expanded.NewValue) + sampleOpen?.Play(); + else + sampleClose?.Play(); + }; + } + + [BackgroundDependencyLoader] + private void load(AudioManager audio) + { + sampleOpen = audio.Samples.Get(@"UI/dropdown-open"); + sampleClose = audio.Samples.Get(@"UI/dropdown-close"); } private class DropdownHeader : OsuClickableContainer @@ -59,6 +79,8 @@ namespace osu.Game.Overlays.News.Sidebar private readonly SpriteIcon icon; + protected override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(); + public DropdownHeader(int month, int year) { var date = new DateTime(year, month, 1); @@ -104,6 +126,7 @@ namespace osu.Game.Overlays.News.Sidebar private readonly APINewsPost post; public PostButton(APINewsPost post) + : base(HoverSampleSet.Submit) { this.post = post; diff --git a/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs b/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs index c2268ff43c..d7a3b052ae 100644 --- a/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs +++ b/osu.Game/Overlays/OverlayPanelDisplayStyleControl.cs @@ -12,7 +12,6 @@ using osu.Framework.Allocation; using osuTK.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Localisation; -using System; using osu.Game.Resources.Localisation.Web; using osu.Framework.Extensions; @@ -101,32 +100,15 @@ namespace osu.Game.Overlays } } - [LocalisableEnum(typeof(OverlayPanelDisplayStyleEnumLocalisationMapper))] public enum OverlayPanelDisplayStyle { + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.ViewModeCard))] Card, + + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.ViewModeList))] List, + + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.ViewModeBrick))] Brick } - - public class OverlayPanelDisplayStyleEnumLocalisationMapper : EnumLocalisationMapper - { - public override LocalisableString Map(OverlayPanelDisplayStyle value) - { - switch (value) - { - case OverlayPanelDisplayStyle.Card: - return UsersStrings.ViewModeCard; - - case OverlayPanelDisplayStyle.List: - return UsersStrings.ViewModeList; - - case OverlayPanelDisplayStyle.Brick: - return UsersStrings.ViewModeBrick; - - default: - throw new ArgumentOutOfRangeException(nameof(value), value, null); - } - } - } } diff --git a/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs index 62ebee7677..f15fa2705a 100644 --- a/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/CentreHeaderContainer.cs @@ -7,7 +7,9 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Textures; +using osu.Framework.Localisation; using osu.Game.Overlays.Profile.Header.Components; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; using osuTK; @@ -119,12 +121,12 @@ namespace osu.Game.Overlays.Profile.Header { hiddenDetailGlobal = new OverlinedInfoContainer { - Title = "Global Ranking", + Title = UsersStrings.ShowRankGlobalSimple, LineColour = colourProvider.Highlight1 }, hiddenDetailCountry = new OverlinedInfoContainer { - Title = "Country Ranking", + Title = UsersStrings.ShowRankCountrySimple, LineColour = colourProvider.Highlight1 }, } @@ -144,8 +146,8 @@ namespace osu.Game.Overlays.Profile.Header private void updateDisplay(User user) { - hiddenDetailGlobal.Content = user?.Statistics?.GlobalRank?.ToString("\\##,##0") ?? "-"; - hiddenDetailCountry.Content = user?.Statistics?.CountryRank?.ToString("\\##,##0") ?? "-"; + hiddenDetailGlobal.Content = user?.Statistics?.GlobalRank?.ToLocalisableString("\\##,##0") ?? (LocalisableString)"-"; + hiddenDetailCountry.Content = user?.Statistics?.CountryRank?.ToLocalisableString("\\##,##0") ?? (LocalisableString)"-"; } } } diff --git a/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs b/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs index 16b443875e..b4a5d5e31b 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ExpandDetailsButton.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Graphics.UserInterface; +using osu.Game.Resources.Localisation.Web; using osuTK; namespace osu.Game.Overlays.Profile.Header.Components @@ -18,7 +19,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { public readonly BindableBool DetailsVisible = new BindableBool(); - public override LocalisableString TooltipText => DetailsVisible.Value ? "collapse" : "expand"; + public override LocalisableString TooltipText => DetailsVisible.Value ? CommonStrings.ButtonsCollapse : CommonStrings.ButtonsExpand; private SpriteIcon icon; private Sample sampleOpen; diff --git a/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs b/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs index db94762efd..8f66120055 100644 --- a/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/FollowersButton.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; namespace osu.Game.Overlays.Profile.Header.Components @@ -13,7 +14,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { public readonly Bindable User = new Bindable(); - public override LocalisableString TooltipText => "followers"; + public override LocalisableString TooltipText => FriendsStrings.ButtonsDisabled; protected override IconUsage Icon => FontAwesome.Solid.User; diff --git a/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs b/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs index a0b8ef0f11..1deed1a748 100644 --- a/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs +++ b/osu.Game/Overlays/Profile/Header/Components/LevelBadge.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Textures; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; namespace osu.Game.Overlays.Profile.Header.Components @@ -19,13 +20,13 @@ namespace osu.Game.Overlays.Profile.Header.Components { public readonly Bindable User = new Bindable(); - public LocalisableString TooltipText { get; } + public LocalisableString TooltipText { get; private set; } private OsuSpriteText levelText; public LevelBadge() { - TooltipText = "level"; + TooltipText = UsersStrings.ShowStatsLevel("0"); } [BackgroundDependencyLoader] @@ -53,6 +54,7 @@ namespace osu.Game.Overlays.Profile.Header.Components private void updateLevel(User user) { levelText.Text = user?.Statistics?.Level.Current.ToString() ?? "0"; + TooltipText = UsersStrings.ShowStatsLevel(user?.Statistics?.Level.Current.ToString()); } } } diff --git a/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs b/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs index 528b05a80a..877637be22 100644 --- a/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs +++ b/osu.Game/Overlays/Profile/Header/Components/LevelProgressBar.cs @@ -10,6 +10,7 @@ using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; using osuTK.Graphics; @@ -26,7 +27,7 @@ namespace osu.Game.Overlays.Profile.Header.Components public LevelProgressBar() { - TooltipText = "progress to next level"; + TooltipText = UsersStrings.ShowStatsLevelProgress; } [BackgroundDependencyLoader] @@ -60,7 +61,7 @@ namespace osu.Game.Overlays.Profile.Header.Components private void updateProgress(User user) { levelProgressBar.Length = user?.Statistics?.Level.Progress / 100f ?? 0; - levelProgressText.Text = user?.Statistics?.Level.Progress.ToString("0'%'"); + levelProgressText.Text = user?.Statistics?.Level.Progress.ToLocalisableString("0'%'"); } } } diff --git a/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs b/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs index ae3d024fbf..5cdf3a5ef9 100644 --- a/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/MappingSubscribersButton.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; namespace osu.Game.Overlays.Profile.Header.Components @@ -13,7 +14,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { public readonly Bindable User = new Bindable(); - public override LocalisableString TooltipText => "mapping subscribers"; + public override LocalisableString TooltipText => FollowsStrings.MappingFollowers; protected override IconUsage Icon => FontAwesome.Solid.Bell; diff --git a/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs b/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs index 4c2cc768ce..07f1f1c3ed 100644 --- a/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/MessageUserButton.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Online.API; using osu.Game.Online.Chat; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; using osuTK; @@ -17,7 +18,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { public readonly Bindable User = new Bindable(); - public override LocalisableString TooltipText => "send message"; + public override LocalisableString TooltipText => UsersStrings.CardSendMessage; [Resolved(CanBeNull = true)] private ChannelManager channelManager { get; set; } diff --git a/osu.Game/Overlays/Profile/Header/Components/OverlinedInfoContainer.cs b/osu.Game/Overlays/Profile/Header/Components/OverlinedInfoContainer.cs index 9f56a34aa6..5ef8482b47 100644 --- a/osu.Game/Overlays/Profile/Header/Components/OverlinedInfoContainer.cs +++ b/osu.Game/Overlays/Profile/Header/Components/OverlinedInfoContainer.cs @@ -4,6 +4,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK.Graphics; @@ -16,12 +17,12 @@ namespace osu.Game.Overlays.Profile.Header.Components private readonly OsuSpriteText title; private readonly OsuSpriteText content; - public string Title + public LocalisableString Title { set => title.Text = value; } - public string Content + public LocalisableString Content { set => content.Text = value; } diff --git a/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs b/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs index aa7cb8636a..1a40944632 100644 --- a/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs +++ b/osu.Game/Overlays/Profile/Header/Components/OverlinedTotalPlayTime.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; namespace osu.Game.Overlays.Profile.Header.Components @@ -31,7 +32,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { InternalChild = info = new OverlinedInfoContainer { - Title = "Total Play Time", + Title = UsersStrings.ShowStatsPlayTime, LineColour = colourProvider.Highlight1, }; diff --git a/osu.Game/Overlays/Profile/Header/Components/PreviousUsernames.cs b/osu.Game/Overlays/Profile/Header/Components/PreviousUsernames.cs index 3cdf110090..14eeb4e5f0 100644 --- a/osu.Game/Overlays/Profile/Header/Components/PreviousUsernames.cs +++ b/osu.Game/Overlays/Profile/Header/Components/PreviousUsernames.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; using osuTK; @@ -68,7 +69,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, - Text = @"formerly known as", + Text = UsersStrings.ShowPreviousUsernames, Font = OsuFont.GetFont(size: 10, italics: true) } }, diff --git a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs index b65d5e2329..1235836aac 100644 --- a/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs +++ b/osu.Game/Overlays/Profile/Header/Components/ProfileHeaderStatisticsButton.cs @@ -4,6 +4,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK; @@ -46,6 +47,6 @@ namespace osu.Game.Overlays.Profile.Header.Components protected abstract IconUsage Icon { get; } - protected void SetValue(int value) => drawableText.Text = value.ToString("#,##0"); + protected void SetValue(int value) => drawableText.Text = value.ToLocalisableString("#,##0"); } } diff --git a/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs b/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs index ad91e491ef..74a25591b4 100644 --- a/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs +++ b/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs @@ -7,8 +7,10 @@ using System.Linq; using Humanizer; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; namespace osu.Game.Overlays.Profile.Header.Components @@ -27,7 +29,7 @@ namespace osu.Game.Overlays.Profile.Header.Components { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Text = "No recent plays", + Text = UsersStrings.ShowExtraUnranked, Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular) }); } @@ -64,7 +66,7 @@ namespace osu.Game.Overlays.Profile.Header.Components return new TooltipDisplayContent { - Rank = $"#{rank:N0}", + Rank = rank.ToLocalisableString("\\##,##0"), Time = days == 0 ? "now" : $"{"day".ToQuantity(days)} ago" }; } @@ -74,7 +76,7 @@ namespace osu.Game.Overlays.Profile.Header.Components private class RankGraphTooltip : UserGraphTooltip { public RankGraphTooltip() - : base("Global Ranking") + : base(UsersStrings.ShowRankGlobalSimple) { } @@ -91,7 +93,7 @@ namespace osu.Game.Overlays.Profile.Header.Components private class TooltipDisplayContent { - public string Rank; + public LocalisableString Rank; public string Time; } } diff --git a/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs b/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs index 9a43997030..77f0378762 100644 --- a/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs +++ b/osu.Game/Overlays/Profile/Header/Components/SupporterIcon.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Graphics; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Header.Components { @@ -19,7 +20,7 @@ namespace osu.Game.Overlays.Profile.Header.Components private readonly FillFlowContainer iconContainer; private readonly CircularContainer content; - public LocalisableString TooltipText => "osu!supporter"; + public LocalisableString TooltipText => UsersStrings.ShowIsSupporter; public int SupportLevel { diff --git a/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs index 574aef02fd..9e52751904 100644 --- a/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/DetailHeaderContainer.cs @@ -7,10 +7,12 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Online.Leaderboards; using osu.Game.Overlays.Profile.Header.Components; +using osu.Game.Resources.Localisation.Web; using osu.Game.Scoring; using osu.Game.Users; using osuTK; @@ -100,7 +102,7 @@ namespace osu.Game.Overlays.Profile.Header }, medalInfo = new OverlinedInfoContainer { - Title = "Medals", + Title = UsersStrings.ShowStatsMedals, LineColour = colours.GreenLight, }, ppInfo = new OverlinedInfoContainer @@ -151,12 +153,12 @@ namespace osu.Game.Overlays.Profile.Header { detailGlobalRank = new OverlinedInfoContainer(true, 110) { - Title = "Global Ranking", + Title = UsersStrings.ShowRankGlobalSimple, LineColour = colourProvider.Highlight1, }, detailCountryRank = new OverlinedInfoContainer(false, 110) { - Title = "Country Ranking", + Title = UsersStrings.ShowRankCountrySimple, LineColour = colourProvider.Highlight1, }, } @@ -171,13 +173,13 @@ namespace osu.Game.Overlays.Profile.Header private void updateDisplay(User user) { medalInfo.Content = user?.Achievements?.Length.ToString() ?? "0"; - ppInfo.Content = user?.Statistics?.PP?.ToString("#,##0") ?? "0"; + ppInfo.Content = user?.Statistics?.PP?.ToLocalisableString("#,##0") ?? (LocalisableString)"0"; foreach (var scoreRankInfo in scoreRankInfos) scoreRankInfo.Value.RankCount = user?.Statistics?.GradesCount[scoreRankInfo.Key] ?? 0; - detailGlobalRank.Content = user?.Statistics?.GlobalRank?.ToString("\\##,##0") ?? "-"; - detailCountryRank.Content = user?.Statistics?.CountryRank?.ToString("\\##,##0") ?? "-"; + detailGlobalRank.Content = user?.Statistics?.GlobalRank?.ToLocalisableString("\\##,##0") ?? (LocalisableString)"-"; + detailCountryRank.Content = user?.Statistics?.CountryRank?.ToLocalisableString("\\##,##0") ?? (LocalisableString)"-"; rankGraph.Statistics.Value = user?.Statistics; } @@ -188,7 +190,7 @@ namespace osu.Game.Overlays.Profile.Header public int RankCount { - set => rankCount.Text = value.ToString("#,##0"); + set => rankCount.Text = value.ToLocalisableString("#,##0"); } public ScoreRankInfo(ScoreRank rank) diff --git a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs index d751424367..438f52a2ce 100644 --- a/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/TopHeaderContainer.cs @@ -7,11 +7,13 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Overlays.Profile.Header.Components; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; using osu.Game.Users.Drawables; using osuTK; @@ -179,19 +181,19 @@ namespace osu.Game.Overlays.Profile.Header if (user?.Statistics != null) { - userStats.Add(new UserStatsLine("Ranked Score", user.Statistics.RankedScore.ToString("#,##0"))); - userStats.Add(new UserStatsLine("Hit Accuracy", user.Statistics.DisplayAccuracy)); - userStats.Add(new UserStatsLine("Play Count", user.Statistics.PlayCount.ToString("#,##0"))); - userStats.Add(new UserStatsLine("Total Score", user.Statistics.TotalScore.ToString("#,##0"))); - userStats.Add(new UserStatsLine("Total Hits", user.Statistics.TotalHits.ToString("#,##0"))); - userStats.Add(new UserStatsLine("Maximum Combo", user.Statistics.MaxCombo.ToString("#,##0"))); - userStats.Add(new UserStatsLine("Replays Watched by Others", user.Statistics.ReplaysWatched.ToString("#,##0"))); + userStats.Add(new UserStatsLine(UsersStrings.ShowStatsRankedScore, user.Statistics.RankedScore.ToLocalisableString("#,##0"))); + userStats.Add(new UserStatsLine(UsersStrings.ShowStatsHitAccuracy, user.Statistics.DisplayAccuracy)); + userStats.Add(new UserStatsLine(UsersStrings.ShowStatsPlayCount, user.Statistics.PlayCount.ToLocalisableString("#,##0"))); + userStats.Add(new UserStatsLine(UsersStrings.ShowStatsTotalScore, user.Statistics.TotalScore.ToLocalisableString("#,##0"))); + userStats.Add(new UserStatsLine(UsersStrings.ShowStatsTotalHits, user.Statistics.TotalHits.ToLocalisableString("#,##0"))); + userStats.Add(new UserStatsLine(UsersStrings.ShowStatsMaximumCombo, user.Statistics.MaxCombo.ToLocalisableString("#,##0"))); + userStats.Add(new UserStatsLine(UsersStrings.ShowStatsReplaysWatchedByOthers, user.Statistics.ReplaysWatched.ToLocalisableString("#,##0"))); } } private class UserStatsLine : Container { - public UserStatsLine(string left, string right) + public UserStatsLine(LocalisableString left, LocalisableString right) { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; diff --git a/osu.Game/Overlays/Profile/ProfileHeader.cs b/osu.Game/Overlays/Profile/ProfileHeader.cs index c947ef0781..815f9fdafc 100644 --- a/osu.Game/Overlays/Profile/ProfileHeader.cs +++ b/osu.Game/Overlays/Profile/ProfileHeader.cs @@ -7,12 +7,14 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Overlays.Profile.Header; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; namespace osu.Game.Overlays.Profile { - public class ProfileHeader : TabControlOverlayHeader + public class ProfileHeader : TabControlOverlayHeader { private UserCoverBackground coverContainer; @@ -27,8 +29,8 @@ namespace osu.Game.Overlays.Profile User.ValueChanged += e => updateDisplay(e.NewValue); - TabControl.AddItem("info"); - TabControl.AddItem("modding"); + TabControl.AddItem(LayoutStrings.HeaderUsersShow); + TabControl.AddItem(LayoutStrings.HeaderUsersModding); centreHeaderContainer.DetailsVisible.BindValueChanged(visible => detailHeaderContainer.Expanded = visible.NewValue, true); } @@ -96,7 +98,7 @@ namespace osu.Game.Overlays.Profile { public ProfileHeaderTitle() { - Title = "player info"; + Title = PageTitleStrings.MainUsersControllerDefault; IconTexture = "Icons/Hexacons/profile"; } } diff --git a/osu.Game/Overlays/Profile/ProfileSection.cs b/osu.Game/Overlays/Profile/ProfileSection.cs index 21f7921da6..1a5f562fff 100644 --- a/osu.Game/Overlays/Profile/ProfileSection.cs +++ b/osu.Game/Overlays/Profile/ProfileSection.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Sprites; @@ -17,7 +18,7 @@ namespace osu.Game.Overlays.Profile { public abstract class ProfileSection : Container { - public abstract string Title { get; } + public abstract LocalisableString Title { get; } public abstract string Identifier { get; } diff --git a/osu.Game/Overlays/Profile/Sections/AboutSection.cs b/osu.Game/Overlays/Profile/Sections/AboutSection.cs index 1bc01cfd9e..d0d9362fd2 100644 --- a/osu.Game/Overlays/Profile/Sections/AboutSection.cs +++ b/osu.Game/Overlays/Profile/Sections/AboutSection.cs @@ -1,12 +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 osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; + namespace osu.Game.Overlays.Profile.Sections { public class AboutSection : ProfileSection { - public override string Title => "me!"; + public override LocalisableString Title => UsersStrings.ShowExtraMeTitle; - public override string Identifier => "me"; + public override string Identifier => @"me"; } } diff --git a/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs b/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs index 67a976fe6f..a8a4cfc365 100644 --- a/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs @@ -6,6 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; namespace osu.Game.Overlays.Profile.Sections { @@ -17,6 +18,7 @@ namespace osu.Game.Overlays.Profile.Sections private readonly BeatmapInfo beatmap; protected BeatmapMetadataContainer(BeatmapInfo beatmap) + : base(HoverSampleSet.Submit) { this.beatmap = beatmap; diff --git a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs index fe9c710bcc..8657e356c9 100644 --- a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; @@ -19,7 +20,7 @@ namespace osu.Game.Overlays.Profile.Sections.Beatmaps private const float panel_padding = 10f; private readonly BeatmapSetType type; - public PaginatedBeatmapContainer(BeatmapSetType type, Bindable user, string headerText) + public PaginatedBeatmapContainer(BeatmapSetType type, Bindable user, LocalisableString headerText) : base(user, headerText) { this.type = type; @@ -45,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 c283de42f3..af6ab4aad1 100644 --- a/osu.Game/Overlays/Profile/Sections/BeatmapsSection.cs +++ b/osu.Game/Overlays/Profile/Sections/BeatmapsSection.cs @@ -1,26 +1,28 @@ // 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.Localisation; using osu.Game.Online.API.Requests; using osu.Game.Overlays.Profile.Sections.Beatmaps; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections { public class BeatmapsSection : ProfileSection { - public override string Title => "Beatmaps"; + public override LocalisableString Title => UsersStrings.ShowExtraBeatmapsTitle; - public override string Identifier => "beatmaps"; + public override string Identifier => @"beatmaps"; public BeatmapsSection() { Children = new[] { - new PaginatedBeatmapContainer(BeatmapSetType.Favourite, User, "Favourite Beatmaps"), - new PaginatedBeatmapContainer(BeatmapSetType.RankedAndApproved, User, "Ranked & Approved Beatmaps"), - new PaginatedBeatmapContainer(BeatmapSetType.Loved, User, "Loved Beatmaps"), - new PaginatedBeatmapContainer(BeatmapSetType.Unranked, User, "Pending Beatmaps"), - new PaginatedBeatmapContainer(BeatmapSetType.Graveyard, User, "Graveyarded Beatmaps") + new PaginatedBeatmapContainer(BeatmapSetType.Favourite, User, UsersStrings.ShowExtraBeatmapsFavouriteTitle), + new PaginatedBeatmapContainer(BeatmapSetType.Ranked, User, UsersStrings.ShowExtraBeatmapsRankedTitle), + new PaginatedBeatmapContainer(BeatmapSetType.Loved, User, UsersStrings.ShowExtraBeatmapsLovedTitle), + new PaginatedBeatmapContainer(BeatmapSetType.Pending, User, UsersStrings.ShowExtraBeatmapsPendingTitle), + new PaginatedBeatmapContainer(BeatmapSetType.Graveyard, User, UsersStrings.ShowExtraBeatmapsGraveyardTitle) }; } } diff --git a/osu.Game/Overlays/Profile/Sections/CounterPill.cs b/osu.Game/Overlays/Profile/Sections/CounterPill.cs index ca8abcfe5a..34211b40b7 100644 --- a/osu.Game/Overlays/Profile/Sections/CounterPill.cs +++ b/osu.Game/Overlays/Profile/Sections/CounterPill.cs @@ -8,6 +8,7 @@ using osu.Game.Graphics; using osu.Framework.Bindables; using osu.Game.Graphics.Sprites; using osu.Framework.Allocation; +using osu.Framework.Localisation; namespace osu.Game.Overlays.Profile.Sections { @@ -48,7 +49,7 @@ namespace osu.Game.Overlays.Profile.Sections private void onCurrentChanged(ValueChangedEvent value) { - counter.Text = value.NewValue.ToString("N0"); + counter.Text = value.NewValue.ToLocalisableString("N0"); } } } diff --git a/osu.Game/Overlays/Profile/Sections/Historical/ChartProfileSubsection.cs b/osu.Game/Overlays/Profile/Sections/Historical/ChartProfileSubsection.cs index a48036dcbb..986b3d9874 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/ChartProfileSubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/ChartProfileSubsection.cs @@ -6,6 +6,7 @@ using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; using osu.Game.Users; using static osu.Game.Users.User; @@ -18,9 +19,9 @@ namespace osu.Game.Overlays.Profile.Sections.Historical /// /// Text describing the value being plotted on the graph, which will be displayed as a prefix to the value in the history graph tooltip. /// - protected abstract string GraphCounterName { get; } + protected abstract LocalisableString GraphCounterName { get; } - protected ChartProfileSubsection(Bindable user, string headerText) + protected ChartProfileSubsection(Bindable user, LocalisableString headerText) : base(user, headerText) { } diff --git a/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs b/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs index 6f1869966a..a419bef233 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs @@ -13,6 +13,7 @@ using osu.Game.Graphics.Sprites; using osuTK; using osu.Framework.Graphics.Cursor; using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections.Historical { @@ -143,7 +144,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical private class PlayCountText : CompositeDrawable, IHasTooltip { - public LocalisableString TooltipText => "times played"; + public LocalisableString TooltipText => UsersStrings.ShowExtraHistoricalMostPlayedCount; public PlayCountText(int playCount) { diff --git a/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs index eeb14e5e4f..d0979526da 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics.Containers; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; namespace osu.Game.Overlays.Profile.Sections.Historical @@ -16,7 +17,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical public class PaginatedMostPlayedBeatmapContainer : PaginatedProfileSubsection { public PaginatedMostPlayedBeatmapContainer(Bindable user) - : base(user, "Most Played Beatmaps") + : base(user, UsersStrings.ShowExtraHistoricalMostPlayedTitle) { ItemsPerPage = 5; } diff --git a/osu.Game/Overlays/Profile/Sections/Historical/PlayHistorySubsection.cs b/osu.Game/Overlays/Profile/Sections/Historical/PlayHistorySubsection.cs index dfd29db693..83c005970e 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/PlayHistorySubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/PlayHistorySubsection.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; using static osu.Game.Users.User; @@ -9,10 +11,10 @@ namespace osu.Game.Overlays.Profile.Sections.Historical { public class PlayHistorySubsection : ChartProfileSubsection { - protected override string GraphCounterName => "Plays"; + protected override LocalisableString GraphCounterName => UsersStrings.ShowExtraHistoricalMonthlyPlaycountsCountLabel; public PlayHistorySubsection(Bindable user) - : base(user, "Play History") + : base(user, UsersStrings.ShowExtraHistoricalMonthlyPlaycountsTitle) { } diff --git a/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs b/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs index eb5deb2802..449b1da35d 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/ProfileLineChart.cs @@ -12,6 +12,7 @@ using osu.Framework.Allocation; using osu.Game.Graphics; using osu.Framework.Graphics.Shapes; using osuTK; +using osu.Framework.Localisation; using static osu.Game.Users.User; namespace osu.Game.Overlays.Profile.Sections.Historical @@ -42,7 +43,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical private readonly Container rowLinesContainer; private readonly Container columnLinesContainer; - public ProfileLineChart(string graphCounterName) + public ProfileLineChart(LocalisableString graphCounterName) { RelativeSizeAxes = Axes.X; Height = 250; @@ -169,7 +170,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical Origin = Anchor.CentreRight, RelativePositionAxes = Axes.Y, Margin = new MarginPadding { Right = 3 }, - Text = value.ToString("N0"), + Text = value.ToLocalisableString("N0"), Font = OsuFont.GetFont(size: 12), Y = y }); @@ -192,7 +193,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical { Origin = Anchor.CentreLeft, RelativePositionAxes = Axes.X, - Text = value.ToString("MMM yyyy"), + Text = value.ToLocalisableString("MMM yyyy"), Font = OsuFont.GetFont(size: 12, weight: FontWeight.SemiBold), Rotation = 45, X = x diff --git a/osu.Game/Overlays/Profile/Sections/Historical/ReplaysSubsection.cs b/osu.Game/Overlays/Profile/Sections/Historical/ReplaysSubsection.cs index 1c28306f17..76d5f73bd7 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/ReplaysSubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/ReplaysSubsection.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; using static osu.Game.Users.User; @@ -9,10 +11,10 @@ namespace osu.Game.Overlays.Profile.Sections.Historical { public class ReplaysSubsection : ChartProfileSubsection { - protected override string GraphCounterName => "Replays Watched"; + protected override LocalisableString GraphCounterName => UsersStrings.ShowExtraHistoricalReplaysWatchedCountsCountLabel; public ReplaysSubsection(Bindable user) - : base(user, "Replays Watched History") + : base(user, UsersStrings.ShowExtraHistoricalReplaysWatchedCountsTitle) { } diff --git a/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs b/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs index 52831b4243..ac94f0fc87 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/UserHistoryGraph.cs @@ -5,13 +5,14 @@ using System; using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; +using osu.Framework.Localisation; using static osu.Game.Users.User; namespace osu.Game.Overlays.Profile.Sections.Historical { public class UserHistoryGraph : UserGraph { - private readonly string tooltipCounterName; + private readonly LocalisableString tooltipCounterName; [CanBeNull] public UserHistoryCount[] Values @@ -19,7 +20,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical set => Data = value?.Select(v => new KeyValuePair(v.Date, v.Count)).ToArray(); } - public UserHistoryGraph(string tooltipCounterName) + public UserHistoryGraph(LocalisableString tooltipCounterName) { this.tooltipCounterName = tooltipCounterName; } @@ -33,16 +34,16 @@ namespace osu.Game.Overlays.Profile.Sections.Historical return new TooltipDisplayContent { Name = tooltipCounterName, - Count = playCount.ToString("N0"), - Date = date.ToString("MMMM yyyy") + Count = playCount.ToLocalisableString("N0"), + Date = date.ToLocalisableString("MMMM yyyy") }; } protected class HistoryGraphTooltip : UserGraphTooltip { - private readonly string tooltipCounterName; + private readonly LocalisableString tooltipCounterName; - public HistoryGraphTooltip(string tooltipCounterName) + public HistoryGraphTooltip(LocalisableString tooltipCounterName) : base(tooltipCounterName) { this.tooltipCounterName = tooltipCounterName; @@ -61,9 +62,9 @@ namespace osu.Game.Overlays.Profile.Sections.Historical private class TooltipDisplayContent { - public string Name; - public string Count; - public string Date; + public LocalisableString Name; + public LocalisableString Count; + public LocalisableString Date; } } } diff --git a/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs b/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs index 4fbb7fc7d7..203844b6b5 100644 --- a/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs +++ b/osu.Game/Overlays/Profile/Sections/HistoricalSection.cs @@ -2,17 +2,19 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Online.API.Requests; using osu.Game.Overlays.Profile.Sections.Historical; using osu.Game.Overlays.Profile.Sections.Ranks; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections { public class HistoricalSection : ProfileSection { - public override string Title => "Historical"; + public override LocalisableString Title => UsersStrings.ShowExtraHistoricalTitle; - public override string Identifier => "historical"; + public override string Identifier => @"historical"; public HistoricalSection() { @@ -20,7 +22,7 @@ namespace osu.Game.Overlays.Profile.Sections { new PlayHistorySubsection(User), new PaginatedMostPlayedBeatmapContainer(User), - new PaginatedScoreContainer(ScoreType.Recent, User, "Recent Plays (24h)"), + new PaginatedScoreContainer(ScoreType.Recent, User, UsersStrings.ShowExtraHistoricalRecentPlaysTitle), new ReplaysSubsection(User) }; } diff --git a/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs b/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs index cdb24b784c..eb55a0a78d 100644 --- a/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs +++ b/osu.Game/Overlays/Profile/Sections/Kudosu/KudosuInfo.cs @@ -12,6 +12,8 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Users; using osu.Framework.Allocation; +using osu.Game.Resources.Localisation.Web; +using osu.Framework.Localisation; namespace osu.Game.Overlays.Profile.Sections.Kudosu { @@ -37,7 +39,7 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu private class CountTotal : CountSection { public CountTotal() - : base("Total Kudosu Earned") + : base(UsersStrings.ShowExtraKudosuTotal) { DescriptionText.AddText("Based on how much of a contribution the user has made to beatmap moderation. See "); DescriptionText.AddLink("this page", "https://osu.ppy.sh/wiki/Kudosu"); @@ -53,10 +55,10 @@ namespace osu.Game.Overlays.Profile.Sections.Kudosu public new int Count { - set => valueText.Text = value.ToString("N0"); + set => valueText.Text = value.ToLocalisableString("N0"); } - public CountSection(string header) + public CountSection(LocalisableString header) { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; diff --git a/osu.Game/Overlays/Profile/Sections/Kudosu/PaginatedKudosuHistoryContainer.cs b/osu.Game/Overlays/Profile/Sections/Kudosu/PaginatedKudosuHistoryContainer.cs index 008d89d881..76cd7ed722 100644 --- a/osu.Game/Overlays/Profile/Sections/Kudosu/PaginatedKudosuHistoryContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Kudosu/PaginatedKudosuHistoryContainer.cs @@ -8,13 +8,14 @@ using osu.Framework.Bindables; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.API; using System.Collections.Generic; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections.Kudosu { public class PaginatedKudosuHistoryContainer : PaginatedProfileSubsection { public PaginatedKudosuHistoryContainer(Bindable user) - : base(user, missingText: "This user hasn't received any kudosu!") + : base(user, missingText: UsersStrings.ShowExtraKudosuEntryEmpty) { ItemsPerPage = 5; } diff --git a/osu.Game/Overlays/Profile/Sections/KudosuSection.cs b/osu.Game/Overlays/Profile/Sections/KudosuSection.cs index a9e9952257..5b749c78a8 100644 --- a/osu.Game/Overlays/Profile/Sections/KudosuSection.cs +++ b/osu.Game/Overlays/Profile/Sections/KudosuSection.cs @@ -3,14 +3,16 @@ using osu.Framework.Graphics; using osu.Game.Overlays.Profile.Sections.Kudosu; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections { public class KudosuSection : ProfileSection { - public override string Title => "Kudosu!"; + public override LocalisableString Title => UsersStrings.ShowExtraKudosuTitle; - public override string Identifier => "kudosu"; + public override string Identifier => @"kudosu"; public KudosuSection() { diff --git a/osu.Game/Overlays/Profile/Sections/MedalsSection.cs b/osu.Game/Overlays/Profile/Sections/MedalsSection.cs index 575a2f2c19..cacdd44b61 100644 --- a/osu.Game/Overlays/Profile/Sections/MedalsSection.cs +++ b/osu.Game/Overlays/Profile/Sections/MedalsSection.cs @@ -1,12 +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 osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; + namespace osu.Game.Overlays.Profile.Sections { public class MedalsSection : ProfileSection { - public override string Title => "Medals"; + public override LocalisableString Title => UsersStrings.ShowExtraMedalsTitle; - public override string Identifier => "medals"; + public override string Identifier => @"medals"; } } diff --git a/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs b/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs index e237b43b2e..d60243cd0a 100644 --- a/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs @@ -15,6 +15,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets; using osu.Game.Graphics.Sprites; using osu.Game.Graphics; +using osu.Framework.Localisation; namespace osu.Game.Overlays.Profile.Sections { @@ -36,9 +37,9 @@ namespace osu.Game.Overlays.Profile.Sections private ShowMoreButton moreButton; private OsuSpriteText missing; - private readonly string missingText; + private readonly LocalisableString? missingText; - protected PaginatedProfileSubsection(Bindable user, string headerText = "", string missingText = "") + protected PaginatedProfileSubsection(Bindable user, LocalisableString? headerText = null, LocalisableString? missingText = null) : base(user, headerText, CounterVisibilityState.AlwaysVisible) { this.missingText = missingText; @@ -68,7 +69,7 @@ namespace osu.Game.Overlays.Profile.Sections missing = new OsuSpriteText { Font = OsuFont.GetFont(size: 15), - Text = missingText, + Text = missingText ?? string.Empty, Alpha = 0, } } @@ -114,7 +115,7 @@ namespace osu.Game.Overlays.Profile.Sections moreButton.Hide(); moreButton.IsLoading = false; - if (!string.IsNullOrEmpty(missingText)) + if (missingText.HasValue) missing.Show(); return; diff --git a/osu.Game/Overlays/Profile/Sections/ProfileSubsection.cs b/osu.Game/Overlays/Profile/Sections/ProfileSubsection.cs index 3e331f85e9..5a17f0d8bb 100644 --- a/osu.Game/Overlays/Profile/Sections/ProfileSubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/ProfileSubsection.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Users; using JetBrains.Annotations; +using osu.Framework.Localisation; namespace osu.Game.Overlays.Profile.Sections { @@ -14,14 +15,14 @@ namespace osu.Game.Overlays.Profile.Sections { protected readonly Bindable User = new Bindable(); - private readonly string headerText; + private readonly LocalisableString headerText; private readonly CounterVisibilityState counterVisibilityState; private ProfileSubsectionHeader header; - protected ProfileSubsection(Bindable user, string headerText = "", CounterVisibilityState counterVisibilityState = CounterVisibilityState.AlwaysHidden) + protected ProfileSubsection(Bindable user, LocalisableString? headerText = null, CounterVisibilityState counterVisibilityState = CounterVisibilityState.AlwaysHidden) { - this.headerText = headerText; + this.headerText = headerText ?? string.Empty; this.counterVisibilityState = counterVisibilityState; User.BindTo(user); } @@ -37,7 +38,7 @@ namespace osu.Game.Overlays.Profile.Sections { header = new ProfileSubsectionHeader(headerText, counterVisibilityState) { - Alpha = string.IsNullOrEmpty(headerText) ? 0 : 1 + Alpha = string.IsNullOrEmpty(headerText.ToString()) ? 0 : 1 }, CreateContent() }; diff --git a/osu.Game/Overlays/Profile/Sections/ProfileSubsectionHeader.cs b/osu.Game/Overlays/Profile/Sections/ProfileSubsectionHeader.cs index 5858cebe89..408cb00770 100644 --- a/osu.Game/Overlays/Profile/Sections/ProfileSubsectionHeader.cs +++ b/osu.Game/Overlays/Profile/Sections/ProfileSubsectionHeader.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Shapes; using osuTK; using osu.Game.Graphics.Sprites; using osu.Game.Graphics; +using osu.Framework.Localisation; namespace osu.Game.Overlays.Profile.Sections { @@ -24,12 +25,12 @@ namespace osu.Game.Overlays.Profile.Sections set => current.Current = value; } - private readonly string text; + private readonly LocalisableString text; private readonly CounterVisibilityState counterState; private CounterPill counterPill; - public ProfileSubsectionHeader(string text, CounterVisibilityState counterState) + public ProfileSubsectionHeader(LocalisableString text, CounterVisibilityState counterState) { this.text = text; this.counterState = counterState; diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs index 3afa79e59e..4e4a665a60 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs @@ -3,8 +3,10 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Resources.Localisation.Web; using osu.Game.Scoring; using osuTK; @@ -51,7 +53,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks new OsuSpriteText { Font = OsuFont.GetFont(size: 12), - Text = $@"weighted {weight:0%}" + Text = UsersStrings.ShowExtraTopRanksPpWeight(weight.ToLocalisableString("0%")) } } }; diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs b/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs index 720cd4a3db..7c04b331c2 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs @@ -11,6 +11,7 @@ using osu.Game.Online.API.Requests.Responses; using System.Collections.Generic; using osu.Game.Online.API; using osu.Framework.Allocation; +using osu.Framework.Localisation; namespace osu.Game.Overlays.Profile.Sections.Ranks { @@ -18,7 +19,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks { private readonly ScoreType type; - public PaginatedScoreContainer(ScoreType type, Bindable user, string headerText) + public PaginatedScoreContainer(ScoreType type, Bindable user, LocalisableString headerText) : base(user, headerText) { this.type = type; diff --git a/osu.Game/Overlays/Profile/Sections/RanksSection.cs b/osu.Game/Overlays/Profile/Sections/RanksSection.cs index 33f7c2f71a..00a68d5bf9 100644 --- a/osu.Game/Overlays/Profile/Sections/RanksSection.cs +++ b/osu.Game/Overlays/Profile/Sections/RanksSection.cs @@ -3,21 +3,23 @@ using osu.Game.Overlays.Profile.Sections.Ranks; using osu.Game.Online.API.Requests; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections { public class RanksSection : ProfileSection { - public override string Title => "Ranks"; + public override LocalisableString Title => UsersStrings.ShowExtraTopRanksTitle; - public override string Identifier => "top_ranks"; + public override string Identifier => @"top_ranks"; public RanksSection() { Children = new[] { - new PaginatedScoreContainer(ScoreType.Best, User, "Best Performance"), - new PaginatedScoreContainer(ScoreType.Firsts, User, "First Place Ranks") + new PaginatedScoreContainer(ScoreType.Best, User, UsersStrings.ShowExtraTopRanksBestTitle), + new PaginatedScoreContainer(ScoreType.Firsts, User, UsersStrings.ShowExtraTopRanksFirstTitle) }; } } diff --git a/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs b/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs index d7101a8147..db2e6bc1e0 100644 --- a/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Recent/PaginatedRecentActivityContainer.cs @@ -10,13 +10,14 @@ using osu.Game.Online.API; using System.Collections.Generic; using osuTK; using osu.Framework.Allocation; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections.Recent { public class PaginatedRecentActivityContainer : PaginatedProfileSubsection { public PaginatedRecentActivityContainer(Bindable user) - : base(user, missingText: "This user hasn't done anything notable recently!") + : base(user, missingText: EventsStrings.Empty) { ItemsPerPage = 10; } diff --git a/osu.Game/Overlays/Profile/Sections/RecentSection.cs b/osu.Game/Overlays/Profile/Sections/RecentSection.cs index 1e6cfcc9fd..33d435aa1b 100644 --- a/osu.Game/Overlays/Profile/Sections/RecentSection.cs +++ b/osu.Game/Overlays/Profile/Sections/RecentSection.cs @@ -1,15 +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.Localisation; using osu.Game.Overlays.Profile.Sections.Recent; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections { public class RecentSection : ProfileSection { - public override string Title => "Recent"; + public override LocalisableString Title => UsersStrings.ShowExtraRecentActivityTitle; - public override string Identifier => "recent_activity"; + public override string Identifier => @"recent_activity"; public RecentSection() { diff --git a/osu.Game/Overlays/Profile/UserGraph.cs b/osu.Game/Overlays/Profile/UserGraph.cs index cdfd722d68..b7a08b6c5e 100644 --- a/osu.Game/Overlays/Profile/UserGraph.cs +++ b/osu.Game/Overlays/Profile/UserGraph.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -211,7 +212,7 @@ namespace osu.Game.Overlays.Profile protected readonly OsuSpriteText Counter, BottomText; private readonly Box background; - protected UserGraphTooltip(string tooltipCounterName) + protected UserGraphTooltip(LocalisableString tooltipCounterName) { AutoSizeAxes = Axes.Both; Masking = true; diff --git a/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs b/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs index 92e22f5873..417b33ddf6 100644 --- a/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs +++ b/osu.Game/Overlays/Rankings/RankingsOverlayHeader.cs @@ -1,8 +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 osu.Framework.Graphics; using osu.Framework.Bindables; +using osu.Game.Localisation; +using osu.Game.Resources.Localisation.Web; +using osu.Framework.Graphics; using osu.Game.Rulesets; using osu.Game.Users; @@ -29,18 +31,10 @@ namespace osu.Game.Overlays.Rankings { public RankingsTitle() { - Title = "ranking"; - Description = "find out who's the best right now"; + Title = PageTitleStrings.MainRankingControllerDefault; + Description = NamedOverlayComponentStrings.RankingsDescription; IconTexture = "Icons/Hexacons/rankings"; } } } - - public enum RankingsScope - { - Performance, - Spotlights, - Score, - Country - } } diff --git a/osu.Game/Overlays/Rankings/RankingsScope.cs b/osu.Game/Overlays/Rankings/RankingsScope.cs new file mode 100644 index 0000000000..e660c2898a --- /dev/null +++ b/osu.Game/Overlays/Rankings/RankingsScope.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. + +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Overlays.Rankings +{ + public enum RankingsScope + { + [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.TypePerformance))] + Performance, + + [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.TypeCharts))] + Spotlights, + + [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.TypeScore))] + Score, + + [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.TypeCountry))] + Country + } +} diff --git a/osu.Game/Overlays/Rankings/RankingsSortTabControl.cs b/osu.Game/Overlays/Rankings/RankingsSortTabControl.cs index c0bbf46e30..f05795b2a2 100644 --- a/osu.Game/Overlays/Rankings/RankingsSortTabControl.cs +++ b/osu.Game/Overlays/Rankings/RankingsSortTabControl.cs @@ -1,19 +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.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; + namespace osu.Game.Overlays.Rankings { public class RankingsSortTabControl : OverlaySortTabControl { public RankingsSortTabControl() { - Title = "Show"; + Title = RankingsStrings.FilterTitle.ToUpper(); } } public enum RankingsSortCriteria { + [LocalisableDescription(typeof(SortStrings), nameof(SortStrings.All))] All, + + [LocalisableDescription(typeof(SortStrings), nameof(SortStrings.Friends))] Friends } } diff --git a/osu.Game/Overlays/Rankings/SpotlightSelector.cs b/osu.Game/Overlays/Rankings/SpotlightSelector.cs index 89dd4eafdd..5309778a47 100644 --- a/osu.Game/Overlays/Rankings/SpotlightSelector.cs +++ b/osu.Game/Overlays/Rankings/SpotlightSelector.cs @@ -16,6 +16,8 @@ using System.Collections.Generic; using osu.Framework.Graphics.UserInterface; using osu.Game.Online.API.Requests; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Rankings { @@ -92,10 +94,10 @@ namespace osu.Game.Overlays.Rankings Margin = new MarginPadding { Bottom = 5 }, Children = new Drawable[] { - startDateColumn = new InfoColumn(@"Start Date"), - endDateColumn = new InfoColumn(@"End Date"), - mapCountColumn = new InfoColumn(@"Map Count"), - participantsColumn = new InfoColumn(@"Participants") + startDateColumn = new InfoColumn(RankingsStrings.SpotlightStartDate), + endDateColumn = new InfoColumn(RankingsStrings.SpotlightEndDate), + mapCountColumn = new InfoColumn(RankingsStrings.SpotlightMapCount), + participantsColumn = new InfoColumn(RankingsStrings.SpotlightParticipants) } }, new RankingsSortTabControl @@ -122,22 +124,22 @@ namespace osu.Game.Overlays.Rankings { startDateColumn.Value = dateToString(response.Spotlight.StartDate); endDateColumn.Value = dateToString(response.Spotlight.EndDate); - mapCountColumn.Value = response.BeatmapSets.Count.ToString(); - participantsColumn.Value = response.Spotlight.Participants?.ToString("N0"); + mapCountColumn.Value = response.BeatmapSets.Count.ToLocalisableString(@"N0"); + participantsColumn.Value = response.Spotlight.Participants?.ToLocalisableString(@"N0"); } - private string dateToString(DateTimeOffset date) => date.ToString("yyyy-MM-dd"); + private LocalisableString dateToString(DateTimeOffset date) => date.ToLocalisableString(@"yyyy-MM-dd"); private class InfoColumn : FillFlowContainer { - public string Value + public LocalisableString Value { set => valueText.Text = value; } private readonly OsuSpriteText valueText; - public InfoColumn(string name) + public InfoColumn(LocalisableString name) { AutoSizeAxes = Axes.Both; Direction = FillDirection.Vertical; diff --git a/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs b/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs index c5e413c7fa..85a317728f 100644 --- a/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/CountriesTable.cs @@ -9,6 +9,8 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using System.Collections.Generic; using osu.Framework.Allocation; +using osu.Game.Resources.Localisation.Web; +using osu.Framework.Localisation; namespace osu.Game.Overlays.Rankings.Tables { @@ -19,14 +21,14 @@ namespace osu.Game.Overlays.Rankings.Tables { } - protected override TableColumn[] CreateAdditionalHeaders() => new[] + protected override RankingsTableColumn[] CreateAdditionalHeaders() => new[] { - new TableColumn("Active Users", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), - new TableColumn("Play Count", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), - new TableColumn("Ranked Score", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), - new TableColumn("Avg. Score", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), - new TableColumn("Performance", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), - new TableColumn("Avg. Perf.", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new RankingsTableColumn(RankingsStrings.StatActiveUsers, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new RankingsTableColumn(RankingsStrings.StatPlayCount, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new RankingsTableColumn(RankingsStrings.StatRankedScore, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new RankingsTableColumn(RankingsStrings.StatAverageScore, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new RankingsTableColumn(RankingsStrings.StatPerformance, Anchor.Centre, new Dimension(GridSizeMode.AutoSize), true), + new RankingsTableColumn(RankingsStrings.StatAveragePerformance, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), }; protected override Country GetCountry(CountryStatistics item) => item.Country; @@ -35,29 +37,29 @@ namespace osu.Game.Overlays.Rankings.Tables protected override Drawable[] CreateAdditionalContent(CountryStatistics item) => new Drawable[] { - new ColoredRowText + new ColouredRowText { - Text = $@"{item.ActiveUsers:N0}", + Text = item.ActiveUsers.ToLocalisableString(@"N0") }, - new ColoredRowText + new ColouredRowText { - Text = $@"{item.PlayCount:N0}", + Text = item.PlayCount.ToLocalisableString(@"N0") }, - new ColoredRowText + new ColouredRowText { - Text = $@"{item.RankedScore:N0}", + Text = item.RankedScore.ToLocalisableString(@"N0") }, - new ColoredRowText + new ColouredRowText { - Text = $@"{item.RankedScore / Math.Max(item.ActiveUsers, 1):N0}", + Text = (item.RankedScore / Math.Max(item.ActiveUsers, 1)).ToLocalisableString(@"N0") }, new RowText { - Text = $@"{item.Performance:N0}", + Text = item.Performance.ToLocalisableString(@"N0") }, - new ColoredRowText + new ColouredRowText { - Text = $@"{item.Performance / Math.Max(item.ActiveUsers, 1):N0}", + Text = (item.Performance / Math.Max(item.ActiveUsers, 1)).ToLocalisableString(@"N0") } }; diff --git a/osu.Game/Overlays/Rankings/Tables/PerformanceTable.cs b/osu.Game/Overlays/Rankings/Tables/PerformanceTable.cs index 1e6b2307e0..6facf1e7a2 100644 --- a/osu.Game/Overlays/Rankings/Tables/PerformanceTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/PerformanceTable.cs @@ -4,6 +4,8 @@ using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; namespace osu.Game.Overlays.Rankings.Tables @@ -15,14 +17,14 @@ namespace osu.Game.Overlays.Rankings.Tables { } - protected override TableColumn[] CreateUniqueHeaders() => new[] + protected override RankingsTableColumn[] CreateUniqueHeaders() => new[] { - new TableColumn("Performance", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new RankingsTableColumn(RankingsStrings.StatPerformance, Anchor.Centre, new Dimension(GridSizeMode.AutoSize), true), }; protected override Drawable[] CreateUniqueContent(UserStatistics item) => new Drawable[] { - new RowText { Text = $@"{item.PP:N0}", } + new RowText { Text = item.PP.ToLocalisableString(@"N0"), } }; } } diff --git a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs index 585b5c22aa..bc8eac16a9 100644 --- a/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/RankingsTable.cs @@ -13,6 +13,7 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Users; using osu.Game.Users.Drawables; using osuTK; +using osu.Framework.Localisation; namespace osu.Game.Overlays.Rankings.Tables { @@ -54,29 +55,24 @@ namespace osu.Game.Overlays.Rankings.Tables rankings.ForEach(_ => backgroundFlow.Add(new TableRowBackground { Height = row_height })); - Columns = mainHeaders.Concat(CreateAdditionalHeaders()).ToArray(); + Columns = mainHeaders.Concat(CreateAdditionalHeaders()).Cast().ToArray(); Content = rankings.Select((s, i) => createContent((page - 1) * items_per_page + i, s)).ToArray().ToRectangular(); } private Drawable[] createContent(int index, TModel item) => new Drawable[] { createIndexDrawable(index), createMainContent(item) }.Concat(CreateAdditionalContent(item)).ToArray(); - private static TableColumn[] mainHeaders => new[] + private static RankingsTableColumn[] mainHeaders => new[] { - new TableColumn(string.Empty, Anchor.Centre, new Dimension(GridSizeMode.Absolute, 40)), // place - new TableColumn(string.Empty, Anchor.CentreLeft, new Dimension()), // flag and username (country name) + new RankingsTableColumn(string.Empty, Anchor.Centre, new Dimension(GridSizeMode.Absolute, 40)), // place + new RankingsTableColumn(string.Empty, Anchor.CentreLeft, new Dimension()), // flag and username (country name) }; - protected abstract TableColumn[] CreateAdditionalHeaders(); + protected abstract RankingsTableColumn[] CreateAdditionalHeaders(); protected abstract Drawable[] CreateAdditionalContent(TModel item); - protected virtual string HighlightedColumn => @"Performance"; - - protected override Drawable CreateHeader(int index, TableColumn column) - { - var title = column?.Header ?? string.Empty; - return new HeaderText(title, title == HighlightedColumn); - } + protected sealed override Drawable CreateHeader(int index, TableColumn column) + => (column as RankingsTableColumn)?.CreateHeaderText() ?? new HeaderText(column?.Header ?? default, false); protected abstract Country GetCountry(TModel item); @@ -84,7 +80,7 @@ namespace osu.Game.Overlays.Rankings.Tables private OsuSpriteText createIndexDrawable(int index) => new RowText { - Text = $"#{index + 1}", + Text = (index + 1).ToLocalisableString(@"\##"), Font = OsuFont.GetFont(size: TEXT_SIZE, weight: FontWeight.SemiBold) }; @@ -105,11 +101,24 @@ namespace osu.Game.Overlays.Rankings.Tables } }; + protected class RankingsTableColumn : TableColumn + { + protected readonly bool Highlighted; + + public RankingsTableColumn(LocalisableString? header = null, Anchor anchor = Anchor.TopLeft, Dimension dimension = null, bool highlighted = false) + : base(header, anchor, dimension) + { + Highlighted = highlighted; + } + + public virtual HeaderText CreateHeaderText() => new HeaderText(Header, Highlighted); + } + protected class HeaderText : OsuSpriteText { private readonly bool isHighlighted; - public HeaderText(string text, bool isHighlighted) + public HeaderText(LocalisableString text, bool isHighlighted) { this.isHighlighted = isHighlighted; @@ -135,7 +144,7 @@ namespace osu.Game.Overlays.Rankings.Tables } } - protected class ColoredRowText : RowText + protected class ColouredRowText : RowText { [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) diff --git a/osu.Game/Overlays/Rankings/Tables/ScoresTable.cs b/osu.Game/Overlays/Rankings/Tables/ScoresTable.cs index 9fae8e1897..b6bb66e2c8 100644 --- a/osu.Game/Overlays/Rankings/Tables/ScoresTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/ScoresTable.cs @@ -4,6 +4,8 @@ using System.Collections.Generic; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; using osu.Game.Users; namespace osu.Game.Overlays.Rankings.Tables @@ -15,24 +17,22 @@ namespace osu.Game.Overlays.Rankings.Tables { } - protected override TableColumn[] CreateUniqueHeaders() => new[] + protected override RankingsTableColumn[] CreateUniqueHeaders() => new[] { - new TableColumn("Total Score", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), - new TableColumn("Ranked Score", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)) + new RankingsTableColumn(RankingsStrings.StatTotalScore, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new RankingsTableColumn(RankingsStrings.StatRankedScore, Anchor.Centre, new Dimension(GridSizeMode.AutoSize), true) }; protected override Drawable[] CreateUniqueContent(UserStatistics item) => new Drawable[] { - new ColoredRowText + new ColouredRowText { - Text = $@"{item.TotalScore:N0}", + Text = item.TotalScore.ToLocalisableString(@"N0"), }, new RowText { - Text = $@"{item.RankedScore:N0}", + Text = item.RankedScore.ToLocalisableString(@"N0") } }; - - protected override string HighlightedColumn => @"Ranked Score"; } } diff --git a/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs b/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs index a6969f483f..b96ab556df 100644 --- a/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs +++ b/osu.Game/Overlays/Rankings/Tables/UserBasedTable.cs @@ -9,6 +9,8 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Users; using osu.Game.Scoring; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Rankings.Tables { @@ -19,22 +21,16 @@ namespace osu.Game.Overlays.Rankings.Tables { } - protected virtual IEnumerable GradeColumns => new List { "SS", "S", "A" }; + protected virtual IEnumerable GradeColumns => new List { RankingsStrings.Statss, RankingsStrings.Stats, RankingsStrings.Stata }; - protected override TableColumn[] CreateAdditionalHeaders() => new[] + protected override RankingsTableColumn[] CreateAdditionalHeaders() => new[] { - new TableColumn("Accuracy", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), - new TableColumn("Play Count", Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new RankingsTableColumn(RankingsStrings.StatAccuracy, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), + new RankingsTableColumn(RankingsStrings.StatPlayCount, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)), }.Concat(CreateUniqueHeaders()) - .Concat(GradeColumns.Select(grade => new TableColumn(grade, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)))) + .Concat(GradeColumns.Select(grade => new GradeTableColumn(grade, Anchor.Centre, new Dimension(GridSizeMode.AutoSize)))) .ToArray(); - protected override Drawable CreateHeader(int index, TableColumn column) - { - var title = column?.Header ?? string.Empty; - return new UserTableHeaderText(title, HighlightedColumn == title, GradeColumns.Contains(title)); - } - protected sealed override Country GetCountry(UserStatistics item) => item.User.Country; protected sealed override Drawable CreateFlagContent(UserStatistics item) @@ -51,28 +47,38 @@ namespace osu.Game.Overlays.Rankings.Tables protected sealed override Drawable[] CreateAdditionalContent(UserStatistics item) => new[] { - new ColoredRowText { Text = item.DisplayAccuracy, }, - new ColoredRowText { Text = $@"{item.PlayCount:N0}", }, + new ColouredRowText { Text = item.DisplayAccuracy, }, + new ColouredRowText { Text = item.PlayCount.ToLocalisableString(@"N0") }, }.Concat(CreateUniqueContent(item)).Concat(new[] { - new ColoredRowText { Text = $@"{item.GradesCount[ScoreRank.XH] + item.GradesCount[ScoreRank.X]:N0}", }, - new ColoredRowText { Text = $@"{item.GradesCount[ScoreRank.SH] + item.GradesCount[ScoreRank.S]:N0}", }, - new ColoredRowText { Text = $@"{item.GradesCount[ScoreRank.A]:N0}", } + new ColouredRowText { Text = (item.GradesCount[ScoreRank.XH] + item.GradesCount[ScoreRank.X]).ToLocalisableString(@"N0"), }, + new ColouredRowText { Text = (item.GradesCount[ScoreRank.SH] + item.GradesCount[ScoreRank.S]).ToLocalisableString(@"N0"), }, + new ColouredRowText { Text = item.GradesCount[ScoreRank.A].ToLocalisableString(@"N0"), } }).ToArray(); - protected abstract TableColumn[] CreateUniqueHeaders(); + protected abstract RankingsTableColumn[] CreateUniqueHeaders(); protected abstract Drawable[] CreateUniqueContent(UserStatistics item); - private class UserTableHeaderText : HeaderText + private class GradeTableColumn : RankingsTableColumn { - public UserTableHeaderText(string text, bool isHighlighted, bool isGrade) + public GradeTableColumn(LocalisableString? header = null, Anchor anchor = Anchor.TopLeft, Dimension dimension = null, bool highlighted = false) + : base(header, anchor, dimension, highlighted) + { + } + + public override HeaderText CreateHeaderText() => new GradeHeaderText(Header, Highlighted); + } + + private class GradeHeaderText : HeaderText + { + public GradeHeaderText(LocalisableString text, bool isHighlighted) : base(text, isHighlighted) { Margin = new MarginPadding { // Grade columns have extra horizontal padding for readibility - Horizontal = isGrade ? 20 : 10, + Horizontal = 20, Vertical = 5 }; } diff --git a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs index b31e7dc45b..d64f176468 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs @@ -13,7 +13,7 @@ namespace osu.Game.Overlays.Settings.Sections.Audio { public class AudioDevicesSettings : SettingsSubsection { - protected override string Header => "Devices"; + protected override LocalisableString Header => "Devices"; [Resolved] private AudioManager audio { get; set; } diff --git a/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs index 1ae297f2a9..7f2e377c83 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs @@ -11,7 +11,7 @@ namespace osu.Game.Overlays.Settings.Sections.Audio { public class OffsetSettings : SettingsSubsection { - protected override string Header => "Offset Adjustment"; + protected override LocalisableString Header => "Offset Adjustment"; [BackgroundDependencyLoader] private void load(OsuConfigManager config) diff --git a/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs index c172a76ab9..8f88b03471 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/VolumeSettings.cs @@ -4,13 +4,14 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; namespace osu.Game.Overlays.Settings.Sections.Audio { public class VolumeSettings : SettingsSubsection { - protected override string Header => "Volume"; + protected override LocalisableString Header => "Volume"; [BackgroundDependencyLoader] private void load(AudioManager audio, OsuConfigManager config) diff --git a/osu.Game/Overlays/Settings/Sections/Debug/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Debug/GeneralSettings.cs index 4a9c9bd8a2..2b868cab85 100644 --- a/osu.Game/Overlays/Settings/Sections/Debug/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Debug/GeneralSettings.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Configuration; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Framework.Screens; using osu.Game.Screens.Import; @@ -11,7 +12,7 @@ namespace osu.Game.Overlays.Settings.Sections.Debug { public class GeneralSettings : SettingsSubsection { - protected override string Header => "General"; + protected override LocalisableString Header => "General"; [BackgroundDependencyLoader(true)] private void load(FrameworkDebugConfigManager config, FrameworkConfigManager frameworkConfig, OsuGame game) diff --git a/osu.Game/Overlays/Settings/Sections/Debug/MemorySettings.cs b/osu.Game/Overlays/Settings/Sections/Debug/MemorySettings.cs index db64c9a8ac..bf7fb351c0 100644 --- a/osu.Game/Overlays/Settings/Sections/Debug/MemorySettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Debug/MemorySettings.cs @@ -4,13 +4,14 @@ using osu.Framework.Allocation; using osu.Framework.Configuration; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Framework.Platform; namespace osu.Game.Overlays.Settings.Sections.Debug { public class MemorySettings : SettingsSubsection { - protected override string Header => "Memory"; + protected override LocalisableString Header => "Memory"; [BackgroundDependencyLoader] private void load(FrameworkDebugConfigManager config, GameHost host) diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs index 0b5ec4f338..353292606f 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs @@ -4,6 +4,7 @@ using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Rulesets.Scoring; @@ -11,7 +12,7 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay { public class GeneralSettings : SettingsSubsection { - protected override string Header => "General"; + protected override LocalisableString Header => "General"; [BackgroundDependencyLoader] private void load(OsuConfigManager config) diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/ModsSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/ModsSettings.cs index 2b2fb9cef7..ec9ddde2da 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/ModsSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/ModsSettings.cs @@ -4,13 +4,14 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Localisation; using osu.Game.Configuration; namespace osu.Game.Overlays.Settings.Sections.Gameplay { public class ModsSettings : SettingsSubsection { - protected override string Header => "Mods"; + protected override LocalisableString Header => "Mods"; public override IEnumerable FilterTerms => base.FilterTerms.Concat(new[] { "mod" }); diff --git a/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs b/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs index dfcdb8e340..c6c752e2fd 100644 --- a/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Extensions; using osu.Game.Localisation; @@ -15,7 +16,7 @@ namespace osu.Game.Overlays.Settings.Sections.General private SettingsDropdown languageSelection; private Bindable frameworkLocale; - protected override string Header => "Language"; + protected override LocalisableString Header => "Language"; [BackgroundDependencyLoader] private void load(FrameworkConfigManager frameworkConfig) diff --git a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs index c213313559..dd20e1d7ef 100644 --- a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Framework.Screens; using osu.Game.Configuration; @@ -19,7 +20,7 @@ namespace osu.Game.Overlays.Settings.Sections.General [Resolved(CanBeNull = true)] private UpdateManager updateManager { get; set; } - protected override string Header => "Updates"; + protected override LocalisableString Header => "Updates"; private SettingsButton checkForUpdatesButton; diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs index 30caa45995..f889cfca0f 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs @@ -3,13 +3,14 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; namespace osu.Game.Overlays.Settings.Sections.Graphics { public class DetailSettings : SettingsSubsection { - protected override string Header => "Detail Settings"; + protected override LocalisableString Header => "Detail Settings"; [BackgroundDependencyLoader] private void load(OsuConfigManager config) diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs index 669753d2cb..91208cb78a 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs @@ -22,7 +22,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics { public class LayoutSettings : SettingsSubsection { - protected override string Header => "Layout"; + protected override LocalisableString Header => "Layout"; private FillFlowContainer> scalingSettings; diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs index 70225ff6b8..2210c7911e 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/RendererSettings.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Configuration; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Game.Configuration; @@ -11,7 +12,7 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics { public class RendererSettings : SettingsSubsection { - protected override string Header => "Renderer"; + protected override LocalisableString Header => "Renderer"; private SettingsEnumDropdown frameLimiterDropdown; diff --git a/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs index 79c73863cf..3227decc46 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/BindingSettings.cs @@ -2,12 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Input { public class BindingSettings : SettingsSubsection { - protected override string Header => "Shortcut and gameplay bindings"; + protected override LocalisableString Header => BindingSettingsStrings.ShortcutAndGameplayBindings; public BindingSettings(KeyBindingPanel keyConfig) { @@ -15,8 +17,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input { new SettingsButton { - Text = "Configure", - TooltipText = "change global shortcut keys and gameplay bindings", + Text = BindingSettingsStrings.Configure, + TooltipText = BindingSettingsStrings.ChangeBindingsButton, Action = keyConfig.ToggleVisibility }, }; diff --git a/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs b/osu.Game/Overlays/Settings/Sections/Input/GlobalKeyBindingsSection.cs similarity index 84% rename from osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs rename to osu.Game/Overlays/Settings/Sections/Input/GlobalKeyBindingsSection.cs index c905397e77..9898a50320 100644 --- a/osu.Game/Overlays/KeyBinding/GlobalKeyBindingsSection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/GlobalKeyBindingsSection.cs @@ -3,10 +3,10 @@ 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 { @@ -28,7 +28,7 @@ namespace osu.Game.Overlays.KeyBinding private class DefaultBindingsSubsection : KeyBindingsSubsection { - protected override string Header => string.Empty; + protected override LocalisableString Header => string.Empty; public DefaultBindingsSubsection(GlobalActionContainer manager) : base(null) @@ -39,7 +39,7 @@ namespace osu.Game.Overlays.KeyBinding private class SongSelectKeyBindingSubsection : KeyBindingsSubsection { - protected override string Header => "Song Select"; + protected override LocalisableString Header => "Song Select"; public SongSelectKeyBindingSubsection(GlobalActionContainer manager) : base(null) @@ -50,7 +50,7 @@ namespace osu.Game.Overlays.KeyBinding private class InGameKeyBindingsSubsection : KeyBindingsSubsection { - protected override string Header => "In Game"; + protected override LocalisableString Header => "In Game"; public InGameKeyBindingsSubsection(GlobalActionContainer manager) : base(null) @@ -61,7 +61,7 @@ namespace osu.Game.Overlays.KeyBinding private class AudioControlKeyBindingsSubsection : KeyBindingsSubsection { - protected override string Header => "Audio"; + protected override LocalisableString Header => "Audio"; public AudioControlKeyBindingsSubsection(GlobalActionContainer manager) : base(null) @@ -72,7 +72,7 @@ namespace osu.Game.Overlays.KeyBinding private class EditorKeyBindingsSubsection : KeyBindingsSubsection { - protected override string Header => "Editor"; + protected override LocalisableString Header => "Editor"; public EditorKeyBindingsSubsection(GlobalActionContainer manager) : base(null) 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..6e018597be 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 { @@ -138,7 +138,8 @@ namespace osu.Game.Overlays.KeyBinding }, } } - } + }, + new HoverClickSounds() }; foreach (var b in bindings) @@ -458,6 +459,7 @@ namespace osu.Game.Overlays.KeyBinding Origin = Anchor.Centre, Text = keyBinding.KeyCombination.ReadableString(), }, + new HoverSounds() }; } 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/Settings/Sections/Input/MouseSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs index e87572e2ca..a4da17c5cd 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.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 osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Configuration; @@ -10,6 +11,7 @@ using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Input; +using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Input { @@ -17,7 +19,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input { private readonly MouseHandler mouseHandler; - protected override string Header => "Mouse"; + protected override LocalisableString Header => MouseSettingsStrings.Mouse; private Bindable handlerSensitivity; @@ -27,6 +29,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input private SettingsEnumDropdown confineMouseModeSetting; private Bindable relativeMode; + private SettingsCheckbox highPrecisionMouse; + public MouseSettings(MouseHandler mouseHandler) { this.mouseHandler = mouseHandler; @@ -44,29 +48,31 @@ namespace osu.Game.Overlays.Settings.Sections.Input Children = new Drawable[] { - new SettingsCheckbox + highPrecisionMouse = new SettingsCheckbox { - LabelText = "High precision mouse", - Current = relativeMode + LabelText = MouseSettingsStrings.HighPrecisionMouse, + TooltipText = MouseSettingsStrings.HighPrecisionMouseTooltip, + Current = relativeMode, + Keywords = new[] { @"raw", @"input", @"relative", @"cursor" } }, new SensitivitySetting { - LabelText = "Cursor sensitivity", + LabelText = MouseSettingsStrings.CursorSensitivity, Current = localSensitivity }, confineMouseModeSetting = new SettingsEnumDropdown { - LabelText = "Confine mouse cursor to window", + LabelText = MouseSettingsStrings.ConfineMouseMode, Current = osuConfig.GetBindable(OsuSetting.ConfineMouseMode) }, new SettingsCheckbox { - LabelText = "Disable mouse wheel during gameplay", + LabelText = MouseSettingsStrings.DisableMouseWheel, Current = osuConfig.GetBindable(OsuSetting.MouseDisableWheel) }, new SettingsCheckbox { - LabelText = "Disable mouse buttons during gameplay", + LabelText = MouseSettingsStrings.DisableMouseButtons, Current = osuConfig.GetBindable(OsuSetting.MouseDisableButtons) }, }; @@ -96,7 +102,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input if (isFullscreen) { confineMouseModeSetting.Current.Disabled = true; - confineMouseModeSetting.TooltipText = "Not applicable in full screen mode"; + confineMouseModeSetting.TooltipText = MouseSettingsStrings.NotApplicableFullscreen; } else { @@ -104,6 +110,17 @@ namespace osu.Game.Overlays.Settings.Sections.Input confineMouseModeSetting.TooltipText = string.Empty; } }, true); + + highPrecisionMouse.Current.BindValueChanged(highPrecision => + { + if (RuntimeInfo.OS != RuntimeInfo.Platform.Windows) + { + if (highPrecision.NewValue) + highPrecisionMouse.WarningText = MouseSettingsStrings.HighPrecisionPlatformWarning; + else + highPrecisionMouse.WarningText = null; + } + }, true); } private class SensitivitySetting : SettingsSlider @@ -117,7 +134,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input private class SensitivitySlider : OsuSliderBar { - public override LocalisableString TooltipText => Current.Disabled ? "enable high precision mouse to adjust sensitivity" : $"{base.TooltipText}x"; + public override LocalisableString TooltipText => Current.Disabled ? MouseSettingsStrings.EnableHighPrecisionForSensitivityAdjust : $"{base.TooltipText}x"; } } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs b/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs index 3e8da9f7d0..26610628d5 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs @@ -36,7 +36,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input RelativeSizeAxes = Axes.X, Height = height, Width = 0.25f, - Text = $"{presetRotation}º", + Text = $@"{presetRotation}º", Action = () => tabletHandler.Rotation.Value = presetRotation, }); } 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/Settings/Sections/Input/TabletSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs index d770c18878..c7342c251d 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs @@ -6,10 +6,12 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Handlers.Tablet; +using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Framework.Threading; using osu.Game.Graphics.Sprites; using osuTK; +using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Input { @@ -52,7 +54,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input private OsuSpriteText noTabletMessage; - protected override string Header => "Tablet"; + protected override LocalisableString Header => TabletSettingsStrings.Tablet; public TabletSettings(ITabletHandler tabletHandler) { @@ -66,14 +68,14 @@ namespace osu.Game.Overlays.Settings.Sections.Input { new SettingsCheckbox { - LabelText = "Enabled", + LabelText = CommonStrings.Enabled, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Current = tabletHandler.Enabled }, noTabletMessage = new OsuSpriteText { - Text = "No tablet detected!", + Text = TabletSettingsStrings.NoTabletDetected, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS } @@ -94,7 +96,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input }, new DangerousSettingsButton { - Text = "Reset to full area", + Text = TabletSettingsStrings.ResetToFullArea, Action = () => { aspectLock.Value = false; @@ -105,7 +107,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input }, new SettingsButton { - Text = "Conform to current game aspect ratio", + Text = TabletSettingsStrings.ConformToCurrentGameAspectRatio, Action = () => { forceAspectRatio((float)host.Window.ClientSize.Width / host.Window.ClientSize.Height); @@ -114,43 +116,43 @@ namespace osu.Game.Overlays.Settings.Sections.Input new SettingsSlider { TransferValueOnCommit = true, - LabelText = "X Offset", + LabelText = TabletSettingsStrings.XOffset, Current = offsetX }, new SettingsSlider { TransferValueOnCommit = true, - LabelText = "Y Offset", + LabelText = TabletSettingsStrings.YOffset, Current = offsetY }, new SettingsSlider { TransferValueOnCommit = true, - LabelText = "Rotation", + LabelText = TabletSettingsStrings.Rotation, Current = rotation }, new RotationPresetButtons(tabletHandler), new SettingsSlider { TransferValueOnCommit = true, - LabelText = "Aspect Ratio", + LabelText = TabletSettingsStrings.AspectRatio, Current = aspectRatio }, new SettingsCheckbox { - LabelText = "Lock aspect ratio", + LabelText = TabletSettingsStrings.LockAspectRatio, Current = aspectLock }, new SettingsSlider { TransferValueOnCommit = true, - LabelText = "Width", + LabelText = CommonStrings.Width, Current = sizeX }, new SettingsSlider { TransferValueOnCommit = true, - LabelText = "Height", + LabelText = CommonStrings.Height, Current = sizeY }, } diff --git a/osu.Game/Overlays/KeyBinding/VariantBindingsSubsection.cs b/osu.Game/Overlays/Settings/Sections/Input/VariantBindingsSubsection.cs similarity index 80% rename from osu.Game/Overlays/KeyBinding/VariantBindingsSubsection.cs rename to osu.Game/Overlays/Settings/Sections/Input/VariantBindingsSubsection.cs index 861d59c8f4..a0f069b3bb 100644 --- a/osu.Game/Overlays/KeyBinding/VariantBindingsSubsection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/VariantBindingsSubsection.cs @@ -1,13 +1,14 @@ // 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.Localisation; using osu.Game.Rulesets; -namespace osu.Game.Overlays.KeyBinding +namespace osu.Game.Overlays.Settings.Sections.Input { public class VariantBindingsSubsection : KeyBindingsSubsection { - protected override string Header { get; } + protected override LocalisableString Header { get; } public VariantBindingsSubsection(RulesetInfo ruleset, int variant) : base(variant) diff --git a/osu.Game/Overlays/Settings/Sections/InputSection.cs b/osu.Game/Overlays/Settings/Sections/InputSection.cs index 6e99891794..366f39388a 100644 --- a/osu.Game/Overlays/Settings/Sections/InputSection.cs +++ b/osu.Game/Overlays/Settings/Sections/InputSection.cs @@ -9,6 +9,7 @@ using osu.Framework.Input.Handlers.Joystick; using osu.Framework.Input.Handlers.Midi; using osu.Framework.Input.Handlers.Mouse; using osu.Framework.Input.Handlers.Tablet; +using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Game.Overlays.Settings.Sections.Input; @@ -100,7 +101,7 @@ namespace osu.Game.Overlays.Settings.Sections }; } - protected override string Header => handler.Description; + protected override LocalisableString Header => handler.Description; } } } diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs index a38ca81e23..b9a408b1f8 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Collections; using osu.Game.Database; @@ -17,7 +18,7 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { public class GeneralSettings : SettingsSubsection { - protected override string Header => "General"; + protected override LocalisableString Header => "General"; private TriangleButton importBeatmapsButton; private TriangleButton importScoresButton; diff --git a/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs b/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs index b0f6400d4f..3a2de2ee36 100644 --- a/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Online/AlertsAndPrivacySettings.cs @@ -3,13 +3,14 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; namespace osu.Game.Overlays.Settings.Sections.Online { public class AlertsAndPrivacySettings : SettingsSubsection { - protected override string Header => "Alerts and Privacy"; + protected override LocalisableString Header => "Alerts and Privacy"; [BackgroundDependencyLoader] private void load(OsuConfigManager config) diff --git a/osu.Game/Overlays/Settings/Sections/Online/IntegrationSettings.cs b/osu.Game/Overlays/Settings/Sections/Online/IntegrationSettings.cs index d2867962c0..f2012f0d9c 100644 --- a/osu.Game/Overlays/Settings/Sections/Online/IntegrationSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Online/IntegrationSettings.cs @@ -3,13 +3,14 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; namespace osu.Game.Overlays.Settings.Sections.Online { public class IntegrationSettings : SettingsSubsection { - protected override string Header => "Integrations"; + protected override LocalisableString Header => "Integrations"; [BackgroundDependencyLoader] private void load(OsuConfigManager config) diff --git a/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs b/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs index 59bcbe4d89..89e7b096f3 100644 --- a/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Online/WebSettings.cs @@ -3,13 +3,14 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; namespace osu.Game.Overlays.Settings.Sections.Online { public class WebSettings : SettingsSubsection { - protected override string Header => "Web"; + protected override LocalisableString Header => "Web"; [BackgroundDependencyLoader] private void load(OsuConfigManager config) diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index 316837d27d..9f3543d059 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -13,6 +14,7 @@ using osu.Framework.Logging; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Skinning; +using osu.Game.Skinning.Editor; using osuTK; namespace osu.Game.Overlays.Settings.Sections @@ -57,14 +59,19 @@ namespace osu.Game.Overlays.Settings.Sections private IBindable> managerUpdated; private IBindable> managerRemoved; - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) + [BackgroundDependencyLoader(permitNulls: true)] + private void load(OsuConfigManager config, [CanBeNull] SkinEditorOverlay skinEditor) { FlowContent.Spacing = new Vector2(0, 5); Children = new Drawable[] { skinDropdown = new SkinSettingsDropdown(), + new SettingsButton + { + Text = "Skin layout editor", + Action = () => skinEditor?.Toggle(), + }, new ExportSkinButton(), new SettingsSlider { diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs index a6eb008623..4b26645ef3 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/GeneralSettings.cs @@ -11,7 +11,7 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface { public class GeneralSettings : SettingsSubsection { - protected override string Header => "General"; + protected override LocalisableString Header => "General"; [BackgroundDependencyLoader] private void load(OsuConfigManager config) diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs index 5f703ed5a4..81bbcbb54a 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/MainMenuSettings.cs @@ -4,6 +4,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Online.API; using osu.Game.Users; @@ -12,7 +13,7 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface { public class MainMenuSettings : SettingsSubsection { - protected override string Header => "Main Menu"; + protected override LocalisableString Header => "Main Menu"; private IBindable user; diff --git a/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs b/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs index 2470c0a6c5..587155eb0d 100644 --- a/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/UserInterface/SongSelectSettings.cs @@ -16,7 +16,7 @@ namespace osu.Game.Overlays.Settings.Sections.UserInterface private Bindable minStars; private Bindable maxStars; - protected override string Header => "Song Select"; + protected override LocalisableString Header => "Song Select"; [BackgroundDependencyLoader] private void load(OsuConfigManager config) diff --git a/osu.Game/Overlays/Settings/SettingsFooter.cs b/osu.Game/Overlays/Settings/SettingsFooter.cs index a815480094..ed49ce2b63 100644 --- a/osu.Game/Overlays/Settings/SettingsFooter.cs +++ b/osu.Game/Overlays/Settings/SettingsFooter.cs @@ -24,7 +24,7 @@ namespace osu.Game.Overlays.Settings RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; Direction = FillDirection.Vertical; - Padding = new MarginPadding { Top = 20, Bottom = 30 }; + Padding = new MarginPadding { Top = 20, Bottom = 30, Horizontal = SettingsPanel.CONTENT_MARGINS }; var modes = new List(); @@ -32,6 +32,8 @@ namespace osu.Game.Overlays.Settings { var icon = new ConstrainedIconContainer { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, Icon = ruleset.CreateInstance().CreateIcon(), Colour = Color4.Gray, Size = new Vector2(20), @@ -47,7 +49,8 @@ namespace osu.Game.Overlays.Settings Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Direction = FillDirection.Full, - AutoSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, Children = modes, Spacing = new Vector2(5), Padding = new MarginPadding { Bottom = 10 }, diff --git a/osu.Game/Overlays/Settings/SettingsItem.cs b/osu.Game/Overlays/Settings/SettingsItem.cs index c60ad020f0..bd17c02af9 100644 --- a/osu.Game/Overlays/Settings/SettingsItem.cs +++ b/osu.Game/Overlays/Settings/SettingsItem.cs @@ -61,12 +61,17 @@ namespace osu.Game.Overlays.Settings /// Text to be displayed at the bottom of this . /// Generally used to recommend the user change their setting as the current one is considered sub-optimal. /// - public string WarningText + public LocalisableString? WarningText { set { + bool hasValue = string.IsNullOrWhiteSpace(value.ToString()); + if (warningText == null) { + if (!hasValue) + return; + // construct lazily for cases where the label is not needed (may be provided by the Control). FlowContent.Add(warningText = new OsuTextFlowContainer { @@ -77,8 +82,8 @@ namespace osu.Game.Overlays.Settings }); } - warningText.Alpha = string.IsNullOrWhiteSpace(value) ? 0 : 1; - warningText.Text = value; + warningText.Alpha = hasValue ? 0 : 1; + warningText.Text = value.ToString(); // TODO: Remove ToString() call after TextFlowContainer supports localisation (see https://github.com/ppy/osu-framework/issues/4636). } } diff --git a/osu.Game/Overlays/Settings/SettingsSubsection.cs b/osu.Game/Overlays/Settings/SettingsSubsection.cs index 6abf6283b9..df32424b67 100644 --- a/osu.Game/Overlays/Settings/SettingsSubsection.cs +++ b/osu.Game/Overlays/Settings/SettingsSubsection.cs @@ -8,6 +8,7 @@ using osu.Game.Graphics.Sprites; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Localisation; using osu.Framework.Testing; using osu.Game.Graphics; @@ -20,10 +21,10 @@ namespace osu.Game.Overlays.Settings protected readonly FillFlowContainer FlowContent; - protected abstract string Header { get; } + protected abstract LocalisableString Header { get; } public IEnumerable FilterableChildren => Children.OfType(); - public virtual IEnumerable FilterTerms => new[] { Header }; + public virtual IEnumerable FilterTerms => new[] { Header.ToString() }; public bool MatchingFilter { @@ -54,7 +55,7 @@ namespace osu.Game.Overlays.Settings { new OsuSpriteText { - Text = Header.ToUpperInvariant(), + Text = Header.ToString().ToUpper(), // TODO: Add localisation support after https://github.com/ppy/osu-framework/pull/4603 is merged. Margin = new MarginPadding { Vertical = 30, Left = SettingsPanel.CONTENT_MARGINS, Right = SettingsPanel.CONTENT_MARGINS }, Font = OsuFont.GetFont(weight: FontWeight.Bold), }, 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/Overlays/Wiki/Markdown/WikiMarkdownImageBlock.cs b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImageBlock.cs index 1a4f6087c7..501e00bc00 100644 --- a/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImageBlock.cs +++ b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownImageBlock.cs @@ -14,7 +14,7 @@ namespace osu.Game.Overlays.Wiki.Markdown public class WikiMarkdownImageBlock : FillFlowContainer { [Resolved] - private IMarkdownTextComponent parentTextComponent { get; set; } + private IMarkdownTextFlowComponent parentFlowComponent { get; set; } private readonly LinkInline linkInline; @@ -31,16 +31,20 @@ namespace osu.Game.Overlays.Wiki.Markdown [BackgroundDependencyLoader] private void load() { + MarkdownTextFlowContainer textFlow; + Children = new Drawable[] { new BlockMarkdownImage(linkInline), - parentTextComponent.CreateSpriteText().With(t => + textFlow = parentFlowComponent.CreateTextFlow().With(t => { - t.Text = linkInline.Title; t.Anchor = Anchor.TopCentre; t.Origin = Anchor.TopCentre; + t.TextAnchor = Anchor.TopCentre; }), }; + + textFlow.AddText(linkInline.Title); } private class BlockMarkdownImage : WikiMarkdownImage diff --git a/osu.Game/Overlays/Wiki/WikiHeader.cs b/osu.Game/Overlays/Wiki/WikiHeader.cs index 6b8cba48b4..3e81d2cffe 100644 --- a/osu.Game/Overlays/Wiki/WikiHeader.cs +++ b/osu.Game/Overlays/Wiki/WikiHeader.cs @@ -5,15 +5,19 @@ using System; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Localisation; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Wiki { public class WikiHeader : BreadcrumbControlOverlayHeader { - private const string index_page_string = "index"; private const string index_path = "Main_Page"; + public static LocalisableString IndexPageString => LayoutStrings.HeaderHelpIndex; + public readonly Bindable WikiPageData = new Bindable(); public Action ShowIndexPage; @@ -21,8 +25,8 @@ namespace osu.Game.Overlays.Wiki public WikiHeader() { - TabControl.AddItem(index_page_string); - Current.Value = index_page_string; + TabControl.AddItem(IndexPageString); + Current.Value = IndexPageString; WikiPageData.BindValueChanged(onWikiPageChange); Current.BindValueChanged(onCurrentChange); @@ -36,11 +40,11 @@ namespace osu.Game.Overlays.Wiki TabControl.Clear(); Current.Value = null; - TabControl.AddItem(index_page_string); + TabControl.AddItem(IndexPageString); if (e.NewValue.Path == index_path) { - Current.Value = index_page_string; + Current.Value = IndexPageString; return; } @@ -51,12 +55,12 @@ namespace osu.Game.Overlays.Wiki Current.Value = e.NewValue.Title; } - private void onCurrentChange(ValueChangedEvent e) + private void onCurrentChange(ValueChangedEvent e) { if (e.NewValue == TabControl.Items.LastOrDefault()) return; - if (e.NewValue == index_page_string) + if (e.NewValue == IndexPageString) { ShowIndexPage?.Invoke(); return; @@ -73,8 +77,8 @@ namespace osu.Game.Overlays.Wiki { public WikiHeaderTitle() { - Title = "wiki"; - Description = "knowledge base"; + Title = PageTitleStrings.MainWikiControllerDefault; + Description = NamedOverlayComponentStrings.WikiDescription; IconTexture = "Icons/Hexacons/wiki"; } } diff --git a/osu.Game/Replays/Replay.cs b/osu.Game/Replays/Replay.cs index 5430915394..30e176b5c7 100644 --- a/osu.Game/Replays/Replay.cs +++ b/osu.Game/Replays/Replay.cs @@ -2,11 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Linq; using osu.Game.Rulesets.Replays; +using osu.Game.Utils; namespace osu.Game.Replays { - public class Replay + public class Replay : IDeepCloneable { /// /// Whether all frames for this replay have been received. @@ -15,5 +17,15 @@ namespace osu.Game.Replays public bool HasReceivedAllFrames = true; public List Frames = new List(); + + public Replay DeepClone() + { + return new Replay + { + HasReceivedAllFrames = HasReceivedAllFrames, + // individual frames are mutable for now but hopefully this will not be a thing in the future. + Frames = Frames.ToList(), + }; + } } } diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index 3cc69bd85b..224c9178ae 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Difficulty /// A structure describing the difficulty of the beatmap. public DifficultyAttributes Calculate(params Mod[] mods) { - mods = mods.Select(m => m.CreateCopy()).ToArray(); + mods = mods.Select(m => m.DeepClone()).ToArray(); IBeatmap playableBeatmap = beatmap.GetPlayableBeatmap(ruleset.RulesetInfo, mods); 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/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index a7005954b2..8090fcbd32 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Collections.Specialized; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input; @@ -63,10 +64,12 @@ namespace osu.Game.Rulesets.Edit private InputManager inputManager; - private RadioButtonCollection toolboxCollection; + private EditorRadioButtonCollection toolboxCollection; private FillFlowContainer togglesCollection; + private IBindable hasTiming; + protected HitObjectComposer(Ruleset ruleset) { Ruleset = ruleset; @@ -126,7 +129,7 @@ namespace osu.Game.Rulesets.Edit { new ToolboxGroup("toolbox (1-9)") { - Child = toolboxCollection = new RadioButtonCollection { RelativeSizeAxes = Axes.X } + Child = toolboxCollection = new EditorRadioButtonCollection { RelativeSizeAxes = Axes.X } }, new ToolboxGroup("toggles (Q~P)") { @@ -160,6 +163,14 @@ namespace osu.Game.Rulesets.Edit base.LoadComplete(); inputManager = GetContainingInputManager(); + + hasTiming = EditorBeatmap.HasTiming.GetBoundCopy(); + hasTiming.BindValueChanged(timing => + { + // it's important this is performed before the similar code in EditorRadioButton disables the button. + if (!timing.NewValue) + setSelectTool(); + }); } public override Playfield Playfield => drawableRulesetWrapper.Playfield; @@ -219,7 +230,8 @@ namespace osu.Game.Rulesets.Edit if (item != null) { - item.Select(); + if (!item.Selected.Disabled) + item.Select(); return true; } } diff --git a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs b/osu.Game/Rulesets/Judgements/DrawableJudgement.cs index 0f22d35bb5..d25d46c6e2 100644 --- a/osu.Game/Rulesets/Judgements/DrawableJudgement.cs +++ b/osu.Game/Rulesets/Judgements/DrawableJudgement.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.Diagnostics; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -31,6 +32,9 @@ namespace osu.Game.Rulesets.Judgements private readonly Container aboveHitObjectsContent; + private readonly Lazy proxiedAboveHitObjectsContent; + public Drawable ProxiedAboveHitObjectsContent => proxiedAboveHitObjectsContent.Value; + /// /// Creates a drawable which visualises a . /// @@ -52,6 +56,8 @@ namespace osu.Game.Rulesets.Judgements Depth = float.MinValue, RelativeSizeAxes = Axes.Both }); + + proxiedAboveHitObjectsContent = new Lazy(() => aboveHitObjectsContent.CreateProxy()); } [BackgroundDependencyLoader] @@ -60,8 +66,6 @@ namespace osu.Game.Rulesets.Judgements prepareDrawables(); } - public Drawable GetProxyAboveHitObjectsContent() => aboveHitObjectsContent.CreateProxy(); - /// /// Apply top-level animations to the current judgement when successfully hit. /// If displaying components which require lifetime extensions, manually adjusting is required. diff --git a/osu.Game/Rulesets/Mods/IApplicableToHealthProcessor.cs b/osu.Game/Rulesets/Mods/IApplicableToHealthProcessor.cs index a181955653..2676060efa 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToHealthProcessor.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToHealthProcessor.cs @@ -8,7 +8,7 @@ namespace osu.Game.Rulesets.Mods public interface IApplicableToHealthProcessor : IApplicableMod { /// - /// Provide a to a mod. Called once on initialisation of a play instance. + /// Provides a loaded to a mod. Called once on initialisation of a play instance. /// void ApplyToHealthProcessor(HealthProcessor healthProcessor); } diff --git a/osu.Game/Rulesets/Mods/IApplicableToScoreProcessor.cs b/osu.Game/Rulesets/Mods/IApplicableToScoreProcessor.cs index cb00770868..b93e50921f 100644 --- a/osu.Game/Rulesets/Mods/IApplicableToScoreProcessor.cs +++ b/osu.Game/Rulesets/Mods/IApplicableToScoreProcessor.cs @@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Mods public interface IApplicableToScoreProcessor : IApplicableMod { /// - /// Provide a to a mod. Called once on initialisation of a play instance. + /// Provides a loaded to a mod. Called once on initialisation of a play instance. /// void ApplyToScoreProcessor(ScoreProcessor scoreProcessor); diff --git a/osu.Game/Rulesets/Mods/Metronome.cs b/osu.Game/Rulesets/Mods/Metronome.cs new file mode 100644 index 0000000000..8b6d86c45f --- /dev/null +++ b/osu.Game/Rulesets/Mods/Metronome.cs @@ -0,0 +1,91 @@ +// 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.Audio; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Game.Audio; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; +using osu.Game.Skinning; + +namespace osu.Game.Rulesets.Mods +{ + public class Metronome : BeatSyncedContainer, IAdjustableAudioComponent + { + private readonly double firstHitTime; + + private readonly PausableSkinnableSound sample; + + /// Start time of the first hit object, used for providing a count down. + public Metronome(double firstHitTime) + { + this.firstHitTime = firstHitTime; + AllowMistimedEventFiring = false; + Divisor = 1; + + InternalChild = sample = new PausableSkinnableSound(new SampleInfo("Gameplay/catch-banana")); + } + + protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes) + { + base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); + + if (!IsBeatSyncedWithTrack) return; + + int timeSignature = (int)timingPoint.TimeSignature; + + // play metronome from one measure before the first object. + if (BeatSyncClock.CurrentTime < firstHitTime - timingPoint.BeatLength * timeSignature) + return; + + sample.Frequency.Value = beatIndex % timeSignature == 0 ? 1 : 0.5f; + sample.Play(); + } + + #region IAdjustableAudioComponent + + public IBindable AggregateVolume => sample.AggregateVolume; + + public IBindable AggregateBalance => sample.AggregateBalance; + + public IBindable AggregateFrequency => sample.AggregateFrequency; + + public IBindable AggregateTempo => sample.AggregateTempo; + + public BindableNumber Volume => sample.Volume; + + public BindableNumber Balance => sample.Balance; + + public BindableNumber Frequency => sample.Frequency; + + public BindableNumber Tempo => sample.Tempo; + + public void BindAdjustments(IAggregateAudioAdjustment component) + { + sample.BindAdjustments(component); + } + + public void UnbindAdjustments(IAggregateAudioAdjustment component) + { + sample.UnbindAdjustments(component); + } + + public void AddAdjustment(AdjustableProperty type, IBindable adjustBindable) + { + sample.AddAdjustment(type, adjustBindable); + } + + public void RemoveAdjustment(AdjustableProperty type, IBindable adjustBindable) + { + sample.RemoveAdjustment(type, adjustBindable); + } + + public void RemoveAllAdjustments(AdjustableProperty type) + { + sample.RemoveAllAdjustments(type); + } + + #endregion + } +} diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index 6f00bb6c75..f2fd02c652 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.cs @@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Mods /// The base class for gameplay modifiers. /// [ExcludeFromDynamicCompile] - public abstract class Mod : IMod, IEquatable, IJsonSerializable + public abstract class Mod : IMod, IEquatable, IJsonSerializable, IDeepCloneable { /// /// The name of this mod. @@ -132,7 +132,7 @@ namespace osu.Game.Rulesets.Mods /// /// Creates a copy of this initialised to a default state. /// - public virtual Mod CreateCopy() + public virtual Mod DeepClone() { var result = (Mod)Activator.CreateInstance(GetType()); result.CopyFrom(this); diff --git a/osu.Game/Rulesets/Mods/ModMirror.cs b/osu.Game/Rulesets/Mods/ModMirror.cs new file mode 100644 index 0000000000..3c4b7d0c60 --- /dev/null +++ b/osu.Game/Rulesets/Mods/ModMirror.cs @@ -0,0 +1,13 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Rulesets.Mods +{ + public abstract class ModMirror : Mod + { + public override string Name => "Mirror"; + public override string Acronym => "MR"; + public override ModType Type => ModType.Conversion; + public override double ScoreMultiplier => 1; + } +} diff --git a/osu.Game/Rulesets/Mods/ModMuted.cs b/osu.Game/Rulesets/Mods/ModMuted.cs new file mode 100644 index 0000000000..7fde14d6ca --- /dev/null +++ b/osu.Game/Rulesets/Mods/ModMuted.cs @@ -0,0 +1,104 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Audio; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Configuration; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI; +using osu.Game.Scoring; + +namespace osu.Game.Rulesets.Mods +{ + public abstract class ModMuted : Mod + { + public override string Name => "Muted"; + public override string Acronym => "MU"; + public override IconUsage? Icon => FontAwesome.Solid.VolumeMute; + public override string Description => "Can you still feel the rhythm without music?"; + public override ModType Type => ModType.Fun; + public override double ScoreMultiplier => 1; + } + + public abstract class ModMuted : ModMuted, IApplicableToDrawableRuleset, IApplicableToTrack, IApplicableToScoreProcessor + where TObject : HitObject + { + private readonly BindableNumber mainVolumeAdjust = new BindableDouble(0.5); + private readonly BindableNumber metronomeVolumeAdjust = new BindableDouble(0.5); + + private BindableNumber currentCombo; + + [SettingSource("Enable metronome", "Add a metronome beat to help you keep track of the rhythm.")] + public BindableBool EnableMetronome { get; } = new BindableBool + { + Default = true, + Value = true + }; + + [SettingSource("Final volume at combo", "The combo count at which point the track reaches its final volume.")] + public BindableInt MuteComboCount { get; } = new BindableInt + { + Default = 100, + Value = 100, + MinValue = 0, + MaxValue = 500, + }; + + [SettingSource("Start muted", "Increase volume as combo builds.")] + public BindableBool InverseMuting { get; } = new BindableBool + { + Default = false, + Value = false + }; + + [SettingSource("Mute hit sounds", "Hit sounds are also muted alongside the track.")] + public BindableBool AffectsHitSounds { get; } = new BindableBool + { + Default = true, + Value = true + }; + + public void ApplyToTrack(ITrack track) + { + track.AddAdjustment(AdjustableProperty.Volume, mainVolumeAdjust); + } + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + if (EnableMetronome.Value) + { + Metronome metronome; + + drawableRuleset.Overlays.Add(metronome = new Metronome(drawableRuleset.Beatmap.HitObjects.First().StartTime)); + + metronome.AddAdjustment(AdjustableProperty.Volume, metronomeVolumeAdjust); + } + + if (AffectsHitSounds.Value) + drawableRuleset.Audio.AddAdjustment(AdjustableProperty.Volume, mainVolumeAdjust); + } + + public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) + { + currentCombo = scoreProcessor.Combo.GetBoundCopy(); + currentCombo.BindValueChanged(combo => + { + double dimFactor = Math.Min(1, (double)combo.NewValue / MuteComboCount.Value); + + if (InverseMuting.Value) + dimFactor = 1 - dimFactor; + + scoreProcessor.TransformBindableTo(metronomeVolumeAdjust, dimFactor, 500, Easing.OutQuint); + scoreProcessor.TransformBindableTo(mainVolumeAdjust, 1 - dimFactor, 500, Easing.OutQuint); + }, true); + } + + public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank; + } +} diff --git a/osu.Game/Rulesets/Mods/MultiMod.cs b/osu.Game/Rulesets/Mods/MultiMod.cs index 2107009dbb..1c41c6b8b3 100644 --- a/osu.Game/Rulesets/Mods/MultiMod.cs +++ b/osu.Game/Rulesets/Mods/MultiMod.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Mods Mods = mods; } - public override Mod CreateCopy() => new MultiMod(Mods.Select(m => m.CreateCopy()).ToArray()); + public override Mod DeepClone() => new MultiMod(Mods.Select(m => m.DeepClone()).ToArray()); public override Type[] IncompatibleMods => Mods.SelectMany(m => m.IncompatibleMods).ToArray(); } diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index c5db806918..25f3b8931a 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -124,7 +124,9 @@ namespace osu.Game.Rulesets.Objects.Drawables public readonly Bindable StartTimeBindable = new Bindable(); private readonly BindableList samplesBindable = new BindableList(); private readonly Bindable userPositionalHitSounds = new Bindable(); + private readonly Bindable comboIndexBindable = new Bindable(); + private readonly Bindable comboIndexWithOffsetsBindable = new Bindable(); protected override bool RequiresChildrenUpdate => true; @@ -185,7 +187,8 @@ namespace osu.Game.Rulesets.Objects.Drawables { base.LoadComplete(); - comboIndexBindable.BindValueChanged(_ => UpdateComboColour(), true); + comboIndexBindable.BindValueChanged(_ => UpdateComboColour()); + comboIndexWithOffsetsBindable.BindValueChanged(_ => UpdateComboColour(), true); updateState(ArmedState.Idle, true); } @@ -250,7 +253,10 @@ namespace osu.Game.Rulesets.Objects.Drawables StartTimeBindable.BindValueChanged(onStartTimeChanged); if (HitObject is IHasComboInformation combo) + { comboIndexBindable.BindTo(combo.ComboIndexBindable); + comboIndexWithOffsetsBindable.BindTo(combo.ComboIndexWithOffsetsBindable); + } samplesBindable.BindTo(HitObject.SamplesBindable); samplesBindable.BindCollectionChanged(onSamplesChanged, true); @@ -275,8 +281,13 @@ namespace osu.Game.Rulesets.Objects.Drawables protected sealed override void OnFree(HitObjectLifetimeEntry entry) { StartTimeBindable.UnbindFrom(HitObject.StartTimeBindable); + if (HitObject is IHasComboInformation combo) + { comboIndexBindable.UnbindFrom(combo.ComboIndexBindable); + comboIndexWithOffsetsBindable.UnbindFrom(combo.ComboIndexWithOffsetsBindable); + } + samplesBindable.UnbindFrom(HitObject.SamplesBindable); // Changes in start time trigger state updates. When a new hitobject is applied, OnApply() automatically performs a state update anyway. @@ -502,8 +513,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/HitObject.cs b/osu.Game/Rulesets/Objects/HitObject.cs index db02eafa92..422655502d 100644 --- a/osu.Game/Rulesets/Objects/HitObject.cs +++ b/osu.Game/Rulesets/Objects/HitObject.cs @@ -118,6 +118,7 @@ namespace osu.Game.Rulesets.Objects foreach (var n in NestedHitObjects.OfType()) { n.ComboIndexBindable.BindTo(hasCombo.ComboIndexBindable); + n.ComboIndexWithOffsetsBindable.BindTo(hasCombo.ComboIndexWithOffsetsBindable); n.IndexInCurrentComboBindable.BindTo(hasCombo.IndexInCurrentComboBindable); } } diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs index 19722fb796..12b4812824 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHit.cs @@ -2,15 +2,20 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Objects.Types; +using osuTK; namespace osu.Game.Rulesets.Objects.Legacy.Catch { /// /// Legacy osu!catch Hit-type, used for parsing Beatmaps. /// - internal sealed class ConvertHit : ConvertHitObject, IHasCombo, IHasXPosition + internal sealed class ConvertHit : ConvertHitObject, IHasPosition, IHasCombo { - public float X { get; set; } + public float X => Position.X; + + public float Y => Position.Y; + + public Vector2 Position { get; set; } public bool NewCombo { get; set; } diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs index c10c8dc30f..c29179f749 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertHitObjectParser.cs @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Objects.Legacy.Catch return new ConvertHit { - X = position.X, + Position = position, NewCombo = newCombo, ComboOffset = comboOffset }; @@ -47,7 +47,7 @@ namespace osu.Game.Rulesets.Objects.Legacy.Catch return new ConvertSlider { - X = position.X, + Position = position, NewCombo = FirstObject || newCombo, ComboOffset = comboOffset, Path = new SliderPath(controlPoints, length), diff --git a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSlider.cs index 56790629b4..fb1afed3b4 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Catch/ConvertSlider.cs @@ -2,15 +2,20 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Objects.Types; +using osuTK; namespace osu.Game.Rulesets.Objects.Legacy.Catch { /// /// Legacy osu!catch Slider-type, used for parsing Beatmaps. /// - internal sealed class ConvertSlider : Legacy.ConvertSlider, IHasXPosition, IHasCombo + internal sealed class ConvertSlider : Legacy.ConvertSlider, IHasPosition, IHasCombo { - public float X { get; set; } + public float X => Position.X; + + public float Y => Position.Y; + + public Vector2 Position { get; set; } public bool NewCombo { get; set; } 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..29a56fc625 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 @@ -16,17 +15,25 @@ namespace osu.Game.Rulesets.Objects.Types Bindable IndexInCurrentComboBindable { get; } /// - /// The offset of this hitobject in the current combo. + /// The index of this hitobject in the current combo. /// int IndexInCurrentCombo { get; set; } Bindable ComboIndexBindable { get; } /// - /// The offset of this combo in relation to the beatmap. + /// The index of this combo in relation to the beatmap. /// int ComboIndex { get; set; } + Bindable ComboIndexWithOffsetsBindable { get; } + + /// + /// The index of this combo in relation to the beatmap, with all aggregate s applied. + /// This should be used instead of only when retrieving combo colours from the beatmap's skin. + /// + int ComboIndexWithOffsets { get; set; } + /// /// Whether the HitObject starts a new combo. /// @@ -40,11 +47,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/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index f32f70d4ba..6a2601170c 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; using osu.Framework.Bindables; -using osu.Framework.Extensions; using osu.Framework.Utils; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; @@ -146,7 +145,7 @@ namespace osu.Game.Rulesets.Scoring rollingMaxBaseScore += result.Judgement.MaxNumericResult; } - scoreResultCounts[result.Type] = scoreResultCounts.GetOrDefault(result.Type) + 1; + scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) + 1; hitEvents.Add(CreateHitEvent(result)); lastHitObject = result.HitObject; @@ -181,7 +180,7 @@ namespace osu.Game.Rulesets.Scoring rollingMaxBaseScore -= result.Judgement.MaxNumericResult; } - scoreResultCounts[result.Type] = scoreResultCounts.GetOrDefault(result.Type) - 1; + scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) - 1; Debug.Assert(hitEvents.Count > 0); lastHitObject = hitEvents[^1].LastHitObject; @@ -272,8 +271,8 @@ namespace osu.Game.Rulesets.Scoring private double calculateComboRatio(int maxCombo) => maxAchievableCombo > 0 ? (double)maxCombo / maxAchievableCombo : 1; private double getBonusScore(Dictionary statistics) - => statistics.GetOrDefault(HitResult.SmallBonus) * Judgement.SMALL_BONUS_SCORE - + statistics.GetOrDefault(HitResult.LargeBonus) * Judgement.LARGE_BONUS_SCORE; + => statistics.GetValueOrDefault(HitResult.SmallBonus) * Judgement.SMALL_BONUS_SCORE + + statistics.GetValueOrDefault(HitResult.LargeBonus) * Judgement.LARGE_BONUS_SCORE; private ScoreRank rankFrom(double acc) { @@ -291,7 +290,7 @@ namespace osu.Game.Rulesets.Scoring return ScoreRank.D; } - public int GetStatistic(HitResult result) => scoreResultCounts.GetOrDefault(result); + public int GetStatistic(HitResult result) => scoreResultCounts.GetValueOrDefault(result); public double GetStandardisedScore() => getScore(ScoringMode.Standardised); @@ -339,7 +338,6 @@ namespace osu.Game.Rulesets.Scoring score.MaxCombo = HighestCombo.Value; score.Accuracy = Accuracy.Value; score.Rank = Rank.Value; - score.Date = DateTimeOffset.Now; foreach (var result in Enum.GetValues(typeof(HitResult)).OfType().Where(r => r.IsScorable())) score.Statistics[result] = GetStatistic(result); diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs index daf46dcdcc..29559f5036 100644 --- a/osu.Game/Rulesets/UI/DrawableRuleset.cs +++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs @@ -1,29 +1,30 @@ // 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.Rulesets.Judgements; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Drawables; using System; using System.Collections.Generic; using System.Linq; using System.Threading; using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Audio; using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Input; using osu.Framework.Input.Events; +using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics.Cursor; using osu.Game.Input.Handlers; using osu.Game.Overlays; using osu.Game.Replays; using osu.Game.Rulesets.Configuration; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play; @@ -98,6 +99,14 @@ namespace osu.Game.Rulesets.UI private DrawableRulesetDependencies dependencies; + /// + /// Audio adjustments which are applied to the playfield. + /// + /// + /// Does not affect . + /// + public IAdjustableAudioComponent Audio { get; private set; } + /// /// Creates a ruleset visualisation for the provided ruleset and beatmap. /// @@ -155,23 +164,28 @@ namespace osu.Game.Rulesets.UI [BackgroundDependencyLoader] private void load(CancellationToken? cancellationToken) { - InternalChildren = new Drawable[] + AudioContainer audioContainer; + + InternalChild = frameStabilityContainer = new FrameStabilityContainer(GameplayStartTime) { - frameStabilityContainer = new FrameStabilityContainer(GameplayStartTime) + FrameStablePlayback = FrameStablePlayback, + Children = new Drawable[] { - FrameStablePlayback = FrameStablePlayback, - Children = new Drawable[] + FrameStableComponents, + audioContainer = new AudioContainer { - FrameStableComponents, - KeyBindingInputManager + RelativeSizeAxes = Axes.Both, + Child = KeyBindingInputManager .WithChild(CreatePlayfieldAdjustmentContainer() .WithChild(Playfield) ), - Overlays, - } - }, + }, + Overlays, + } }; + Audio = audioContainer; + if ((ResumeOverlay = CreateResumeOverlay()) != null) { AddInternal(CreateInputManager() diff --git a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs index 6ffdad211b..f8d5a6c5a9 100644 --- a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs +++ b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs @@ -43,7 +43,7 @@ namespace osu.Game.Rulesets.UI.Scrolling /// /// The maximum span of time that may be visible by the length of the scrolling axes. /// - private const double time_span_max = 10000; + private const double time_span_max = 20000; /// /// The step increase/decrease of the span of time visible by the length of the scrolling axes. diff --git a/osu.Game/Scoring/Score.cs b/osu.Game/Scoring/Score.cs index 4e82b1584e..83e4389dc8 100644 --- a/osu.Game/Scoring/Score.cs +++ b/osu.Game/Scoring/Score.cs @@ -2,12 +2,22 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Replays; +using osu.Game.Utils; namespace osu.Game.Scoring { - public class Score + public class Score : IDeepCloneable { public ScoreInfo ScoreInfo = new ScoreInfo(); public Replay Replay = new Replay(); + + public Score DeepClone() + { + return new Score + { + ScoreInfo = ScoreInfo.DeepClone(), + Replay = Replay.DeepClone(), + }; + } } } diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index 4fd1d00fef..890ead40e3 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -7,7 +7,7 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Linq; using Newtonsoft.Json; using Newtonsoft.Json.Converters; -using osu.Framework.Extensions; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Online.API; @@ -19,7 +19,7 @@ using osu.Game.Utils; namespace osu.Game.Scoring { - public class ScoreInfo : IHasFiles, IHasPrimaryKey, ISoftDelete, IEquatable + public class ScoreInfo : IHasFiles, IHasPrimaryKey, ISoftDelete, IEquatable, IDeepCloneable { public int ID { get; set; } @@ -35,7 +35,7 @@ namespace osu.Game.Scoring public double Accuracy { get; set; } [JsonIgnore] - public string DisplayAccuracy => Accuracy.FormatAccuracy(); + public LocalisableString DisplayAccuracy => Accuracy.FormatAccuracy(); [JsonProperty(@"pp")] public double? PP { get; set; } @@ -209,13 +209,13 @@ namespace osu.Game.Scoring { foreach (var r in Ruleset.CreateInstance().GetHitResults()) { - int value = Statistics.GetOrDefault(r.result); + int value = Statistics.GetValueOrDefault(r.result); switch (r.result) { case HitResult.SmallTickHit: { - int total = value + Statistics.GetOrDefault(HitResult.SmallTickMiss); + int total = value + Statistics.GetValueOrDefault(HitResult.SmallTickMiss); if (total > 0) yield return new HitResultDisplayStatistic(r.result, value, total, r.displayName); @@ -224,7 +224,7 @@ namespace osu.Game.Scoring case HitResult.LargeTickHit: { - int total = value + Statistics.GetOrDefault(HitResult.LargeTickMiss); + int total = value + Statistics.GetValueOrDefault(HitResult.LargeTickMiss); if (total > 0) yield return new HitResultDisplayStatistic(r.result, value, total, r.displayName); @@ -243,6 +243,15 @@ namespace osu.Game.Scoring } } + public ScoreInfo DeepClone() + { + var clone = (ScoreInfo)MemberwiseClone(); + + clone.Statistics = new Dictionary(clone.Statistics); + + return clone; + } + public override string ToString() => $"{User} playing {Beatmap}"; public bool Equals(ScoreInfo other) diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index ebbdc8a109..83bcac01ac 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -11,7 +11,6 @@ using System.Threading.Tasks; using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; using osu.Framework.Bindables; -using osu.Framework.Extensions; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Beatmaps; @@ -210,7 +209,7 @@ namespace osu.Game.Scoring { // This is guaranteed to be a non-legacy score. // The combo must be determined through the score's statistics, as both the beatmap's max combo and the difficulty calculator will provide osu!stable combo values. - beatmapMaxCombo = Enum.GetValues(typeof(HitResult)).OfType().Where(r => r.AffectsCombo()).Select(r => score.Statistics.GetOrDefault(r)).Sum(); + beatmapMaxCombo = Enum.GetValues(typeof(HitResult)).OfType().Where(r => r.AffectsCombo()).Select(r => score.Statistics.GetValueOrDefault(r)).Sum(); } updateScore(beatmapMaxCombo, accuracy); diff --git a/osu.Game/Scoring/ScoreRank.cs b/osu.Game/Scoring/ScoreRank.cs index f3b4551ff8..64f7da9ba3 100644 --- a/osu.Game/Scoring/ScoreRank.cs +++ b/osu.Game/Scoring/ScoreRank.cs @@ -1,74 +1,44 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.ComponentModel; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Scoring { - [LocalisableEnum(typeof(ScoreRankEnumLocalisationMapper))] public enum ScoreRank { + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankD))] [Description(@"D")] D, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankC))] [Description(@"C")] C, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankB))] [Description(@"B")] B, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankA))] [Description(@"A")] A, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankS))] [Description(@"S")] S, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankSH))] [Description(@"S+")] SH, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankX))] [Description(@"SS")] X, + [LocalisableDescription(typeof(BeatmapsStrings), nameof(BeatmapsStrings.RankXH))] [Description(@"SS+")] XH, } - - public class ScoreRankEnumLocalisationMapper : EnumLocalisationMapper - { - public override LocalisableString Map(ScoreRank value) - { - switch (value) - { - case ScoreRank.XH: - return BeatmapsStrings.RankXH; - - case ScoreRank.X: - return BeatmapsStrings.RankX; - - case ScoreRank.SH: - return BeatmapsStrings.RankSH; - - case ScoreRank.S: - return BeatmapsStrings.RankS; - - case ScoreRank.A: - return BeatmapsStrings.RankA; - - case ScoreRank.B: - return BeatmapsStrings.RankB; - - case ScoreRank.C: - return BeatmapsStrings.RankC; - - case ScoreRank.D: - return BeatmapsStrings.RankD; - - default: - throw new ArgumentOutOfRangeException(nameof(value), value, null); - } - } - } } diff --git a/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs b/osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButton.cs similarity index 69% rename from osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs rename to osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButton.cs index 1f608d28fd..d66856ebd8 100644 --- a/osu.Game/Screens/Edit/Components/RadioButtons/DrawableRadioButton.cs +++ b/osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButton.cs @@ -5,9 +5,11 @@ using System; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -16,26 +18,30 @@ using osuTK.Graphics; namespace osu.Game.Screens.Edit.Components.RadioButtons { - public class DrawableRadioButton : OsuButton + public class EditorRadioButton : OsuButton, IHasTooltip { /// - /// Invoked when this has been selected. + /// Invoked when this has been selected. /// public Action Selected; + public readonly RadioButton Button; + private Color4 defaultBackgroundColour; private Color4 defaultBubbleColour; private Color4 selectedBackgroundColour; private Color4 selectedBubbleColour; private Drawable icon; - private readonly RadioButton button; - public DrawableRadioButton(RadioButton button) + [Resolved(canBeNull: true)] + private EditorBeatmap editorBeatmap { get; set; } + + public EditorRadioButton(RadioButton button) { - this.button = button; + Button = button; - Text = button.Item.ToString(); + Text = button.Label; Action = button.Select; RelativeSizeAxes = Axes.X; @@ -57,7 +63,7 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons Colour = Color4.Black.Opacity(0.5f) }; - Add(icon = (button.CreateIcon?.Invoke() ?? new Circle()).With(b => + Add(icon = (Button.CreateIcon?.Invoke() ?? new Circle()).With(b => { b.Blending = BlendingParameters.Additive; b.Anchor = Anchor.CentreLeft; @@ -71,13 +77,16 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons { base.LoadComplete(); - button.Selected.ValueChanged += selected => + Button.Selected.ValueChanged += selected => { updateSelectionState(); if (selected.NewValue) - Selected?.Invoke(button); + Selected?.Invoke(Button); }; + editorBeatmap?.HasTiming.BindValueChanged(hasTiming => Button.Selected.Disabled = !hasTiming.NewValue, true); + + Button.Selected.BindDisabledChanged(disabled => Enabled.Value = !disabled, true); updateSelectionState(); } @@ -86,8 +95,8 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons if (!IsLoaded) return; - BackgroundColour = button.Selected.Value ? selectedBackgroundColour : defaultBackgroundColour; - icon.Colour = button.Selected.Value ? selectedBubbleColour : defaultBubbleColour; + BackgroundColour = Button.Selected.Value ? selectedBackgroundColour : defaultBackgroundColour; + icon.Colour = Button.Selected.Value ? selectedBubbleColour : defaultBubbleColour; } protected override SpriteText CreateText() => new OsuSpriteText @@ -97,5 +106,7 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons Anchor = Anchor.CentreLeft, X = 40f }; + + public LocalisableString TooltipText => Enabled.Value ? string.Empty : "Add at least one timing point first!"; } } diff --git a/osu.Game/Screens/Edit/Components/RadioButtons/RadioButtonCollection.cs b/osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButtonCollection.cs similarity index 85% rename from osu.Game/Screens/Edit/Components/RadioButtons/RadioButtonCollection.cs rename to osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButtonCollection.cs index 16574c0baf..6a7b0c9ef7 100644 --- a/osu.Game/Screens/Edit/Components/RadioButtons/RadioButtonCollection.cs +++ b/osu.Game/Screens/Edit/Components/RadioButtons/EditorRadioButtonCollection.cs @@ -9,7 +9,7 @@ using osuTK; namespace osu.Game.Screens.Edit.Components.RadioButtons { - public class RadioButtonCollection : CompositeDrawable + public class EditorRadioButtonCollection : CompositeDrawable { private IReadOnlyList items; @@ -28,13 +28,13 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons } } - private readonly FlowContainer buttonContainer; + private readonly FlowContainer buttonContainer; - public RadioButtonCollection() + public EditorRadioButtonCollection() { AutoSizeAxes = Axes.Y; - InternalChild = buttonContainer = new FillFlowContainer + InternalChild = buttonContainer = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, @@ -58,7 +58,7 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons currentlySelected = null; }; - buttonContainer.Add(new DrawableRadioButton(button)); + buttonContainer.Add(new EditorRadioButton(button)); } } } diff --git a/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs b/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs index dcf5f8a788..ca79dd15d7 100644 --- a/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs +++ b/osu.Game/Screens/Edit/Components/RadioButtons/RadioButton.cs @@ -17,7 +17,7 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons /// /// The item related to this button. /// - public object Item; + public string Label; /// /// A function which creates a drawable icon to represent this item. If null, a sane default should be used. @@ -26,21 +26,14 @@ namespace osu.Game.Screens.Edit.Components.RadioButtons private readonly Action action; - public RadioButton(object item, Action action, Func createIcon = null) + public RadioButton(string label, Action action, Func createIcon = null) { - Item = item; + Label = label; CreateIcon = createIcon; this.action = action; Selected = new BindableBool(); } - public RadioButton(string item) - : this(item, null) - { - Item = item; - action = null; - } - /// /// Selects this . /// 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..6e57b8e88c 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; @@ -38,7 +37,10 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private readonly Bindable startTime; private Bindable indexInCurrentComboBindable; + private Bindable comboIndexBindable; + private Bindable comboIndexWithOffsetsBindable; + private Bindable displayColourBindable; private readonly ExtendableCircle circle; @@ -121,7 +123,10 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline indexInCurrentComboBindable.BindValueChanged(_ => updateComboIndex(), true); comboIndexBindable = comboInfo.ComboIndexBindable.GetBoundCopy(); - comboIndexBindable.BindValueChanged(_ => updateColour(), true); + comboIndexWithOffsetsBindable = comboInfo.ComboIndexWithOffsetsBindable.GetBoundCopy(); + + comboIndexBindable.BindValueChanged(_ => updateColour()); + comboIndexWithOffsetsBindable.BindValueChanged(_ => updateColour(), true); skin.SourceChanged += updateColour; break; @@ -153,11 +158,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 986a4efb28..61a3b0f5cc 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -55,7 +55,7 @@ namespace osu.Game.Screens.Edit public override bool DisallowExternalBeatmapRulesetChanges => true; - public override bool AllowRateAdjustments => false; + public override bool AllowTrackAdjustments => false; protected bool HasUnsavedChanges => lastSavedHash != changeHandler.CurrentStateHash; @@ -128,7 +128,7 @@ namespace osu.Game.Screens.Edit // clone these locally for now to avoid incurring overhead on GetPlayableBeatmap usages. // eventually we will want to improve how/where this is done as there are issues with *not* cloning it in all cases. - playableBeatmap.ControlPointInfo = playableBeatmap.ControlPointInfo.CreateCopy(); + playableBeatmap.ControlPointInfo = playableBeatmap.ControlPointInfo.DeepClone(); } catch (Exception e) { @@ -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/EditorBeatmap.cs b/osu.Game/Screens/Edit/EditorBeatmap.cs index be53abbd55..7de98e5e85 100644 --- a/osu.Game/Screens/Edit/EditorBeatmap.cs +++ b/osu.Game/Screens/Edit/EditorBeatmap.cs @@ -46,12 +46,22 @@ namespace osu.Game.Screens.Edit public readonly IBeatmap PlayableBeatmap; + /// + /// Whether at least one timing control point is present and providing timing information. + /// + public IBindable HasTiming => hasTiming; + + private readonly Bindable hasTiming = new Bindable(); + [CanBeNull] public readonly ISkin BeatmapSkin; [Resolved] private BindableBeatDivisor beatDivisor { get; set; } + [Resolved] + private EditorClock editorClock { get; set; } + private readonly IBeatmapProcessor beatmapProcessor; private readonly Dictionary> startTimeBindables = new Dictionary>(); @@ -238,6 +248,8 @@ namespace osu.Game.Screens.Edit if (batchPendingUpdates.Count > 0) UpdateState(); + + hasTiming.Value = !ReferenceEquals(ControlPointInfo.TimingPointAt(editorClock.CurrentTime), TimingControlPoint.DEFAULT); } protected override void UpdateState() diff --git a/osu.Game/Screens/Edit/EditorTable.cs b/osu.Game/Screens/Edit/EditorTable.cs index 815f3ed0ea..ab8bd6a3bc 100644 --- a/osu.Game/Screens/Edit/EditorTable.cs +++ b/osu.Game/Screens/Edit/EditorTable.cs @@ -2,10 +2,12 @@ // 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.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -41,11 +43,11 @@ namespace osu.Game.Screens.Edit }); } - protected override Drawable CreateHeader(int index, TableColumn column) => new HeaderText(column?.Header ?? string.Empty); + protected override Drawable CreateHeader(int index, TableColumn column) => new HeaderText(column?.Header ?? default); private class HeaderText : OsuSpriteText { - public HeaderText(string text) + public HeaderText(LocalisableString text) { Text = text.ToUpper(); Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold); diff --git a/osu.Game/Screens/Edit/Setup/ColoursSection.cs b/osu.Game/Screens/Edit/Setup/ColoursSection.cs index 4a81959a54..d7e16645f2 100644 --- a/osu.Game/Screens/Edit/Setup/ColoursSection.cs +++ b/osu.Game/Screens/Edit/Setup/ColoursSection.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; @@ -32,7 +33,7 @@ namespace osu.Game.Screens.Edit.Setup var colours = Beatmap.BeatmapSkin?.GetConfig>(GlobalSkinColours.ComboColours)?.Value; if (colours != null) - comboColours.Colours.AddRange(colours); + comboColours.Colours.AddRange(colours.Select(c => (Colour4)c)); } } } diff --git a/osu.Game/Screens/IOsuScreen.cs b/osu.Game/Screens/IOsuScreen.cs index 0434135547..17384c161c 100644 --- a/osu.Game/Screens/IOsuScreen.cs +++ b/osu.Game/Screens/IOsuScreen.cs @@ -59,9 +59,9 @@ namespace osu.Game.Screens Bindable Ruleset { get; } /// - /// Whether mod rate adjustments are allowed to be applied. + /// Whether mod track adjustments are allowed to be applied. /// - bool AllowRateAdjustments { get; } + bool AllowTrackAdjustments { get; } /// /// Invoked when the back button has been pressed to close any overlays before exiting this . diff --git a/osu.Game/Screens/Menu/MainMenu.cs b/osu.Game/Screens/Menu/MainMenu.cs index e53b46f391..1d0182a945 100644 --- a/osu.Game/Screens/Menu/MainMenu.cs +++ b/osu.Game/Screens/Menu/MainMenu.cs @@ -36,7 +36,7 @@ namespace osu.Game.Screens.Menu public override bool AllowExternalScreenChange => true; - public override bool AllowRateAdjustments => false; + public override bool AllowTrackAdjustments => false; private Screen songSelect; diff --git a/osu.Game/Screens/Menu/OsuLogo.cs b/osu.Game/Screens/Menu/OsuLogo.cs index 283be913b0..a9376325cd 100644 --- a/osu.Game/Screens/Menu/OsuLogo.cs +++ b/osu.Game/Screens/Menu/OsuLogo.cs @@ -72,8 +72,6 @@ namespace osu.Game.Screens.Menu set => colourAndTriangles.FadeTo(value ? 1 : 0, transition_length, Easing.OutQuint); } - public bool BeatMatching = true; - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => logoContainer.ReceivePositionalInputAt(screenSpacePos); public bool Ripple @@ -272,8 +270,6 @@ namespace osu.Game.Screens.Menu { base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes); - if (!BeatMatching) return; - lastBeatIndex = beatIndex; var beatLength = timingPoint.BeatLength; diff --git a/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs b/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs index 227a772b2d..422576648c 100644 --- a/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs +++ b/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs @@ -84,10 +84,10 @@ namespace osu.Game.Screens.OnlinePlay.Components private JoinRoomRequest currentJoinRoomRequest; - public virtual void JoinRoom(Room room, Action onSuccess = null, Action onError = null) + public virtual void JoinRoom(Room room, string password = null, Action onSuccess = null, Action onError = null) { currentJoinRoomRequest?.Cancel(); - currentJoinRoomRequest = new JoinRoomRequest(room); + currentJoinRoomRequest = new JoinRoomRequest(room, password); currentJoinRoomRequest.Success += () => { 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/IRoomManager.cs b/osu.Game/Screens/OnlinePlay/IRoomManager.cs index 8ff02536f3..34c1393ff1 100644 --- a/osu.Game/Screens/OnlinePlay/IRoomManager.cs +++ b/osu.Game/Screens/OnlinePlay/IRoomManager.cs @@ -6,6 +6,8 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Online.Rooms; +#nullable enable + namespace osu.Game.Screens.OnlinePlay { [Cached(typeof(IRoomManager))] @@ -32,15 +34,16 @@ namespace osu.Game.Screens.OnlinePlay /// The to create. /// An action to be invoked if the creation succeeds. /// An action to be invoked if an error occurred. - void CreateRoom(Room room, Action onSuccess = null, Action onError = null); + void CreateRoom(Room room, Action? onSuccess = null, Action? onError = null); /// /// Joins a . /// /// The to join. must be populated. + /// An optional password to use for the join operation. /// /// - void JoinRoom(Room room, Action onSuccess = null, Action onError = null); + void JoinRoom(Room room, string? password = null, Action? onSuccess = null, Action? onError = null); /// /// Parts the currently-joined . diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs index 9bfec72942..c2955cec94 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -13,13 +14,18 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Input.Bindings; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Components; using osuTK; @@ -27,7 +33,7 @@ using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public class DrawableRoom : OsuClickableContainer, IStateful, IFilterable, IHasContextMenu + public class DrawableRoom : OsuClickableContainer, IStateful, IFilterable, IHasContextMenu, IHasPopover, IKeyBindingHandler { public const float SELECTION_BORDER_WIDTH = 4; private const float corner_radius = 10; @@ -44,6 +50,12 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components [Resolved] private BeatmapManager beatmaps { get; set; } + [Resolved(canBeNull: true)] + private Bindable selectedRoom { get; set; } + + [Resolved(canBeNull: true)] + private LoungeSubScreen lounge { get; set; } + public readonly Room Room; private SelectionState state; @@ -112,6 +124,10 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components public bool FilteringActive { get; set; } + private PasswordProtectedIcon passwordIcon; + + private readonly Bindable hasPassword = new Bindable(); + public DrawableRoom(Room room) { Room = room; @@ -296,7 +312,8 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components NumberOfAvatars = NumberOfAvatars } } - } + }, + passwordIcon = new PasswordProtectedIcon { Alpha = 0 } }, }, }, @@ -348,10 +365,61 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components else specialCategoryPill.Hide(); }, true); + + hasPassword.BindTo(Room.HasPassword); + hasPassword.BindValueChanged(v => passwordIcon.Alpha = v.NewValue ? 1 : 0, true); + } + + public Popover GetPopover() => new PasswordEntryPopover(Room) { JoinRequested = lounge.Join }; + + public MenuItem[] ContextMenuItems => new MenuItem[] + { + new OsuMenuItem("Create copy", MenuItemType.Standard, () => + { + lounge?.Open(Room.DeepClone()); + }) + }; + + public bool OnPressed(GlobalAction action) + { + if (selectedRoom.Value != Room) + return false; + + switch (action) + { + case GlobalAction.Select: + Click(); + return true; + } + + return false; + } + + public void OnReleased(GlobalAction action) + { } protected override bool ShouldBeConsideredForInput(Drawable child) => state == SelectionState.Selected; + protected override bool OnClick(ClickEvent e) + { + if (Room != selectedRoom.Value) + { + selectedRoom.Value = Room; + return true; + } + + if (Room.HasPassword.Value) + { + this.ShowPopover(); + return true; + } + + lounge?.Join(Room, null); + + return base.OnClick(e); + } + private class RoomNameText : OsuSpriteText { [Resolved(typeof(Room), nameof(Online.Rooms.Room.Name))] @@ -404,12 +472,84 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components } } - public MenuItem[] ContextMenuItems => new MenuItem[] + public class PasswordProtectedIcon : CompositeDrawable { - new OsuMenuItem("Create copy", MenuItemType.Standard, () => + [BackgroundDependencyLoader] + private void load(OsuColour colours) { - loungeScreen?.OpenNewRoom(Room.CreateCopy()); - }) - }; + Anchor = Anchor.TopRight; + Origin = Anchor.TopRight; + + Size = new Vector2(32); + + InternalChildren = new Drawable[] + { + new Box + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopCentre, + Colour = colours.Gray5, + Rotation = 45, + RelativeSizeAxes = Axes.Both, + Width = 2, + }, + new SpriteIcon + { + Icon = FontAwesome.Solid.Lock, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Margin = new MarginPadding(6), + Size = new Vector2(14), + } + }; + } + } + + public class PasswordEntryPopover : OsuPopover + { + private readonly Room room; + + public Action JoinRequested; + + public PasswordEntryPopover(Room room) + { + this.room = room; + } + + private OsuPasswordTextBox passwordTextbox; + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Child = new FillFlowContainer + { + Margin = new MarginPadding(10), + Spacing = new Vector2(5), + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = new Drawable[] + { + passwordTextbox = new OsuPasswordTextBox + { + Width = 200, + }, + new TriangleButton + { + Width = 80, + Text = "Join Room", + Action = () => JoinRequested?.Invoke(room, passwordTextbox.Text) + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Schedule(() => GetContainingInputManager().ChangeFocus(passwordTextbox)); + passwordTextbox.OnCommit += (_, __) => JoinRequested?.Invoke(room, passwordTextbox.Text); + } + } } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs index 0c6c84e656..b9b034272d 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs @@ -24,12 +24,11 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { public class RoomsContainer : CompositeDrawable, IKeyBindingHandler { - public Action JoinRequested; - private readonly IBindableList rooms = new BindableList(); private readonly FillFlowContainer roomFlow; - public IReadOnlyList Rooms => roomFlow; + + public IReadOnlyList Rooms => roomFlow.FlowingChildren.Cast().ToArray(); [Resolved(CanBeNull = true)] private Bindable filter { get; set; } @@ -121,19 +120,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { foreach (var room in rooms) { - roomFlow.Add(new DrawableRoom(room) - { - Action = () => - { - if (room == selectedRoom.Value) - { - joinSelected(); - return; - } - - selectRoom(room); - } - }); + roomFlow.Add(new DrawableRoom(room)); } Filter(filter?.Value); @@ -150,7 +137,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components roomFlow.Remove(toRemove); - selectRoom(null); + selectedRoom.Value = null; } } @@ -160,18 +147,9 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components roomFlow.SetLayoutPosition(room, room.Room.Position.Value); } - private void selectRoom(Room room) => selectedRoom.Value = room; - - private void joinSelected() - { - if (selectedRoom.Value == null) return; - - JoinRequested?.Invoke(selectedRoom.Value); - } - protected override bool OnClick(ClickEvent e) { - selectRoom(null); + selectedRoom.Value = null; return base.OnClick(e); } @@ -181,10 +159,6 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { switch (action) { - case GlobalAction.Select: - joinSelected(); - return true; - case GlobalAction.SelectNext: beginRepeatSelection(() => selectNext(1), action); return true; @@ -253,7 +227,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components // we already have a valid selection only change selection if we still have a room to switch to. if (room != null) - selectRoom(room); + selectedRoom.Value = room; } #endregion diff --git a/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs index 115dddafec..122b30b1d2 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs @@ -6,6 +6,7 @@ using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -55,10 +56,11 @@ namespace osu.Game.Screens.OnlinePlay.Lounge [CanBeNull] private IDisposable joiningRoomOperation { get; set; } + private RoomsContainer roomsContainer; + [BackgroundDependencyLoader] private void load() { - RoomsContainer roomsContainer; OsuScrollContainer scrollContainer; InternalChildren = new Drawable[] @@ -102,7 +104,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge Buttons.WithChild(CreateNewRoomButton().With(d => { d.Size = new Vector2(150, 25); - d.Action = () => OpenNewRoom(); + d.Action = () => Open(); })) } } @@ -119,7 +121,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge { RelativeSizeAxes = Axes.Both, ScrollbarOverlapsContent = false, - Child = roomsContainer = new RoomsContainer { JoinRequested = joinRequested } + Child = roomsContainer = new RoomsContainer() }, loadingLayer = new LoadingLayer(true), } @@ -177,31 +179,39 @@ namespace osu.Game.Screens.OnlinePlay.Lounge onReturning(); } - private void onReturning() - { - filter.HoldFocus = true; - } - public override bool OnExiting(IScreen next) { - filter.HoldFocus = false; + onLeaving(); return base.OnExiting(next); } public override void OnSuspending(IScreen next) { + onLeaving(); base.OnSuspending(next); - filter.HoldFocus = false; } - private void joinRequested(Room room) + private void onReturning() + { + filter.HoldFocus = true; + } + + private void onLeaving() + { + filter.HoldFocus = false; + + // ensure any password prompt is dismissed. + this.HidePopover(); + } + + public void Join(Room room, string password) => Schedule(() => { if (joiningRoomOperation != null) return; joiningRoomOperation = ongoingOperationTracker?.BeginOperation(); - RoomManager?.JoinRoom(room, r => + RoomManager?.JoinRoom(room, password, r => { Open(room); joiningRoomOperation?.Dispose(); @@ -211,25 +221,23 @@ namespace osu.Game.Screens.OnlinePlay.Lounge joiningRoomOperation?.Dispose(); joiningRoomOperation = null; }); - } - - private void updateLoadingLayer() - { - if (operationInProgress.Value || !initialRoomsReceived.Value) - loadingLayer.Show(); - else - loadingLayer.Hide(); - } + }); /// /// Push a room as a new subscreen. /// - public virtual void Open(Room room) + /// An optional template to use when creating the room. + public void Open(Room room = null) => Schedule(() => { // Handles the case where a room is clicked 3 times in quick succession if (!this.IsCurrentScreen()) return; + OpenNewRoom(room ?? CreateNewRoom()); + }); + + protected virtual void OpenNewRoom(Room room) + { selectedRoom.Value = room; this.Push(CreateRoomSubScreen(room)); @@ -237,12 +245,6 @@ namespace osu.Game.Screens.OnlinePlay.Lounge protected abstract FilterControl CreateFilterControl(); - /// - /// Creates and opens the newly-created room. - /// - /// An optional template to use when creating the room. - public void OpenNewRoom(Room room = null) => Open(room ?? CreateNewRoom()); - protected abstract OsuButton CreateNewRoomButton(); /// @@ -252,5 +254,13 @@ namespace osu.Game.Screens.OnlinePlay.Lounge protected abstract Room CreateNewRoom(); protected abstract RoomSubScreen CreateRoomSubScreen(Room room); + + private void updateLoadingLayer() + { + if (operationInProgress.Value || !initialRoomsReceived.Value) + loadingLayer.Show(); + else + loadingLayer.Hide(); + } } } diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/MatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Match/Components/MatchSettingsOverlay.cs index 5699da740c..61bb39d0c5 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/MatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/MatchSettingsOverlay.cs @@ -25,8 +25,12 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components private void load() { Masking = true; + + Add(Settings = CreateSettings()); } + protected abstract OnlinePlayComposite CreateSettings(); + protected override void PopIn() { Settings.MoveToY(0, TRANSITION_DURATION, Easing.OutQuint); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs index 2e180f31fd..338d2c9e84 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs @@ -27,16 +27,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { public class MultiplayerMatchSettingsOverlay : MatchSettingsOverlay { - [BackgroundDependencyLoader] - private void load() - { - Child = Settings = new MatchSettings + protected override OnlinePlayComposite CreateSettings() + => new MatchSettings { RelativeSizeAxes = Axes.Both, RelativePositionAxes = Axes.Y, SettingsApplied = Hide }; - } protected class MatchSettings : OnlinePlayComposite { @@ -47,6 +44,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match public OsuTextBox NameField, MaxParticipantsField; public RoomAvailabilityPicker AvailabilityPicker; public GameTypePicker TypePicker; + public OsuTextBox PasswordTextBox; public TriangleButton ApplyButton; public OsuSpriteText ErrorText; @@ -193,12 +191,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match }, new Section("Password (optional)") { - Alpha = disabled_alpha, - Child = new SettingsPasswordTextBox + Child = PasswordTextBox = new SettingsPasswordTextBox { RelativeSizeAxes = Axes.X, TabbableContentContainer = this, - ReadOnly = true, }, }, } @@ -275,6 +271,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match Type.BindValueChanged(type => TypePicker.Current.Value = type.NewValue, true); MaxParticipants.BindValueChanged(count => MaxParticipantsField.Text = count.NewValue?.ToString(), true); RoomID.BindValueChanged(roomId => initialBeatmapControl.Alpha = roomId.NewValue == null ? 1 : 0, true); + Password.BindValueChanged(password => PasswordTextBox.Text = password.NewValue ?? string.Empty, true); operationInProgress.BindTo(ongoingOperationTracker.InProgress); operationInProgress.BindValueChanged(v => @@ -307,7 +304,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match // Otherwise, update the room directly in preparation for it to be submitted to the API on match creation. if (client.Room != null) { - client.ChangeSettings(name: NameField.Text).ContinueWith(t => Schedule(() => + client.ChangeSettings(name: NameField.Text, password: PasswordTextBox.Text).ContinueWith(t => Schedule(() => { if (t.IsCompletedSuccessfully) onSuccess(currentRoom.Value); @@ -320,6 +317,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match currentRoom.Value.Name.Value = NameField.Text; currentRoom.Value.Availability.Value = AvailabilityPicker.Current.Value; currentRoom.Value.Type.Value = TypePicker.Current.Value; + currentRoom.Value.Password.Value = PasswordTextBox.Current.Value; if (int.TryParse(MaxParticipantsField.Text, out int max)) currentRoom.Value.MaxParticipants.Value = max; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs index dd5290f127..991d5313e9 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs @@ -33,15 +33,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer protected override RoomSubScreen CreateRoomSubScreen(Room room) => new MultiplayerMatchSubScreen(room); - public override void Open(Room room) + protected override void OpenNewRoom(Room room) { - if (!client.IsConnected.Value) + if (client?.IsConnected.Value != true) { Logger.Log("Not currently connected to the multiplayer server.", LoggingTarget.Runtime, LogLevel.Important); return; } - base.Open(room); + base.OpenNewRoom(room); } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs index 8526196902..cbba4babe5 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomManager.cs @@ -38,9 +38,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer } public override void CreateRoom(Room room, Action onSuccess = null, Action onError = null) - => base.CreateRoom(room, r => joinMultiplayerRoom(r, onSuccess, onError), onError); + => base.CreateRoom(room, r => joinMultiplayerRoom(r, r.Password.Value, onSuccess, onError), onError); - public override void JoinRoom(Room room, Action onSuccess = null, Action onError = null) + public override void JoinRoom(Room room, string password = null, Action onSuccess = null, Action onError = null) { if (!multiplayerClient.IsConnected.Value) { @@ -56,7 +56,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer return; } - base.JoinRoom(room, r => joinMultiplayerRoom(r, onSuccess, onError), onError); + base.JoinRoom(room, password, r => joinMultiplayerRoom(r, password, onSuccess, onError), onError); } public override void PartRoom() @@ -79,11 +79,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer }); } - private void joinMultiplayerRoom(Room room, Action onSuccess = null, Action onError = null) + private void joinMultiplayerRoom(Room room, string password, Action onSuccess = null, Action onError = null) { Debug.Assert(room.RoomID.Value != null); - multiplayerClient.JoinRoom(room).ContinueWith(t => + multiplayerClient.JoinRoom(room, password).ContinueWith(t => { if (t.IsCompletedSuccessfully) Schedule(() => onSuccess?.Invoke(room)); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs index 2a2759e0dd..56ed7a9564 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs @@ -24,7 +24,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate public override bool DisallowExternalBeatmapRulesetChanges => true; // We are managing our own adjustments. For now, this happens inside the Player instances themselves. - public override bool AllowRateAdjustments => false; + public override bool AllowTrackAdjustments => false; /// /// Whether all spectating players have finished loading. diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs index ffaeb8fc97..ac816c8561 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayComposite.cs @@ -59,6 +59,9 @@ namespace osu.Game.Screens.OnlinePlay [Resolved(typeof(Room))] protected Bindable Availability { get; private set; } + [Resolved(typeof(Room), nameof(Room.Password))] + public Bindable Password { get; private set; } + [Resolved(typeof(Room))] protected Bindable Duration { get; private set; } diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs index 3a229ecd4a..5ec9202759 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; @@ -28,16 +29,15 @@ 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 readonly LoungeSubScreen loungeSubScreen; - private readonly ScreenStack screenStack; + private MultiplayerWaveContainer waves; + private LoungeSubScreen loungeSubScreen; + private ScreenStack screenStack; private readonly IBindable isIdle = new BindableBool(); @@ -51,7 +51,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; } @@ -62,11 +62,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() { @@ -75,6 +78,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 @@ -130,26 +141,9 @@ namespace osu.Game.Screens.OnlinePlay }, new Header(ScreenTitle, screenStack), RoomManager = CreateRoomManager(), - ongoingOperationTracker = new OngoingOperationTracker() + 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); } private void onlineStateChanged(ValueChangedEvent state) => Schedule(() => @@ -164,7 +158,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 = CreateLounge()); + + 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) @@ -207,7 +214,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); @@ -218,14 +227,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/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index 2c46f76737..be28de5c43 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -72,8 +72,8 @@ namespace osu.Game.Screens.OnlinePlay // At this point, Mods contains both the required and allowed mods. For selection purposes, it should only contain the required mods. // Similarly, freeMods is currently empty but should only contain the allowed mods. - Mods.Value = selectedItem?.Value?.RequiredMods.Select(m => m.CreateCopy()).ToArray() ?? Array.Empty(); - FreeMods.Value = selectedItem?.Value?.AllowedMods.Select(m => m.CreateCopy()).ToArray() ?? Array.Empty(); + Mods.Value = selectedItem?.Value?.RequiredMods.Select(m => m.DeepClone()).ToArray() ?? Array.Empty(); + FreeMods.Value = selectedItem?.Value?.AllowedMods.Select(m => m.DeepClone()).ToArray() ?? Array.Empty(); Mods.BindValueChanged(onModsChanged); Ruleset.BindValueChanged(onRulesetChanged); @@ -108,8 +108,8 @@ namespace osu.Game.Screens.OnlinePlay } }; - item.RequiredMods.AddRange(Mods.Value.Select(m => m.CreateCopy())); - item.AllowedMods.AddRange(FreeMods.Value.Select(m => m.CreateCopy())); + item.RequiredMods.AddRange(Mods.Value.Select(m => m.DeepClone())); + item.AllowedMods.AddRange(FreeMods.Value.Select(m => m.DeepClone())); SelectItem(item); return true; diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsMatchSettingsOverlay.cs index 5eb2b545cb..88ac5ef6e5 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsMatchSettingsOverlay.cs @@ -26,16 +26,13 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { public Action EditPlaylist; - [BackgroundDependencyLoader] - private void load() - { - Child = Settings = new MatchSettings + protected override OnlinePlayComposite CreateSettings() + => new MatchSettings { RelativeSizeAxes = Axes.Both, RelativePositionAxes = Axes.Y, EditPlaylist = () => EditPlaylist?.Invoke() }; - } protected class MatchSettings : OnlinePlayComposite { diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs index 21335fc90c..076fa77336 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs @@ -55,10 +55,10 @@ namespace osu.Game.Screens.OnlinePlay.Playlists item.Ruleset.Value = Ruleset.Value; item.RequiredMods.Clear(); - item.RequiredMods.AddRange(Mods.Value.Select(m => m.CreateCopy())); + item.RequiredMods.AddRange(Mods.Value.Select(m => m.DeepClone())); item.AllowedMods.Clear(); - item.AllowedMods.AddRange(FreeMods.Value.Select(m => m.CreateCopy())); + item.AllowedMods.AddRange(FreeMods.Value.Select(m => m.DeepClone())); } } } diff --git a/osu.Game/Screens/OsuScreen.cs b/osu.Game/Screens/OsuScreen.cs index aeb51813e4..e3fe14a585 100644 --- a/osu.Game/Screens/OsuScreen.cs +++ b/osu.Game/Screens/OsuScreen.cs @@ -81,7 +81,7 @@ namespace osu.Game.Screens public virtual float BackgroundParallaxAmount => 1; - public virtual bool AllowRateAdjustments => true; + public virtual bool AllowTrackAdjustments => true; public Bindable Beatmap { get; private set; } @@ -242,7 +242,6 @@ namespace osu.Game.Screens logo.Anchor = Anchor.TopLeft; logo.Origin = Anchor.Centre; logo.RelativePositionAxes = Axes.Both; - logo.BeatMatching = true; logo.Triangles = true; logo.Ripple = true; } 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/Break/BreakInfoLine.cs b/osu.Game/Screens/Play/Break/BreakInfoLine.cs index 18aab394f8..0bc79d6e77 100644 --- a/osu.Game/Screens/Play/Break/BreakInfoLine.cs +++ b/osu.Game/Screens/Play/Break/BreakInfoLine.cs @@ -7,6 +7,7 @@ using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Utils; @@ -63,7 +64,7 @@ namespace osu.Game.Screens.Play.Break valueText.Text = newText; } - protected virtual string Format(T count) + protected virtual LocalisableString Format(T count) { if (count is Enum countEnum) return countEnum.GetDescription(); @@ -86,6 +87,6 @@ namespace osu.Game.Screens.Play.Break { } - protected override string Format(double count) => count.FormatAccuracy(); + protected override LocalisableString Format(double count) => count.FormatAccuracy(); } } diff --git a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs index 718ae24cf1..6d87211ddc 100644 --- a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs @@ -4,6 +4,7 @@ using System; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -31,7 +32,7 @@ namespace osu.Game.Screens.Play.HUD Current.BindTo(scoreProcessor.Combo); } - protected override string FormatCount(int count) + protected override LocalisableString FormatCount(int count) { return $@"{count}x"; } 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/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 97854ee12f..09eaf1c543 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -56,7 +56,7 @@ namespace osu.Game.Screens.Play protected override OverlayActivation InitialOverlayActivationMode => OverlayActivation.UserTriggered; // We are managing our own adjustments (see OnEntering/OnExiting). - public override bool AllowRateAdjustments => false; + public override bool AllowTrackAdjustments => false; private readonly IBindable gameActive = new Bindable(true); @@ -184,7 +184,7 @@ namespace osu.Game.Screens.Play [BackgroundDependencyLoader(true)] private void load(AudioManager audio, OsuConfigManager config, OsuGameBase game) { - Mods.Value = base.Mods.Value.Select(m => m.CreateCopy()).ToArray(); + Mods.Value = base.Mods.Value.Select(m => m.DeepClone()).ToArray(); if (Beatmap.Value is DummyWorkingBeatmap) return; @@ -297,11 +297,19 @@ namespace osu.Game.Screens.Play ScoreProcessor.HasCompleted.BindValueChanged(scoreCompletionChanged); HealthProcessor.Failed += onFail; - foreach (var mod in Mods.Value.OfType()) - mod.ApplyToScoreProcessor(ScoreProcessor); + // Provide judgement processors to mods after they're loaded so that they're on the gameplay clock, + // this is required for mods that apply transforms to these processors. + ScoreProcessor.OnLoadComplete += _ => + { + foreach (var mod in Mods.Value.OfType()) + mod.ApplyToScoreProcessor(ScoreProcessor); + }; - foreach (var mod in Mods.Value.OfType()) - mod.ApplyToHealthProcessor(HealthProcessor); + HealthProcessor.OnLoadComplete += _ => + { + foreach (var mod in Mods.Value.OfType()) + mod.ApplyToHealthProcessor(HealthProcessor); + }; IsBreakTime.BindTo(breakTracker.IsBreakTime); IsBreakTime.BindValueChanged(onBreakTimeChanged, true); @@ -694,9 +702,11 @@ namespace osu.Game.Screens.Play /// The final score. private async Task prepareScoreForResults() { + var scoreCopy = Score.DeepClone(); + try { - await PrepareScoreForResultsAsync(Score).ConfigureAwait(false); + await PrepareScoreForResultsAsync(scoreCopy).ConfigureAwait(false); } catch (Exception ex) { @@ -705,14 +715,14 @@ namespace osu.Game.Screens.Play try { - await ImportScore(Score).ConfigureAwait(false); + await ImportScore(scoreCopy).ConfigureAwait(false); } catch (Exception ex) { Logger.Error(ex, @"Score import failed!"); } - return Score.ScoreInfo; + return scoreCopy.ScoreInfo; } /// @@ -953,7 +963,11 @@ namespace osu.Game.Screens.Play // if arriving here and the results screen preparation task hasn't run, it's safe to say the user has not completed the beatmap. if (prepareScoreForDisplayTask == null) + { Score.ScoreInfo.Passed = false; + // potentially should be ScoreRank.F instead? this is the best alternative for now. + Score.ScoreInfo.Rank = ScoreRank.D; + } // EndPlaying() is typically called from ReplayRecorder.Dispose(). Disposal is currently asynchronous. // To resolve test failures, forcefully end playing synchronously when this screen exits. @@ -1017,13 +1031,7 @@ namespace osu.Game.Screens.Play /// /// The to prepare. /// A task that prepares the provided score. On completion, the score is assumed to be ready for display. - protected virtual Task PrepareScoreForResultsAsync(Score score) - { - // perform one final population to ensure everything is up-to-date. - ScoreProcessor.PopulateScore(score.ScoreInfo); - - return Task.CompletedTask; - } + protected virtual Task PrepareScoreForResultsAsync(Score score) => Task.CompletedTask; /// /// Creates the for a . diff --git a/osu.Game/Screens/Play/SongProgress.cs b/osu.Game/Screens/Play/SongProgress.cs index bd861dc598..f28622f42e 100644 --- a/osu.Game/Screens/Play/SongProgress.cs +++ b/osu.Game/Screens/Play/SongProgress.cs @@ -178,7 +178,7 @@ namespace osu.Game.Screens.Play float barHeight = bottom_bar_height + handle_size.Y; bar.ResizeHeightTo(ShowGraph.Value ? barHeight + graph_height : barHeight, transition_duration, Easing.In); - graph.MoveToY(ShowGraph.Value ? 0 : bottom_bar_height + graph_height, transition_duration, Easing.In); + graph.FadeTo(ShowGraph.Value ? 1 : 0, transition_duration, Easing.In); updateInfoMargin(); } diff --git a/osu.Game/Screens/Play/SubmittingPlayer.cs b/osu.Game/Screens/Play/SubmittingPlayer.cs index 76e9f28dae..5faa384d03 100644 --- a/osu.Game/Screens/Play/SubmittingPlayer.cs +++ b/osu.Game/Screens/Play/SubmittingPlayer.cs @@ -109,6 +109,8 @@ namespace osu.Game.Screens.Play { await base.PrepareScoreForResultsAsync(score).ConfigureAwait(false); + score.ScoreInfo.Date = DateTimeOffset.Now; + await submitScore(score).ConfigureAwait(false); } @@ -116,7 +118,7 @@ namespace osu.Game.Screens.Play { var exiting = base.OnExiting(next); - submitScore(Score); + submitScore(Score.DeepClone()); return exiting; } 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/Screens/Ranking/Expanded/Statistics/AccuracyStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/AccuracyStatistic.cs index 288a107874..476c9fb42f 100644 --- a/osu.Game/Screens/Ranking/Expanded/Statistics/AccuracyStatistic.cs +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/AccuracyStatistic.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -44,7 +45,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics protected override Easing RollingEasing => AccuracyCircle.ACCURACY_TRANSFORM_EASING; - protected override string FormatCount(double count) => count.FormatAccuracy(); + protected override LocalisableString FormatCount(double count) => count.FormatAccuracy(); protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(s => { diff --git a/osu.Game/Screens/Ranking/Expanded/TotalScoreCounter.cs b/osu.Game/Screens/Ranking/Expanded/TotalScoreCounter.cs index 65082d3fae..c54bca9e3a 100644 --- a/osu.Game/Screens/Ranking/Expanded/TotalScoreCounter.cs +++ b/osu.Game/Screens/Ranking/Expanded/TotalScoreCounter.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -26,7 +27,7 @@ namespace osu.Game.Screens.Ranking.Expanded RelativeSizeAxes = Axes.X; } - protected override string FormatCount(long count) => count.ToString("N0"); + protected override LocalisableString FormatCount(long count) => count.ToString("N0"); protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(s => { diff --git a/osu.Game/Screens/Select/BeatmapInfoWedge.cs b/osu.Game/Screens/Select/BeatmapInfoWedge.cs index 4a35202df2..763c27bcbb 100644 --- a/osu.Game/Screens/Select/BeatmapInfoWedge.cs +++ b/osu.Game/Screens/Select/BeatmapInfoWedge.cs @@ -24,6 +24,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Framework.Logging; using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; @@ -333,7 +334,7 @@ namespace osu.Game.Screens.Select { Name = "Length", CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Length), - Content = TimeSpan.FromMilliseconds(beatmap.BeatmapInfo.Length).ToString(@"m\:ss"), + Content = beatmap.BeatmapInfo.Length.ToFormattedDuration().ToString(), }), bpmLabelContainer = new Container { diff --git a/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs b/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs index 845c0a914e..6ecb96f723 100644 --- a/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs +++ b/osu.Game/Screens/Select/Options/BeatmapOptionsButton.cs @@ -14,6 +14,7 @@ using osu.Game.Graphics.Sprites; using osuTK; using osuTK.Graphics; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; namespace osu.Game.Screens.Select.Options { @@ -76,6 +77,7 @@ namespace osu.Game.Screens.Select.Options public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => box.ReceivePositionalInputAt(screenSpacePos); public BeatmapOptionsButton() + : base(HoverSampleSet.Submit) { Width = width; RelativeSizeAxes = Axes.Y; diff --git a/osu.Game/Screens/StartupScreen.cs b/osu.Game/Screens/StartupScreen.cs index e5e134fd39..15f75d7cff 100644 --- a/osu.Game/Screens/StartupScreen.cs +++ b/osu.Game/Screens/StartupScreen.cs @@ -16,7 +16,7 @@ namespace osu.Game.Screens public override bool CursorVisible => false; - public override bool AllowRateAdjustments => false; + public override bool AllowTrackAdjustments => false; protected override OverlayActivation InitialOverlayActivationMode => OverlayActivation.Disabled; } 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/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs index 07a94cac7a..8052f82c93 100644 --- a/osu.Game/Skinning/Editor/SkinEditor.cs +++ b/osu.Game/Skinning/Editor/SkinEditor.cs @@ -14,6 +14,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; +using osu.Game.Resources.Localisation.Web; using osuTK; namespace osu.Game.Skinning.Editor @@ -88,6 +89,13 @@ namespace osu.Game.Skinning.Editor Children = new Drawable[] { new SkinBlueprintContainer(targetScreen), + new TriangleButton + { + Margin = new MarginPadding(10), + Text = CommonStrings.ButtonsClose, + Width = 100, + Action = Hide, + }, new FillFlowContainer { Direction = FillDirection.Horizontal, diff --git a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs index 88020896bb..2562e9c57c 100644 --- a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs +++ b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs @@ -37,29 +37,46 @@ namespace osu.Game.Skinning.Editor switch (action) { case GlobalAction.Back: - if (skinEditor?.State.Value == Visibility.Visible) - { - skinEditor.ToggleVisibility(); - return true; - } + if (skinEditor?.State.Value != Visibility.Visible) + break; - break; + Hide(); + return true; case GlobalAction.ToggleSkinEditor: - if (skinEditor == null) - { - LoadComponentAsync(skinEditor = new SkinEditor(target), AddInternal); - skinEditor.State.BindValueChanged(editorVisibilityChanged); - } - else - skinEditor.ToggleVisibility(); - + Toggle(); return true; } return false; } + public void Toggle() + { + if (skinEditor == null) + Show(); + else + skinEditor.ToggleVisibility(); + } + + public override void Hide() + { + // base call intentionally omitted. + skinEditor.Hide(); + } + + public override void Show() + { + // base call intentionally omitted. + if (skinEditor == null) + { + LoadComponentAsync(skinEditor = new SkinEditor(target), AddInternal); + skinEditor.State.BindValueChanged(editorVisibilityChanged); + } + else + skinEditor.Show(); + } + private void editorVisibilityChanged(ValueChangedEvent visibility) { if (visibility.NewValue == Visibility.Visible) 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/LegacyBeatmapSkin.cs b/osu.Game/Skinning/LegacyBeatmapSkin.cs index caf37e5bc9..e6ddeba316 100644 --- a/osu.Game/Skinning/LegacyBeatmapSkin.cs +++ b/osu.Game/Skinning/LegacyBeatmapSkin.cs @@ -7,8 +7,11 @@ using osu.Framework.Graphics; using osu.Framework.IO.Stores; using osu.Game.Audio; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Formats; using osu.Game.IO; using osu.Game.Rulesets.Objects.Legacy; +using osu.Game.Rulesets.Objects.Types; +using osuTK.Graphics; namespace osu.Game.Skinning { @@ -59,6 +62,9 @@ namespace osu.Game.Skinning return base.GetConfig(lookup); } + protected override IBindable GetComboColour(IHasComboColours source, int comboIndex, IHasComboInformation combo) + => base.GetComboColour(source, combo.ComboIndexWithOffsets, combo); + public override ISample GetSample(ISampleInfo sampleInfo) { if (sampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacy && legacy.CustomSampleBank == 0) 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/Beatmaps/LegacyBeatmapSkinColourTest.cs b/osu.Game/Tests/Beatmaps/LegacyBeatmapSkinColourTest.cs index e0c2965fa0..cdf6a9a2b4 100644 --- a/osu.Game/Tests/Beatmaps/LegacyBeatmapSkinColourTest.cs +++ b/osu.Game/Tests/Beatmaps/LegacyBeatmapSkinColourTest.cs @@ -10,7 +10,6 @@ using osu.Framework.Bindables; using osu.Framework.IO.Stores; using osu.Framework.Testing; using osu.Game.Beatmaps; -using osu.Game.Screens.Play; using osu.Game.Skinning; using osu.Game.Tests.Visual; using osuTK.Graphics; @@ -60,16 +59,12 @@ namespace osu.Game.Tests.Beatmaps protected virtual ExposedPlayer CreateTestPlayer(bool userHasCustomColours) => new ExposedPlayer(userHasCustomColours); - protected class ExposedPlayer : Player + protected class ExposedPlayer : TestPlayer { protected readonly bool UserHasCustomColours; public ExposedPlayer(bool userHasCustomColours) - : base(new PlayerConfiguration - { - AllowPause = false, - ShowResults = false, - }) + : base(false, false) { UserHasCustomColours = userHasCustomColours; } @@ -106,6 +101,8 @@ namespace osu.Game.Tests.Beatmaps { new Color4(50, 100, 150, 255), new Color4(40, 80, 120, 255), + new Color4(25, 50, 75, 255), + new Color4(10, 20, 30, 255), }; public static readonly Color4 HYPER_DASH_COLOUR = Color4.DarkBlue; @@ -133,6 +130,8 @@ namespace osu.Game.Tests.Beatmaps { new Color4(150, 100, 50, 255), new Color4(20, 20, 20, 255), + new Color4(75, 50, 25, 255), + new Color4(80, 80, 80, 255), }; public static readonly Color4 HYPER_DASH_COLOUR = Color4.LightBlue; 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/Tests/Visual/DependencyProvidingContainer.cs b/osu.Game/Tests/Visual/DependencyProvidingContainer.cs new file mode 100644 index 0000000000..c799cad61a --- /dev/null +++ b/osu.Game/Tests/Visual/DependencyProvidingContainer.cs @@ -0,0 +1,34 @@ +// 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.Containers; + +namespace osu.Game.Tests.Visual +{ + /// + /// A which providing ad-hoc dependencies to the child drawables. + /// + /// + /// The must be set while this is not loaded. + /// + public class DependencyProvidingContainer : Container + { + /// + /// The dependencies provided to the children. + /// + // TODO: should be an init-only property when C# 9 + public (Type, object)[] CachedDependencies { get; set; } = Array.Empty<(Type, object)>(); + + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencyContainer = new DependencyContainer(base.CreateChildDependencies(parent)); + + foreach (var (type, value) in CachedDependencies) + dependencyContainer.CacheAs(type, value); + + return dependencyContainer; + } + } +} diff --git a/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs b/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs index 204c189591..3362ebbbd6 100644 --- a/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs +++ b/osu.Game/Tests/Visual/Multiplayer/IMultiplayerTestSceneDependencies.cs @@ -22,7 +22,7 @@ namespace osu.Game.Tests.Visual.Multiplayer /// /// The cached . /// - new TestMultiplayerRoomManager RoomManager { get; } + new TestRequestHandlingMultiplayerRoomManager RoomManager { get; } /// /// The cached . diff --git a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs index b7d3793ab1..42345b7266 100644 --- a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs +++ b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs @@ -18,7 +18,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public const int PLAYER_2_ID = 56; public TestMultiplayerClient Client => OnlinePlayDependencies.Client; - public new TestMultiplayerRoomManager RoomManager => OnlinePlayDependencies.RoomManager; + public new TestRequestHandlingMultiplayerRoomManager RoomManager => OnlinePlayDependencies.RoomManager; public TestUserLookupCache LookupCache => OnlinePlayDependencies?.LookupCache; public TestSpectatorClient SpectatorClient => OnlinePlayDependencies?.SpectatorClient; diff --git a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestSceneDependencies.cs b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestSceneDependencies.cs index a2b0b066a7..2e13fb6a56 100644 --- a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestSceneDependencies.cs +++ b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestSceneDependencies.cs @@ -18,7 +18,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public TestMultiplayerClient Client { get; } public TestUserLookupCache LookupCache { get; } public TestSpectatorClient SpectatorClient { get; } - public new TestMultiplayerRoomManager RoomManager => (TestMultiplayerRoomManager)base.RoomManager; + public new TestRequestHandlingMultiplayerRoomManager RoomManager => (TestRequestHandlingMultiplayerRoomManager)base.RoomManager; public MultiplayerTestSceneDependencies() { @@ -31,7 +31,7 @@ namespace osu.Game.Tests.Visual.Multiplayer CacheAs(SpectatorClient); } - protected override IRoomManager CreateRoomManager() => new TestMultiplayerRoomManager(); + protected override IRoomManager CreateRoomManager() => new TestRequestHandlingMultiplayerRoomManager(); protected virtual TestSpectatorClient CreateSpectatorClient() => new TestSpectatorClient(); } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index b0c8d6d19b..3349d670c8 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -28,7 +28,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public override IBindable IsConnected => isConnected; private readonly Bindable isConnected = new Bindable(true); - public Room? APIRoom { get; private set; } + public new Room? APIRoom => base.APIRoom; public Action? RoomSetupAction; @@ -38,9 +38,9 @@ namespace osu.Game.Tests.Visual.Multiplayer [Resolved] private BeatmapManager beatmaps { get; set; } = null!; - private readonly TestMultiplayerRoomManager roomManager; + private readonly TestRequestHandlingMultiplayerRoomManager roomManager; - public TestMultiplayerClient(TestMultiplayerRoomManager roomManager) + public TestMultiplayerClient(TestRequestHandlingMultiplayerRoomManager roomManager) { this.roomManager = roomManager; } @@ -115,10 +115,13 @@ namespace osu.Game.Tests.Visual.Multiplayer ((IMultiplayerClient)this).UserBeatmapAvailabilityChanged(userId, newBeatmapAvailability); } - protected override Task JoinRoom(long roomId) + protected override Task JoinRoom(long roomId, string? password = null) { var apiRoom = roomManager.Rooms.Single(r => r.RoomID.Value == roomId); + if (password != apiRoom.Password.Value) + throw new InvalidOperationException("Invalid password."); + var localUser = new MultiplayerRoomUser(api.LocalUser.Value.Id) { User = api.LocalUser.Value @@ -134,7 +137,9 @@ namespace osu.Game.Tests.Visual.Multiplayer BeatmapChecksum = apiRoom.Playlist.Last().Beatmap.Value.MD5Hash, RequiredMods = apiRoom.Playlist.Last().RequiredMods.Select(m => new APIMod(m)).ToArray(), AllowedMods = apiRoom.Playlist.Last().AllowedMods.Select(m => new APIMod(m)).ToArray(), - PlaylistItemId = apiRoom.Playlist.Last().ID + PlaylistItemId = apiRoom.Playlist.Last().ID, + // ReSharper disable once ConstantNullCoalescingCondition Incorrect inspection due to lack of nullable in Room.cs. + Password = password ?? string.Empty, }, Users = { localUser }, Host = localUser @@ -143,16 +148,10 @@ namespace osu.Game.Tests.Visual.Multiplayer RoomSetupAction?.Invoke(room); RoomSetupAction = null; - APIRoom = apiRoom; - return Task.FromResult(room); } - protected override Task LeaveRoomInternal() - { - APIRoom = null; - return Task.CompletedTask; - } + protected override Task LeaveRoomInternal() => Task.CompletedTask; public override Task TransferHost(int userId) => ((IMultiplayerClient)this).HostChanged(userId); diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs b/osu.Game/Tests/Visual/Multiplayer/TestRequestHandlingMultiplayerRoomManager.cs similarity index 60% rename from osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs rename to osu.Game/Tests/Visual/Multiplayer/TestRequestHandlingMultiplayerRoomManager.cs index 5d66cdba02..2e56c8a094 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestRequestHandlingMultiplayerRoomManager.cs @@ -20,7 +20,11 @@ namespace osu.Game.Tests.Visual.Multiplayer /// /// A for use in multiplayer test scenes. Should generally not be used by itself outside of a . /// - public class TestMultiplayerRoomManager : MultiplayerRoomManager + /// + /// This implementation will pretend to be a server, handling room retrieval and manipulation requests + /// and returning a roughly expected state, without the need for a server to be running. + /// + public class TestRequestHandlingMultiplayerRoomManager : MultiplayerRoomManager { [Resolved] private IAPIProvider api { get; set; } @@ -33,33 +37,50 @@ namespace osu.Game.Tests.Visual.Multiplayer public new readonly List Rooms = new List(); + private int currentRoomId; + private int currentPlaylistItemId; + [BackgroundDependencyLoader] private void load() { int currentScoreId = 0; - int currentRoomId = 0; - int currentPlaylistItemId = 0; + // Handling here is pretending to be a server, while also updating the local state to match + // how the server would eventually respond and update the RoomManager. ((DummyAPIAccess)api).HandleRequest = req => { switch (req) { case CreateRoomRequest createRoomRequest: - var createdRoom = new APICreatedRoom(); + var apiRoom = new Room(); - createdRoom.CopyFrom(createRoomRequest.Room); - createdRoom.RoomID.Value ??= currentRoomId++; + apiRoom.CopyFrom(createRoomRequest.Room); - for (int i = 0; i < createdRoom.Playlist.Count; i++) - createdRoom.Playlist[i].ID = currentPlaylistItemId++; + // Passwords are explicitly not copied between rooms. + apiRoom.HasPassword.Value = !string.IsNullOrEmpty(createRoomRequest.Room.Password.Value); + apiRoom.Password.Value = createRoomRequest.Room.Password.Value; - Rooms.Add(createdRoom); - createRoomRequest.TriggerSuccess(createdRoom); + AddRoom(apiRoom); + + var responseRoom = new APICreatedRoom(); + responseRoom.CopyFrom(createResponseRoom(apiRoom, false)); + + createRoomRequest.TriggerSuccess(responseRoom); return true; case JoinRoomRequest joinRoomRequest: + { + var room = Rooms.Single(r => r.RoomID.Value == joinRoomRequest.Room.RoomID.Value); + + if (joinRoomRequest.Password != room.Password.Value) + { + joinRoomRequest.TriggerFailure(new InvalidOperationException("Invalid password.")); + return true; + } + joinRoomRequest.TriggerSuccess(); return true; + } case PartRoomRequest partRoomRequest: partRoomRequest.TriggerSuccess(); @@ -69,20 +90,13 @@ namespace osu.Game.Tests.Visual.Multiplayer var roomsWithoutParticipants = new List(); foreach (var r in Rooms) - { - var newRoom = new Room(); - - newRoom.CopyFrom(r); - newRoom.RecentParticipants.Clear(); - - roomsWithoutParticipants.Add(newRoom); - } + roomsWithoutParticipants.Add(createResponseRoom(r, false)); getRoomsRequest.TriggerSuccess(roomsWithoutParticipants); return true; case GetRoomRequest getRoomRequest: - getRoomRequest.TriggerSuccess(Rooms.Single(r => r.RoomID.Value == getRoomRequest.RoomId)); + getRoomRequest.TriggerSuccess(createResponseRoom(Rooms.Single(r => r.RoomID.Value == getRoomRequest.RoomId), true)); return true; case GetBeatmapSetRequest getBeatmapSetRequest: @@ -118,6 +132,27 @@ namespace osu.Game.Tests.Visual.Multiplayer }; } + public void AddRoom(Room room) + { + room.RoomID.Value ??= currentRoomId++; + for (int i = 0; i < room.Playlist.Count; i++) + room.Playlist[i].ID = currentPlaylistItemId++; + + Rooms.Add(room); + } + + public new void RemoveRoom(Room room) => base.RemoveRoom(room); + + private Room createResponseRoom(Room room, bool withParticipants) + { + var responseRoom = new Room(); + responseRoom.CopyFrom(room); + responseRoom.Password.Value = null; + if (!withParticipants) + responseRoom.RecentParticipants.Clear(); + return responseRoom; + } + public new void ClearRooms() => base.ClearRooms(); public new void Schedule(Action action) => base.Schedule(action); diff --git a/osu.Game/Tests/Visual/OnlinePlay/BasicTestRoomManager.cs b/osu.Game/Tests/Visual/OnlinePlay/BasicTestRoomManager.cs index 813e617ac5..d37a64fa4b 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/BasicTestRoomManager.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/BasicTestRoomManager.cs @@ -18,14 +18,12 @@ namespace osu.Game.Tests.Visual.OnlinePlay /// public class BasicTestRoomManager : IRoomManager { - public event Action RoomsUpdated - { - add { } - remove { } - } + public event Action RoomsUpdated; public readonly BindableList Rooms = new BindableList(); + public Action JoinRoomRequested; + public IBindable InitialRoomsReceived { get; } = new Bindable(true); IBindableList IRoomManager.Rooms => Rooms; @@ -33,27 +31,46 @@ namespace osu.Game.Tests.Visual.OnlinePlay public void CreateRoom(Room room, Action onSuccess = null, Action onError = null) { room.RoomID.Value ??= Rooms.Select(r => r.RoomID.Value).Where(id => id != null).Select(id => id.Value).DefaultIfEmpty().Max() + 1; - Rooms.Add(room); onSuccess?.Invoke(room); + + AddRoom(room); } - public void JoinRoom(Room room, Action onSuccess = null, Action onError = null) => onSuccess?.Invoke(room); + public void AddRoom(Room room) + { + Rooms.Add(room); + RoomsUpdated?.Invoke(); + } + + public void RemoveRoom(Room room) + { + Rooms.Remove(room); + RoomsUpdated?.Invoke(); + } + + public void JoinRoom(Room room, string password, Action onSuccess = null, Action onError = null) + { + JoinRoomRequested?.Invoke(room, password); + onSuccess?.Invoke(room); + } public void PartRoom() { } - public void AddRooms(int count, RulesetInfo ruleset = null) + public void AddRooms(int count, RulesetInfo ruleset = null, bool withPassword = false) { for (int i = 0; i < count; i++) { var room = new Room { RoomID = { Value = i }, + Position = { Value = i }, Name = { Value = $"Room {i}" }, Host = { Value = new User { Username = "Host" } }, EndDate = { Value = DateTimeOffset.Now + TimeSpan.FromSeconds(10) }, - Category = { Value = i % 2 == 0 ? RoomCategory.Spotlight : RoomCategory.Normal } + Category = { Value = i % 2 == 0 ? RoomCategory.Spotlight : RoomCategory.Normal }, + Password = { Value = withPassword ? "password" : string.Empty } }; if (ruleset != null) diff --git a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs index 01dd7a25c8..c5e2e67eaf 100644 --- a/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs +++ b/osu.Game/Tests/Visual/OsuManualInputManagerTestScene.cs @@ -3,6 +3,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Testing.Input; using osu.Game.Graphics.Cursor; @@ -34,9 +35,16 @@ namespace osu.Game.Tests.Visual { MenuCursorContainer cursorContainer; - CompositeDrawable mainContent = - (cursorContainer = new MenuCursorContainer { RelativeSizeAxes = Axes.Both }) - .WithChild(content = new OsuTooltipContainer(cursorContainer.Cursor) { RelativeSizeAxes = Axes.Both }); + CompositeDrawable mainContent = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Child = cursorContainer = new MenuCursorContainer { RelativeSizeAxes = Axes.Both, } + }; + + cursorContainer.Child = content = new OsuTooltipContainer(cursorContainer.Cursor) + { + RelativeSizeAxes = Axes.Both + }; if (CreateNestedActionContainer) { diff --git a/osu.Game/Updater/SimpleUpdateManager.cs b/osu.Game/Updater/SimpleUpdateManager.cs index 50572a7867..e0409e34df 100644 --- a/osu.Game/Updater/SimpleUpdateManager.cs +++ b/osu.Game/Updater/SimpleUpdateManager.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Newtonsoft.Json; using osu.Framework; @@ -41,11 +42,16 @@ namespace osu.Game.Updater var latest = releases.ResponseObject; - if (latest.TagName != version) + // avoid any discrepancies due to build suffixes for now. + // eventually we will want to support release streams and consider these. + version = version.Split('-').First(); + var latestTagName = latest.TagName.Split('-').First(); + + if (latestTagName != version) { Notifications.Post(new SimpleNotification { - Text = $"A newer release of osu! has been found ({version} → {latest.TagName}).\n\n" + Text = $"A newer release of osu! has been found ({version} → {latestTagName}).\n\n" + "Click here to download the new version, which can be installed over the top of your existing installation", Icon = FontAwesome.Solid.Upload, Activated = () => diff --git a/osu.Game/Users/Drawables/ClickableAvatar.cs b/osu.Game/Users/Drawables/ClickableAvatar.cs index f73489ac61..c8af8d80e4 100644 --- a/osu.Game/Users/Drawables/ClickableAvatar.cs +++ b/osu.Game/Users/Drawables/ClickableAvatar.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics.Textures; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; namespace osu.Game.Users.Drawables { @@ -71,6 +72,11 @@ namespace osu.Game.Users.Drawables { private LocalisableString tooltip = default_tooltip_text; + public ClickableArea() + : base(HoverSampleSet.Submit) + { + } + public override LocalisableString TooltipText { get => Enabled.Value ? tooltip : default; diff --git a/osu.Game/Users/Drawables/UpdateableFlag.cs b/osu.Game/Users/Drawables/UpdateableFlag.cs index 1d30720889..7db834bf83 100644 --- a/osu.Game/Users/Drawables/UpdateableFlag.cs +++ b/osu.Game/Users/Drawables/UpdateableFlag.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; +using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; namespace osu.Game.Users.Drawables @@ -32,9 +33,17 @@ namespace osu.Game.Users.Drawables if (country == null && !ShowPlaceholderOnNull) return null; - return new DrawableFlag(country) + return new Container { RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new DrawableFlag(country) + { + RelativeSizeAxes = Axes.Both + }, + new HoverClickSounds(HoverSampleSet.Submit) + } }; } 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/Users/UserPanel.cs b/osu.Game/Users/UserPanel.cs index 0981136dba..ff0d03a036 100644 --- a/osu.Game/Users/UserPanel.cs +++ b/osu.Game/Users/UserPanel.cs @@ -31,6 +31,7 @@ namespace osu.Game.Users protected Drawable Background { get; private set; } protected UserPanel(User user) + : base(HoverSampleSet.Submit) { if (user == null) throw new ArgumentNullException(nameof(user)); diff --git a/osu.Game/Users/UserStatistics.cs b/osu.Game/Users/UserStatistics.cs index 5ddcd86d28..449b0aa212 100644 --- a/osu.Game/Users/UserStatistics.cs +++ b/osu.Game/Users/UserStatistics.cs @@ -3,6 +3,7 @@ using System; using Newtonsoft.Json; +using osu.Framework.Localisation; using osu.Game.Scoring; using osu.Game.Utils; using static osu.Game.Users.User; @@ -45,7 +46,7 @@ namespace osu.Game.Users public double Accuracy; [JsonIgnore] - public string DisplayAccuracy => (Accuracy / 100).FormatAccuracy(); + public LocalisableString DisplayAccuracy => (Accuracy / 100).FormatAccuracy(); [JsonProperty(@"play_count")] public int PlayCount; diff --git a/osu.Game/Utils/FormatUtils.cs b/osu.Game/Utils/FormatUtils.cs index df1b6cf00d..e763558647 100644 --- a/osu.Game/Utils/FormatUtils.cs +++ b/osu.Game/Utils/FormatUtils.cs @@ -2,8 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Globalization; using Humanizer; +using osu.Framework.Localisation; namespace osu.Game.Utils { @@ -13,9 +13,8 @@ namespace osu.Game.Utils /// Turns the provided accuracy into a percentage with 2 decimal places. /// /// The accuracy to be formatted. - /// An optional format provider. /// formatted accuracy in percentage - public static string FormatAccuracy(this double accuracy, IFormatProvider formatProvider = null) + public static LocalisableString FormatAccuracy(this double accuracy) { // for the sake of display purposes, we don't want to show a user a "rounded up" percentage to the next whole number. // ie. a score which gets 89.99999% shouldn't ever show as 90%. @@ -23,7 +22,7 @@ namespace osu.Game.Utils // percentile with a non-matching grade is confusing. accuracy = Math.Floor(accuracy * 10000) / 10000; - return accuracy.ToString("0.00%", formatProvider ?? CultureInfo.CurrentCulture); + return accuracy.ToLocalisableString("0.00%"); } /// diff --git a/osu.Game/Utils/IDeepCloneable.cs b/osu.Game/Utils/IDeepCloneable.cs new file mode 100644 index 0000000000..6877f346c4 --- /dev/null +++ b/osu.Game/Utils/IDeepCloneable.cs @@ -0,0 +1,16 @@ +// 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.Utils +{ + /// A generic interface for a deeply cloneable type. + /// The type of object to clone. + public interface IDeepCloneable where T : class + { + /// + /// Creates a new that is a deep copy of the current instance. + /// + /// The . + T DeepClone(); + } +} diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index eb7a0141c7..4b0edf990e 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -22,23 +22,23 @@ - - - - + + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - + + + diff --git a/osu.iOS.props b/osu.iOS.props index 2e5fab758d..e4992e1132 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -70,8 +70,8 @@ - - + + @@ -93,7 +93,7 @@ - + diff --git a/osu.iOS/osu.iOS.csproj b/osu.iOS/osu.iOS.csproj index 1cbe4422cc..1203c3659b 100644 --- a/osu.iOS/osu.iOS.csproj +++ b/osu.iOS/osu.iOS.csproj @@ -117,7 +117,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