diff --git a/osu.Android.props b/osu.Android.props index d64855e5c1..8ebfde8047 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -10,7 +10,7 @@ true - + + + \ No newline at end of file diff --git a/osu.Game.Rulesets.Catch.Tests.Android/AndroidManifest.xml b/osu.Game.Rulesets.Catch.Tests.Android/AndroidManifest.xml index bf7c0bfeca..52b34959b9 100644 --- a/osu.Game.Rulesets.Catch.Tests.Android/AndroidManifest.xml +++ b/osu.Game.Rulesets.Catch.Tests.Android/AndroidManifest.xml @@ -1,6 +1,6 @@  - + \ No newline at end of file diff --git a/osu.Game.Rulesets.Mania.Tests.Android/AndroidManifest.xml b/osu.Game.Rulesets.Mania.Tests.Android/AndroidManifest.xml index 4a1545a423..f5a49210ea 100644 --- a/osu.Game.Rulesets.Mania.Tests.Android/AndroidManifest.xml +++ b/osu.Game.Rulesets.Mania.Tests.Android/AndroidManifest.xml @@ -1,5 +1,5 @@  - + \ No newline at end of file diff --git a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs index 06c825e37d..a24fcaad8d 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs @@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills { private const double individual_decay_base = 0.125; private const double overall_decay_base = 0.30; - private const double release_threshold = 24; + private const double release_threshold = 30; protected override double SkillMultiplier => 1; protected override double StrainDecayBase => 1; @@ -50,10 +50,13 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills for (int i = 0; i < endTimes.Length; ++i) { // The current note is overlapped if a previous note or end is overlapping the current note body - isOverlapping |= Precision.DefinitelyBigger(endTimes[i], startTime, 1) && Precision.DefinitelyBigger(endTime, endTimes[i], 1); + isOverlapping |= Precision.DefinitelyBigger(endTimes[i], startTime, 1) && + Precision.DefinitelyBigger(endTime, endTimes[i], 1) && + Precision.DefinitelyBigger(startTime, startTimes[i], 1); // We give a slight bonus to everything if something is held meanwhile - if (Precision.DefinitelyBigger(endTimes[i], endTime, 1)) + if (Precision.DefinitelyBigger(endTimes[i], endTime, 1) && + Precision.DefinitelyBigger(startTime, startTimes[i], 1)) holdFactor = 1.25; closestEndTime = Math.Min(closestEndTime, Math.Abs(endTime - endTimes[i])); @@ -70,7 +73,7 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills // 0.0 +--------+-+---------------> Release Difference / ms // release_threshold if (isOverlapping) - holdAddition = 1 / (1 + Math.Exp(0.5 * (release_threshold - closestEndTime))); + holdAddition = 1 / (1 + Math.Exp(0.27 * (release_threshold - closestEndTime))); // Decay and increase individualStrains in own column individualStrains[column] = applyDecay(individualStrains[column], startTime - startTimes[column], individual_decay_base); diff --git a/osu.Game.Rulesets.Osu.Tests.Android/AndroidManifest.xml b/osu.Game.Rulesets.Osu.Tests.Android/AndroidManifest.xml index 45d27dda70..ed4725dd94 100644 --- a/osu.Game.Rulesets.Osu.Tests.Android/AndroidManifest.xml +++ b/osu.Game.Rulesets.Osu.Tests.Android/AndroidManifest.xml @@ -1,5 +1,5 @@  - + \ No newline at end of file diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs index 0c064ecfa6..9338d5453d 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs @@ -9,6 +9,7 @@ using NUnit.Framework; using osu.Framework.Allocation; 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.Input; @@ -70,12 +71,12 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor base.Content.Children = new Drawable[] { editorClock = new EditorClock(editorBeatmap), - snapProvider, + new PopoverContainer { Child = snapProvider }, Content }; } - protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; + protected override Container Content { get; } = new PopoverContainer { RelativeSizeAxes = Axes.Both }; [SetUp] public void Setup() => Schedule(() => diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePreciseRotation.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePreciseRotation.cs new file mode 100644 index 0000000000..d7dd30d608 --- /dev/null +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestScenePreciseRotation.cs @@ -0,0 +1,95 @@ +// 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.Testing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Edit; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Screens.Edit.Components.RadioButtons; +using osu.Game.Tests.Beatmaps; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Osu.Tests.Editor +{ + public partial class TestScenePreciseRotation : TestSceneOsuEditor + { + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset); + + [Test] + public void TestHotkeyHandling() + { + AddStep("select single circle", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.OfType().First())); + AddStep("press rotate hotkey", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddUntilStep("no popover present", () => this.ChildrenOfType().Count(), () => Is.Zero); + + AddStep("select first three objects", () => + { + EditorBeatmap.SelectedHitObjects.Clear(); + EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects.Take(3)); + }); + AddStep("press rotate hotkey", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddUntilStep("popover present", () => this.ChildrenOfType().Count(), () => Is.EqualTo(1)); + AddStep("press rotate hotkey", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddUntilStep("no popover present", () => this.ChildrenOfType().Count(), () => Is.Zero); + } + + [Test] + public void TestRotateCorrectness() + { + AddStep("replace objects", () => + { + EditorBeatmap.Clear(); + EditorBeatmap.AddRange(new HitObject[] + { + new HitCircle { Position = new Vector2(100) }, + new HitCircle { Position = new Vector2(200) }, + }); + }); + AddStep("select both circles", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); + AddStep("press rotate hotkey", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.ControlLeft); + }); + AddUntilStep("popover present", getPopover, () => Is.Not.Null); + + AddStep("rotate by 180deg", () => getPopover().ChildrenOfType().Single().Current.Value = "180"); + AddAssert("first object rotated 180deg around playfield centre", + () => EditorBeatmap.HitObjects.OfType().ElementAt(0).Position, + () => Is.EqualTo(OsuPlayfield.BASE_SIZE - new Vector2(100))); + AddAssert("second object rotated 180deg around playfield centre", + () => EditorBeatmap.HitObjects.OfType().ElementAt(1).Position, + () => Is.EqualTo(OsuPlayfield.BASE_SIZE - new Vector2(200))); + + AddStep("change rotation origin", () => getPopover().ChildrenOfType().ElementAt(1).TriggerClick()); + AddAssert("first object rotated 90deg around selection centre", + () => EditorBeatmap.HitObjects.OfType().ElementAt(0).Position, () => Is.EqualTo(new Vector2(200, 200))); + AddAssert("second object rotated 90deg around selection centre", + () => EditorBeatmap.HitObjects.OfType().ElementAt(1).Position, () => Is.EqualTo(new Vector2(100, 100))); + + PreciseRotationPopover? getPopover() => this.ChildrenOfType().SingleOrDefault(); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index 0b80750a02..cff2171cbd 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -85,6 +85,11 @@ namespace osu.Game.Rulesets.Osu.Edit // we may be entering the screen with a selection already active updateDistanceSnapGrid(); + + RightToolbox.Add(new TransformToolboxGroup + { + RotationHandler = BlueprintContainer.SelectionHandler.RotationHandler + }); } protected override ComposeBlueprintContainer CreateBlueprintContainer() diff --git a/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs new file mode 100644 index 0000000000..f09d6b78e6 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/PreciseRotationPopover.cs @@ -0,0 +1,107 @@ +// 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.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Screens.Edit.Components.RadioButtons; +using osu.Game.Screens.Edit.Compose.Components; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public partial class PreciseRotationPopover : OsuPopover + { + private readonly SelectionRotationHandler rotationHandler; + + private readonly Bindable rotationInfo = new Bindable(new PreciseRotationInfo(0, RotationOrigin.PlayfieldCentre)); + + private SliderWithTextBoxInput angleInput = null!; + private EditorRadioButtonCollection rotationOrigin = null!; + + public PreciseRotationPopover(SelectionRotationHandler rotationHandler) + { + this.rotationHandler = rotationHandler; + + AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight }; + } + + [BackgroundDependencyLoader] + private void load() + { + Child = new FillFlowContainer + { + Width = 220, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(20), + Children = new Drawable[] + { + angleInput = new SliderWithTextBoxInput("Angle (degrees):") + { + Current = new BindableNumber + { + MinValue = -360, + MaxValue = 360, + Precision = 1 + }, + Instantaneous = true + }, + rotationOrigin = new EditorRadioButtonCollection + { + RelativeSizeAxes = Axes.X, + Items = new[] + { + new RadioButton("Playfield centre", + () => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.PlayfieldCentre }, + () => new SpriteIcon { Icon = FontAwesome.Regular.Square }), + new RadioButton("Selection centre", + () => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.SelectionCentre }, + () => new SpriteIcon { Icon = FontAwesome.Solid.VectorSquare }) + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ScheduleAfterChildren(() => angleInput.TakeFocus()); + angleInput.Current.BindValueChanged(angle => rotationInfo.Value = rotationInfo.Value with { Degrees = angle.NewValue }); + rotationOrigin.Items.First().Select(); + + rotationInfo.BindValueChanged(rotation => + { + rotationHandler.Update(rotation.NewValue.Degrees, rotation.NewValue.Origin == RotationOrigin.PlayfieldCentre ? OsuPlayfield.BASE_SIZE / 2 : null); + }); + } + + protected override void PopIn() + { + base.PopIn(); + rotationHandler.Begin(); + } + + protected override void PopOut() + { + base.PopOut(); + + if (IsLoaded) + rotationHandler.Commit(); + } + } + + public enum RotationOrigin + { + PlayfieldCentre, + SelectionCentre + } + + public record PreciseRotationInfo(float Degrees, RotationOrigin Origin); +} diff --git a/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs b/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs new file mode 100644 index 0000000000..3da9f5b69b --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/TransformToolboxGroup.cs @@ -0,0 +1,80 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Input.Bindings; +using osu.Game.Rulesets.Edit; +using osu.Game.Screens.Edit.Components; +using osu.Game.Screens.Edit.Compose.Components; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public partial class TransformToolboxGroup : EditorToolboxGroup, IKeyBindingHandler + { + private readonly Bindable canRotate = new BindableBool(); + + private EditorToolButton rotateButton = null!; + + public SelectionRotationHandler RotationHandler { get; init; } = null!; + + public TransformToolboxGroup() + : base("transform") + { + } + + [BackgroundDependencyLoader] + private void load() + { + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(5), + Children = new Drawable[] + { + rotateButton = new EditorToolButton("Rotate", + () => new SpriteIcon { Icon = FontAwesome.Solid.Undo }, + () => new PreciseRotationPopover(RotationHandler)), + // TODO: scale + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + // bindings to `Enabled` on the buttons are decoupled on purpose + // due to the weird `OsuButton` behaviour of resetting `Enabled` to `false` when `Action` is set. + canRotate.BindTo(RotationHandler.CanRotate); + canRotate.BindValueChanged(_ => rotateButton.Enabled.Value = canRotate.Value, true); + } + + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Repeat) return false; + + switch (e.Action) + { + case GlobalAction.EditorToggleRotateControl: + { + rotateButton.TriggerClick(); + return true; + } + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } +} diff --git a/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs b/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs index 9d64c354e2..d818c8baee 100644 --- a/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs +++ b/osu.Game.Rulesets.Osu/Skinning/SmokeSegment.cs @@ -257,7 +257,7 @@ namespace osu.Game.Rulesets.Osu.Skinning texture.Bind(); for (int i = 0; i < points.Count; i++) - drawPointQuad(points[i], textureRect, i + firstVisiblePointIndex); + drawPointQuad(renderer, points[i], textureRect, i + firstVisiblePointIndex); UnbindTextureShader(renderer); renderer.PopLocalMatrix(); @@ -325,7 +325,7 @@ namespace osu.Game.Rulesets.Osu.Skinning private float getRotation(int index) => max_rotation * (StatelessRNG.NextSingle(rotationSeed, index) * 2 - 1); - private void drawPointQuad(SmokePoint point, RectangleF textureRect, int index) + private void drawPointQuad(IRenderer renderer, SmokePoint point, RectangleF textureRect, int index) { Debug.Assert(quadBatch != null); @@ -347,25 +347,25 @@ namespace osu.Game.Rulesets.Osu.Skinning var localBotLeft = point.Position + ortho - dir; var localBotRight = point.Position + ortho + dir; - quadBatch.Add(new TexturedVertex2D + quadBatch.Add(new TexturedVertex2D(renderer) { Position = localTopLeft, TexturePosition = textureRect.TopLeft, Colour = Color4Extensions.Multiply(ColourAtPosition(localTopLeft), colour), }); - quadBatch.Add(new TexturedVertex2D + quadBatch.Add(new TexturedVertex2D(renderer) { Position = localTopRight, TexturePosition = textureRect.TopRight, Colour = Color4Extensions.Multiply(ColourAtPosition(localTopRight), colour), }); - quadBatch.Add(new TexturedVertex2D + quadBatch.Add(new TexturedVertex2D(renderer) { Position = localBotRight, TexturePosition = textureRect.BottomRight, Colour = Color4Extensions.Multiply(ColourAtPosition(localBotRight), colour), }); - quadBatch.Add(new TexturedVertex2D + quadBatch.Add(new TexturedVertex2D(renderer) { Position = localBotLeft, TexturePosition = textureRect.BottomLeft, diff --git a/osu.Game.Rulesets.Taiko.Tests.Android/AndroidManifest.xml b/osu.Game.Rulesets.Taiko.Tests.Android/AndroidManifest.xml index 452b9683ec..cc88d3080a 100644 --- a/osu.Game.Rulesets.Taiko.Tests.Android/AndroidManifest.xml +++ b/osu.Game.Rulesets.Taiko.Tests.Android/AndroidManifest.xml @@ -1,5 +1,5 @@  - + \ No newline at end of file diff --git a/osu.Game.Tests.Android/AndroidManifest.xml b/osu.Game.Tests.Android/AndroidManifest.xml index f25b2e5328..6f91fb928c 100644 --- a/osu.Game.Tests.Android/AndroidManifest.xml +++ b/osu.Game.Tests.Android/AndroidManifest.xml @@ -1,5 +1,5 @@  - + \ No newline at end of file diff --git a/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs b/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs index b237556d11..0144c0bf97 100644 --- a/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs +++ b/osu.Game.Tests/Database/LegacyBeatmapImporterTest.cs @@ -1,19 +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 System; using System.Collections.Generic; using System.IO; +using System.IO.Compression; using System.Linq; using NUnit.Framework; using osu.Framework.Platform; using osu.Framework.Testing; +using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.IO; +using osu.Game.Tests.Resources; namespace osu.Game.Tests.Database { [TestFixture] - public class LegacyBeatmapImporterTest + public class LegacyBeatmapImporterTest : RealmTest { private readonly TestLegacyBeatmapImporter importer = new TestLegacyBeatmapImporter(); @@ -60,6 +64,33 @@ namespace osu.Game.Tests.Database } } + [Test] + public void TestStableDateAddedApplied() + { + RunTestWithRealmAsync(async (realm, storage) => + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + using (var tmpStorage = new TemporaryNativeStorage("stable-songs-folder")) + { + var stableStorage = new StableStorage(tmpStorage.GetFullPath(""), host); + var songsStorage = stableStorage.GetStorageForDirectory(StableStorage.STABLE_DEFAULT_SONGS_PATH); + + ZipFile.ExtractToDirectory(TestResources.GetQuickTestBeatmapForImport(), songsStorage.GetFullPath("renatus")); + + string[] beatmaps = Directory.GetFiles(songsStorage.GetFullPath("renatus"), "*.osu", SearchOption.TopDirectoryOnly); + + File.SetLastWriteTimeUtc(beatmaps[beatmaps.Length / 2], new DateTime(2000, 1, 1, 12, 0, 0)); + + await new LegacyBeatmapImporter(new BeatmapImporter(storage, realm)).ImportFromStableAsync(stableStorage); + + var importedSet = realm.Realm.All().Single(); + + Assert.NotNull(importedSet); + Assert.AreEqual(new DateTimeOffset(new DateTime(2000, 1, 1, 12, 0, 0, DateTimeKind.Utc)), importedSet.DateAdded); + } + }); + } + private class TestLegacyBeatmapImporter : LegacyBeatmapImporter { public TestLegacyBeatmapImporter() diff --git a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs index 6399507aa0..e30caac95e 100644 --- a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs +++ b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs @@ -6,6 +6,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; @@ -29,7 +30,7 @@ namespace osu.Game.Tests.Editing [Cached(typeof(IBeatSnapProvider))] private readonly EditorBeatmap editorBeatmap; - protected override Container Content { get; } = new Container { RelativeSizeAxes = Axes.Both }; + protected override Container Content { get; } = new PopoverContainer { RelativeSizeAxes = Axes.Both }; public TestSceneHitObjectComposerDistanceSnapping() { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs index a38c481003..ed3bffe5c2 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectComposer.cs @@ -8,7 +8,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; @@ -178,7 +178,7 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("distance spacing increased by 0.5", () => editorBeatmap.BeatmapInfo.DistanceSpacing == originalSpacing + 0.5); } - public partial class EditorBeatmapContainer : Container + public partial class EditorBeatmapContainer : PopoverContainer { private readonly IWorkingBeatmap working; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.cs new file mode 100644 index 0000000000..d23fcebae3 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSliderWithTextBoxInput.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.Linq; +using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneSliderWithTextBoxInput : OsuManualInputManagerTestScene + { + private SliderWithTextBoxInput sliderWithTextBoxInput = null!; + + private OsuSliderBar slider => sliderWithTextBoxInput.ChildrenOfType>().Single(); + private Nub nub => sliderWithTextBoxInput.ChildrenOfType().Single(); + private OsuTextBox textBox => sliderWithTextBoxInput.ChildrenOfType().Single(); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create slider", () => Child = sliderWithTextBoxInput = new SliderWithTextBoxInput("Test Slider") + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f, + Current = new BindableFloat + { + MinValue = -5, + MaxValue = 5, + Precision = 0.2f + } + }); + } + + [Test] + public void TestNonInstantaneousMode() + { + AddStep("set instantaneous to false", () => sliderWithTextBoxInput.Instantaneous = false); + + AddStep("focus textbox", () => InputManager.ChangeFocus(textBox)); + AddStep("change text", () => textBox.Text = "3"); + AddAssert("slider not moved", () => slider.Current.Value, () => Is.Zero); + AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.Zero); + + AddStep("commit text", () => InputManager.Key(Key.Enter)); + AddAssert("slider moved", () => slider.Current.Value, () => Is.EqualTo(3)); + AddAssert("current changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(3)); + + AddStep("move mouse to nub", () => InputManager.MoveMouseTo(nub)); + AddStep("hold left mouse", () => InputManager.PressButton(MouseButton.Left)); + AddStep("move mouse to minimum", () => InputManager.MoveMouseTo(sliderWithTextBoxInput.ScreenSpaceDrawQuad.BottomLeft)); + AddAssert("textbox not changed", () => textBox.Current.Value, () => Is.EqualTo("3")); + AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(3)); + + AddStep("release left mouse", () => InputManager.ReleaseButton(MouseButton.Left)); + AddAssert("textbox changed", () => textBox.Current.Value, () => Is.EqualTo("-5")); + AddAssert("current changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); + + AddStep("focus textbox", () => InputManager.ChangeFocus(textBox)); + AddStep("set text to invalid", () => textBox.Text = "garbage"); + AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); + AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); + + AddStep("commit text", () => InputManager.Key(Key.Enter)); + AddAssert("text restored", () => textBox.Text, () => Is.EqualTo("-5")); + AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); + AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); + + AddStep("focus textbox", () => InputManager.ChangeFocus(textBox)); + AddStep("set text to invalid", () => textBox.Text = "garbage"); + AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); + AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); + + AddStep("lose focus", () => InputManager.ChangeFocus(null)); + AddAssert("text restored", () => textBox.Text, () => Is.EqualTo("-5")); + AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); + AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); + } + + [Test] + public void TestInstantaneousMode() + { + AddStep("set instantaneous to true", () => sliderWithTextBoxInput.Instantaneous = true); + + AddStep("focus textbox", () => InputManager.ChangeFocus(textBox)); + AddStep("change text", () => textBox.Text = "3"); + AddAssert("slider moved", () => slider.Current.Value, () => Is.EqualTo(3)); + AddAssert("current changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(3)); + + AddStep("commit text", () => InputManager.Key(Key.Enter)); + AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(3)); + AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(3)); + + AddStep("move mouse to nub", () => InputManager.MoveMouseTo(nub)); + AddStep("hold left mouse", () => InputManager.PressButton(MouseButton.Left)); + AddStep("move mouse to minimum", () => InputManager.MoveMouseTo(sliderWithTextBoxInput.ScreenSpaceDrawQuad.BottomLeft)); + AddAssert("textbox changed", () => textBox.Current.Value, () => Is.EqualTo("-5")); + AddAssert("current changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); + + AddStep("release left mouse", () => InputManager.ReleaseButton(MouseButton.Left)); + AddAssert("textbox not changed", () => textBox.Current.Value, () => Is.EqualTo("-5")); + AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); + + AddStep("focus textbox", () => InputManager.ChangeFocus(textBox)); + AddStep("set text to invalid", () => textBox.Text = "garbage"); + AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); + AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); + + AddStep("commit text", () => InputManager.Key(Key.Enter)); + AddAssert("text restored", () => textBox.Text, () => Is.EqualTo("-5")); + AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); + AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); + + AddStep("focus textbox", () => InputManager.ChangeFocus(textBox)); + AddStep("set text to invalid", () => textBox.Text = "garbage"); + AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); + AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); + + AddStep("lose focus", () => InputManager.ChangeFocus(null)); + AddAssert("text restored", () => textBox.Text, () => Is.EqualTo("-5")); + AddAssert("slider not moved", () => slider.Current.Value, () => Is.EqualTo(-5)); + AddAssert("current not changed", () => sliderWithTextBoxInput.Current.Value, () => Is.EqualTo(-5)); + } + } +} diff --git a/osu.Game.Tournament/Components/SongBar.cs b/osu.Game.Tournament/Components/SongBar.cs index fa0cbda16b..3d060600f7 100644 --- a/osu.Game.Tournament/Components/SongBar.cs +++ b/osu.Game.Tournament/Components/SongBar.cs @@ -14,7 +14,6 @@ using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Rulesets; using osu.Game.Screens.Menu; -using osu.Game.Tournament.Models; using osuTK; using osuTK.Graphics; @@ -22,14 +21,14 @@ namespace osu.Game.Tournament.Components { public partial class SongBar : CompositeDrawable { - private TournamentBeatmap? beatmap; + private IBeatmapInfo? beatmap; public const float HEIGHT = 145 / 2f; [Resolved] private IBindable ruleset { get; set; } = null!; - public TournamentBeatmap? Beatmap + public IBeatmapInfo? Beatmap { set { @@ -37,7 +36,7 @@ namespace osu.Game.Tournament.Components return; beatmap = value; - update(); + refreshContent(); } } @@ -49,7 +48,7 @@ namespace osu.Game.Tournament.Components set { mods = value; - update(); + refreshContent(); } } @@ -71,19 +70,25 @@ namespace osu.Game.Tournament.Components protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false; [BackgroundDependencyLoader] - private void load() + private void load(OsuColour colours) { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; + Masking = true; + CornerRadius = 5; + InternalChildren = new Drawable[] { + new Box + { + Colour = colours.Gray3, + RelativeSizeAxes = Axes.Both, + }, flow = new FillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - LayoutDuration = 500, - LayoutEasing = Easing.OutQuint, Direction = FillDirection.Full, Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, @@ -93,7 +98,7 @@ namespace osu.Game.Tournament.Components Expanded = true; } - private void update() + private void refreshContent() { if (beatmap == null) { @@ -229,7 +234,7 @@ namespace osu.Game.Tournament.Components } } }, - new TournamentBeatmapPanel(beatmap) + new UnmaskedTournamentBeatmapPanel(beatmap) { RelativeSizeAxes = Axes.X, Width = 0.5f, @@ -272,4 +277,18 @@ namespace osu.Game.Tournament.Components } } } + + internal partial class UnmaskedTournamentBeatmapPanel : TournamentBeatmapPanel + { + public UnmaskedTournamentBeatmapPanel(IBeatmapInfo? beatmap, string mod = "") + : base(beatmap, mod) + { + } + + [BackgroundDependencyLoader] + private void load() + { + Masking = false; + } + } } diff --git a/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs index ba922c7c7b..4e0adb30ac 100644 --- a/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs +++ b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs @@ -20,7 +20,7 @@ namespace osu.Game.Tournament.Components { public partial class TournamentBeatmapPanel : CompositeDrawable { - public readonly TournamentBeatmap? Beatmap; + public readonly IBeatmapInfo? Beatmap; private readonly string mod; @@ -30,7 +30,7 @@ namespace osu.Game.Tournament.Components private Box flash = null!; - public TournamentBeatmapPanel(TournamentBeatmap? beatmap, string mod = "") + public TournamentBeatmapPanel(IBeatmapInfo? beatmap, string mod = "") { Beatmap = beatmap; this.mod = mod; @@ -58,7 +58,7 @@ namespace osu.Game.Tournament.Components { RelativeSizeAxes = Axes.Both, Colour = OsuColour.Gray(0.5f), - OnlineInfo = Beatmap, + OnlineInfo = (Beatmap as IBeatmapSetOnlineInfo), }, new FillFlowContainer { diff --git a/osu.Game.Tournament/IPC/MatchIPCInfo.cs b/osu.Game.Tournament/IPC/MatchIPCInfo.cs index f57518971f..b4575144e7 100644 --- a/osu.Game.Tournament/IPC/MatchIPCInfo.cs +++ b/osu.Game.Tournament/IPC/MatchIPCInfo.cs @@ -14,7 +14,7 @@ namespace osu.Game.Tournament.IPC public Bindable Mods { get; } = new Bindable(); public Bindable State { get; } = new Bindable(); public Bindable ChatChannel { get; } = new Bindable(); - public BindableInt Score1 { get; } = new BindableInt(); - public BindableInt Score2 { get; } = new BindableInt(); + public BindableLong Score1 { get; } = new BindableLong(); + public BindableLong Score2 { get; } = new BindableLong(); } } diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs index 7ae20acc77..f8de34a511 100644 --- a/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs +++ b/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs @@ -1,181 +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 osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics.UserInterface; +using osu.Game.Screens.Play.HUD; using osu.Game.Tournament.IPC; -using osuTK; namespace osu.Game.Tournament.Screens.Gameplay.Components { - // TODO: Update to derive from osu-side class? - public partial class TournamentMatchScoreDisplay : CompositeDrawable + public partial class TournamentMatchScoreDisplay : MatchScoreDisplay { - private const float bar_height = 18; - - private readonly BindableInt score1 = new BindableInt(); - private readonly BindableInt score2 = new BindableInt(); - - private readonly MatchScoreCounter score1Text; - private readonly MatchScoreCounter score2Text; - - private readonly MatchScoreDiffCounter scoreDiffText; - - private readonly Drawable score1Bar; - private readonly Drawable score2Bar; - - public TournamentMatchScoreDisplay() - { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - - InternalChildren = new[] - { - new Box - { - Name = "top bar red (static)", - RelativeSizeAxes = Axes.X, - Height = bar_height / 4, - Width = 0.5f, - Colour = TournamentGame.COLOUR_RED, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopRight - }, - new Box - { - Name = "top bar blue (static)", - RelativeSizeAxes = Axes.X, - Height = bar_height / 4, - Width = 0.5f, - Colour = TournamentGame.COLOUR_BLUE, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopLeft - }, - score1Bar = new Box - { - Name = "top bar red", - RelativeSizeAxes = Axes.X, - Height = bar_height, - Width = 0, - Colour = TournamentGame.COLOUR_RED, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopRight - }, - score1Text = new MatchScoreCounter - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre - }, - score2Bar = new Box - { - Name = "top bar blue", - RelativeSizeAxes = Axes.X, - Height = bar_height, - Width = 0, - Colour = TournamentGame.COLOUR_BLUE, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopLeft - }, - score2Text = new MatchScoreCounter - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre - }, - scoreDiffText = new MatchScoreDiffCounter - { - Anchor = Anchor.TopCentre, - Margin = new MarginPadding - { - Top = bar_height / 4, - Horizontal = 8 - }, - Alpha = 0 - } - }; - } - [BackgroundDependencyLoader] private void load(MatchIPCInfo ipc) { - score1.BindValueChanged(_ => updateScores()); - score1.BindTo(ipc.Score1); - - score2.BindValueChanged(_ => updateScores()); - score2.BindTo(ipc.Score2); - } - - private void updateScores() - { - score1Text.Current.Value = score1.Value; - score2Text.Current.Value = score2.Value; - - var winningText = score1.Value > score2.Value ? score1Text : score2Text; - var losingText = score1.Value <= score2.Value ? score1Text : score2Text; - - winningText.Winning = true; - losingText.Winning = false; - - var winningBar = score1.Value > score2.Value ? score1Bar : score2Bar; - var losingBar = score1.Value <= score2.Value ? score1Bar : score2Bar; - - int diff = Math.Max(score1.Value, score2.Value) - Math.Min(score1.Value, score2.Value); - - losingBar.ResizeWidthTo(0, 400, Easing.OutQuint); - winningBar.ResizeWidthTo(Math.Min(0.4f, MathF.Pow(diff / 1500000f, 0.5f) / 2), 400, Easing.OutQuint); - - scoreDiffText.Alpha = diff != 0 ? 1 : 0; - scoreDiffText.Current.Value = -diff; - scoreDiffText.Origin = score1.Value > score2.Value ? Anchor.TopLeft : Anchor.TopRight; - } - - protected override void UpdateAfterChildren() - { - base.UpdateAfterChildren(); - score1Text.X = -Math.Max(5 + score1Text.DrawWidth / 2, score1Bar.DrawWidth); - score2Text.X = Math.Max(5 + score2Text.DrawWidth / 2, score2Bar.DrawWidth); - } - - private partial class MatchScoreCounter : CommaSeparatedScoreCounter - { - private OsuSpriteText displayedSpriteText = null!; - - public MatchScoreCounter() - { - Margin = new MarginPadding { Top = bar_height, Horizontal = 10 }; - } - - public bool Winning - { - set => updateFont(value); - } - - protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(s => - { - displayedSpriteText = s; - displayedSpriteText.Spacing = new Vector2(-6); - updateFont(false); - }); - - private void updateFont(bool winning) - => displayedSpriteText.Font = winning - ? OsuFont.Torus.With(weight: FontWeight.Bold, size: 50, fixedWidth: true) - : OsuFont.Torus.With(weight: FontWeight.Regular, size: 40, fixedWidth: true); - } - - private partial class MatchScoreDiffCounter : CommaSeparatedScoreCounter - { - protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(s => - { - s.Spacing = new Vector2(-2); - s.Font = OsuFont.Torus.With(weight: FontWeight.Regular, size: bar_height, fixedWidth: true); - }); + Team1Score.BindTo(ipc.Score1); + Team2Score.BindTo(ipc.Score2); } } } diff --git a/osu.Game/Beatmaps/BeatmapImporter.cs b/osu.Game/Beatmaps/BeatmapImporter.cs index c840b4fa94..14719da1bc 100644 --- a/osu.Game/Beatmaps/BeatmapImporter.cs +++ b/osu.Game/Beatmaps/BeatmapImporter.cs @@ -152,6 +152,8 @@ namespace osu.Game.Beatmaps if (archive != null) beatmapSet.Beatmaps.AddRange(createBeatmapDifficulties(beatmapSet, realm)); + beatmapSet.DateAdded = getDateAdded(archive); + foreach (BeatmapInfo b in beatmapSet.Beatmaps) { b.BeatmapSet = beatmapSet; @@ -305,11 +307,36 @@ namespace osu.Game.Beatmaps return new BeatmapSetInfo { OnlineID = beatmap.BeatmapInfo.BeatmapSet?.OnlineID ?? -1, - // Metadata = beatmap.Metadata, - DateAdded = DateTimeOffset.UtcNow }; } + /// + /// Determine the date a given beatmapset has been added to the game. + /// For legacy imports, we can use the oldest file write time for any `.osu` file in the directory. + /// For any other import types, use "now". + /// + private DateTimeOffset getDateAdded(ArchiveReader? reader) + { + DateTimeOffset dateAdded = DateTimeOffset.UtcNow; + + if (reader is LegacyDirectoryArchiveReader legacyReader) + { + var beatmaps = reader.Filenames.Where(f => f.EndsWith(".osu", StringComparison.OrdinalIgnoreCase)); + + dateAdded = File.GetLastWriteTimeUtc(legacyReader.GetFullPath(beatmaps.First())); + + foreach (string beatmapName in beatmaps) + { + var currentDateAdded = File.GetLastWriteTimeUtc(legacyReader.GetFullPath(beatmapName)); + + if (currentDateAdded < dateAdded) + dateAdded = currentDateAdded; + } + } + + return dateAdded; + } + /// /// Create all required s for the provided archive. /// diff --git a/osu.Game/Database/LegacyBeatmapExporter.cs b/osu.Game/Database/LegacyBeatmapExporter.cs index a874353f73..ece705f685 100644 --- a/osu.Game/Database/LegacyBeatmapExporter.cs +++ b/osu.Game/Database/LegacyBeatmapExporter.cs @@ -70,7 +70,22 @@ namespace osu.Game.Database hitObject.StartTime = Math.Floor(hitObject.StartTime); - if (hitObject is not IHasPath hasPath || BezierConverter.CountSegments(hasPath.Path.ControlPoints) <= 1) continue; + if (hitObject is not IHasPath hasPath) continue; + + // stable's hit object parsing expects the entire slider to use only one type of curve, + // and happens to use the last non-empty curve type read for the entire slider. + // this clear of the last control point type handles an edge case + // wherein the last control point of an otherwise-single-segment slider path has a different type than previous, + // which would lead to sliders being mangled when exported back to stable. + // normally, that would be handled by the `BezierConverter.ConvertToModernBezier()` call below, + // which outputs a slider path containing only Bezier control points, + // but a non-inherited last control point is (rightly) not considered to be starting a new segment, + // therefore it would fail to clear the `CountSegments() <= 1` check. + // by clearing explicitly we both fix the issue and avoid unnecessary conversions to Bezier. + if (hasPath.Path.ControlPoints.Count > 1) + hasPath.Path.ControlPoints[^1].Type = null; + + if (BezierConverter.CountSegments(hasPath.Path.ControlPoints) <= 1) continue; var newControlPoints = BezierConverter.ConvertToModernBezier(hasPath.Path.ControlPoints); diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs index 454be02d0b..8b9d35e343 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledTextBox.cs @@ -35,6 +35,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 public string Text { + get => Component.Text; set => Component.Text = value; } diff --git a/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs new file mode 100644 index 0000000000..37ea2a3f96 --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/SliderWithTextBoxInput.cs @@ -0,0 +1,145 @@ +// 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.Globalization; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; +using osu.Game.Overlays.Settings; +using osu.Game.Utils; +using osuTK; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public partial class SliderWithTextBoxInput : CompositeDrawable, IHasCurrentValue + where T : struct, IEquatable, IComparable, IConvertible + { + /// + /// A custom step value for each key press which actuates a change on this control. + /// + public float KeyboardStep + { + get => slider.KeyboardStep; + set => slider.KeyboardStep = value; + } + + public Bindable Current + { + get => slider.Current; + set => slider.Current = value; + } + + private bool instantaneous; + + /// + /// Whether changes to the slider should instantaneously transfer to the text box (and vice versa). + /// If , the transfer will happen on text box commit (explicit, or implicit via focus loss), or on slider drag end. + /// + public bool Instantaneous + { + get => instantaneous; + set + { + instantaneous = value; + slider.TransferValueOnCommit = !instantaneous; + } + } + + private readonly SettingsSlider slider; + private readonly LabelledTextBox textBox; + + public SliderWithTextBoxInput(LocalisableString labelText) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(20), + Children = new Drawable[] + { + textBox = new LabelledTextBox + { + Label = labelText, + }, + slider = new SettingsSlider + { + TransferValueOnCommit = true, + RelativeSizeAxes = Axes.X, + } + } + }, + }; + + textBox.OnCommit += textCommitted; + textBox.Current.BindValueChanged(textChanged); + + Current.BindValueChanged(updateTextBoxFromSlider, true); + } + + public bool TakeFocus() => GetContainingInputManager().ChangeFocus(textBox); + + private bool updatingFromTextBox; + + private void textChanged(ValueChangedEvent change) + { + if (!instantaneous) return; + + tryUpdateSliderFromTextBox(); + } + + private void textCommitted(TextBox t, bool isNew) + { + tryUpdateSliderFromTextBox(); + + // If the attempted update above failed, restore text box to match the slider. + Current.TriggerChange(); + } + + private void tryUpdateSliderFromTextBox() + { + updatingFromTextBox = true; + + try + { + switch (slider.Current) + { + case Bindable bindableInt: + bindableInt.Value = int.Parse(textBox.Current.Value); + break; + + case Bindable bindableDouble: + bindableDouble.Value = double.Parse(textBox.Current.Value); + break; + + default: + slider.Current.Parse(textBox.Current.Value); + break; + } + } + catch + { + // ignore parsing failures. + // sane state will eventually be restored by a commit (either explicit, or implicit via focus loss). + } + + updatingFromTextBox = false; + } + + private void updateTextBoxFromSlider(ValueChangedEvent _) + { + if (updatingFromTextBox) return; + + decimal decimalValue = slider.Current.Value.ToDecimal(NumberFormatInfo.InvariantInfo); + textBox.Text = decimalValue.ToString($@"N{FormatUtils.FindPrecision(decimalValue)}"); + } + } +} diff --git a/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs b/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs index dfae58aed7..1503705022 100644 --- a/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs +++ b/osu.Game/IO/Archives/LegacyDirectoryArchiveReader.cs @@ -21,7 +21,9 @@ namespace osu.Game.IO.Archives this.path = Path.GetFullPath(path); } - public override Stream GetStream(string name) => File.OpenRead(Path.Combine(path, name)); + public override Stream GetStream(string name) => File.OpenRead(GetFullPath(name)); + + public string GetFullPath(string filename) => Path.Combine(path, filename); public override void Dispose() { diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index 1090eeb462..9a0a2d5c15 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -105,6 +105,7 @@ namespace osu.Game.Input.Bindings // See https://github.com/ppy/osu-framework/blob/master/osu.Framework/Input/StateChanges/MouseScrollRelativeInput.cs#L37-L38. new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.MouseWheelRight }, GlobalAction.EditorCyclePreviousBeatSnapDivisor), new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.MouseWheelLeft }, GlobalAction.EditorCycleNextBeatSnapDivisor), + new KeyBinding(new[] { InputKey.Control, InputKey.R }, GlobalAction.EditorToggleRotateControl), }; public IEnumerable InGameKeyBindings => new[] @@ -378,5 +379,8 @@ namespace osu.Game.Input.Bindings [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleInGameLeaderboard))] ToggleInGameLeaderboard, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorToggleRotateControl))] + EditorToggleRotateControl, } } diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs index ceefc27968..8356c480dd 100644 --- a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs +++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs @@ -344,6 +344,11 @@ namespace osu.Game.Localisation /// public static LocalisableString ExportReplay => new TranslatableString(getKey(@"export_replay"), @"Export replay"); + /// + /// "Toggle rotate control" + /// + public static LocalisableString EditorToggleRotateControl => new TranslatableString(getKey(@"editor_toggle_rotate_control"), @"Toggle rotate control"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Screens/Edit/Components/EditorToolButton.cs b/osu.Game/Screens/Edit/Components/EditorToolButton.cs new file mode 100644 index 0000000000..6550362687 --- /dev/null +++ b/osu.Game/Screens/Edit/Components/EditorToolButton.cs @@ -0,0 +1,107 @@ +// 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; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.Edit.Components +{ + public partial class EditorToolButton : OsuButton, IHasPopover + { + public BindableBool Selected { get; } = new BindableBool(); + + private readonly Func createIcon; + private readonly Func createPopover; + + private Color4 defaultBackgroundColour; + private Color4 defaultIconColour; + private Color4 selectedBackgroundColour; + private Color4 selectedIconColour; + + private Drawable icon = null!; + + public EditorToolButton(LocalisableString text, Func createIcon, Func createPopover) + { + Text = text; + this.createIcon = createIcon; + this.createPopover = createPopover; + + RelativeSizeAxes = Axes.X; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + defaultBackgroundColour = colourProvider.Background3; + selectedBackgroundColour = colourProvider.Background1; + + defaultIconColour = defaultBackgroundColour.Darken(0.5f); + selectedIconColour = selectedBackgroundColour.Lighten(0.5f); + + Add(icon = createIcon().With(b => + { + b.Blending = BlendingParameters.Additive; + b.Anchor = Anchor.CentreLeft; + b.Origin = Anchor.CentreLeft; + b.Size = new Vector2(20); + b.X = 10; + })); + + Action = Selected.Toggle; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Selected.BindValueChanged(_ => updateSelectionState(), true); + } + + private void updateSelectionState() + { + if (!IsLoaded) + return; + + BackgroundColour = Selected.Value ? selectedBackgroundColour : defaultBackgroundColour; + icon.Colour = Selected.Value ? selectedIconColour : defaultIconColour; + + if (Selected.Value) + this.ShowPopover(); + else + this.HidePopover(); + } + + protected override SpriteText CreateText() => new OsuSpriteText + { + Depth = -1, + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + X = 40f + }; + + public Popover? GetPopover() => Enabled.Value + ? createPopover()?.With(p => + { + p.State.BindValueChanged(state => + { + if (state.NewValue == Visibility.Hidden) + Selected.Value = false; + }); + }) + : null; + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index 1de6c8364c..110beb0fa6 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -34,7 +34,7 @@ namespace osu.Game.Screens.Edit.Compose.Components public Container> SelectionBlueprints { get; private set; } - protected SelectionHandler SelectionHandler { get; private set; } + public SelectionHandler SelectionHandler { get; private set; } private readonly Dictionary> blueprintMap = new Dictionary>(); diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index 158b4066bc..3c859c65ff 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -55,7 +55,7 @@ namespace osu.Game.Screens.Edit.Compose.Components [Resolved(CanBeNull = true)] protected IEditorChangeHandler ChangeHandler { get; private set; } - protected SelectionRotationHandler RotationHandler { get; private set; } + public SelectionRotationHandler RotationHandler { get; private set; } protected SelectionHandler() { diff --git a/osu.Game/Screens/Edit/Timing/ControlPointList.cs b/osu.Game/Screens/Edit/Timing/ControlPointList.cs index 555c36aac0..22e37b9efb 100644 --- a/osu.Game/Screens/Edit/Timing/ControlPointList.cs +++ b/osu.Game/Screens/Edit/Timing/ControlPointList.cs @@ -147,13 +147,25 @@ namespace osu.Game.Screens.Edit.Timing trackedType = null; else { - // If the selected group only has one control point, update the tracking type. - if (selectedGroup.Value.ControlPoints.Count == 1) - trackedType = selectedGroup.Value?.ControlPoints.Single().GetType(); - // If the selected group has more than one control point, choose the first as the tracking type - // if we don't already have a singular tracked type. - else if (trackedType == null) - trackedType = selectedGroup.Value?.ControlPoints.FirstOrDefault()?.GetType(); + switch (selectedGroup.Value.ControlPoints.Count) + { + // If the selected group has no control points, clear the tracked type. + // Otherwise the user will be unable to select a group with no control points. + case 0: + trackedType = null; + break; + + // If the selected group only has one control point, update the tracking type. + case 1: + trackedType = selectedGroup.Value?.ControlPoints.Single().GetType(); + break; + + // If the selected group has more than one control point, choose the first as the tracking type + // if we don't already have a singular tracked type. + default: + trackedType ??= selectedGroup.Value?.ControlPoints.FirstOrDefault()?.GetType(); + break; + } } if (trackedType != null) diff --git a/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs b/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs deleted file mode 100644 index 1bf0e5299d..0000000000 --- a/osu.Game/Screens/Edit/Timing/SliderWithTextBoxInput.cs +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Globalization; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.UserInterface; -using osu.Framework.Localisation; -using osu.Game.Graphics.UserInterfaceV2; -using osu.Game.Overlays.Settings; -using osu.Game.Utils; -using osuTK; - -namespace osu.Game.Screens.Edit.Timing -{ - public partial class SliderWithTextBoxInput : CompositeDrawable, IHasCurrentValue - where T : struct, IEquatable, IComparable, IConvertible - { - private readonly SettingsSlider slider; - - public SliderWithTextBoxInput(LocalisableString labelText) - { - LabelledTextBox textBox; - - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - - InternalChildren = new Drawable[] - { - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(20), - Children = new Drawable[] - { - textBox = new LabelledTextBox - { - Label = labelText, - }, - slider = new SettingsSlider - { - TransferValueOnCommit = true, - RelativeSizeAxes = Axes.X, - } - } - }, - }; - - textBox.OnCommit += (t, isNew) => - { - if (!isNew) return; - - try - { - switch (slider.Current) - { - case Bindable bindableInt: - bindableInt.Value = int.Parse(t.Text); - break; - - case Bindable bindableDouble: - bindableDouble.Value = double.Parse(t.Text); - break; - - default: - slider.Current.Parse(t.Text); - break; - } - } - catch - { - // TriggerChange below will restore the previous text value on failure. - } - - // This is run regardless of parsing success as the parsed number may not actually trigger a change - // due to bindable clamping. Even in such a case we want to update the textbox to a sane visual state. - Current.TriggerChange(); - }; - - Current.BindValueChanged(_ => - { - decimal decimalValue = slider.Current.Value.ToDecimal(NumberFormatInfo.InvariantInfo); - textBox.Text = decimalValue.ToString($@"N{FormatUtils.FindPrecision(decimalValue)}"); - }, true); - } - - /// - /// A custom step value for each key press which actuates a change on this control. - /// - public float KeyboardStep - { - get => slider.KeyboardStep; - set => slider.KeyboardStep = value; - } - - public Bindable Current - { - get => slider.Current; - set => slider.Current = value; - } - } -} diff --git a/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs b/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs index 58bf4eea4b..4a61c7fd1b 100644 --- a/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs +++ b/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -24,11 +22,13 @@ namespace osu.Game.Screens.Play.HUD public BindableLong Team1Score = new BindableLong(); public BindableLong Team2Score = new BindableLong(); - protected MatchScoreCounter Score1Text; - protected MatchScoreCounter Score2Text; + protected MatchScoreCounter Score1Text = null!; + protected MatchScoreCounter Score2Text = null!; - private Drawable score1Bar; - private Drawable score2Bar; + private Drawable score1Bar = null!; + private Drawable score2Bar = null!; + + private MatchScoreDiffCounter scoreDiffText = null!; [BackgroundDependencyLoader] private void load(OsuColour colours) @@ -98,6 +98,16 @@ namespace osu.Game.Screens.Play.HUD }, } }, + scoreDiffText = new MatchScoreDiffCounter + { + Anchor = Anchor.TopCentre, + Margin = new MarginPadding + { + Top = bar_height / 4, + Horizontal = 8 + }, + Alpha = 0 + } }; } @@ -139,6 +149,10 @@ namespace osu.Game.Screens.Play.HUD losingBar.ResizeWidthTo(0, 400, Easing.OutQuint); winningBar.ResizeWidthTo(Math.Min(0.4f, MathF.Pow(diff / 1500000f, 0.5f) / 2), 400, Easing.OutQuint); + + scoreDiffText.Alpha = diff != 0 ? 1 : 0; + scoreDiffText.Current.Value = -diff; + scoreDiffText.Origin = Team1Score.Value > Team2Score.Value ? Anchor.TopLeft : Anchor.TopRight; } protected override void UpdateAfterChildren() @@ -150,7 +164,7 @@ namespace osu.Game.Screens.Play.HUD protected partial class MatchScoreCounter : CommaSeparatedScoreCounter { - private OsuSpriteText displayedSpriteText; + private OsuSpriteText displayedSpriteText = null!; public MatchScoreCounter() { @@ -174,5 +188,14 @@ namespace osu.Game.Screens.Play.HUD ? OsuFont.Torus.With(weight: FontWeight.Bold, size: font_size, fixedWidth: true) : OsuFont.Torus.With(weight: FontWeight.Regular, size: font_size * 0.8f, fixedWidth: true); } + + private partial class MatchScoreDiffCounter : CommaSeparatedScoreCounter + { + protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(s => + { + s.Spacing = new Vector2(-2); + s.Font = OsuFont.Torus.With(weight: FontWeight.Regular, size: bar_height, fixedWidth: true); + }); + } } } diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index a5a9387e36..7d4a721c91 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -36,7 +36,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/osu.iOS.props b/osu.iOS.props index d93dfaf67c..fd1ba0f6d1 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -23,6 +23,6 @@ iossimulator-x64 - +