diff --git a/.editorconfig b/.editorconfig index c0ea55f4c8..67c47000d3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -191,6 +191,8 @@ csharp_style_prefer_index_operator = false:silent csharp_style_prefer_range_operator = false:silent csharp_style_prefer_switch_expression = false:none +csharp_style_namespace_declarations = block_scoped:warning + [*.{yaml,yml}] insert_final_newline = true indent_style = space diff --git a/osu.Android.props b/osu.Android.props index 3ede0b85da..e9ecbaa10b 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -11,7 +11,7 @@ manifestmerger.jar - + diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs index 1e9f931b74..4ad78a3190 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs @@ -15,6 +15,10 @@ using osuTK.Graphics; using osu.Game.Rulesets.Mods; using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Testing; using osu.Game.Beatmaps.Legacy; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; @@ -22,6 +26,7 @@ using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Configuration; namespace osu.Game.Rulesets.Osu.Tests { @@ -30,6 +35,27 @@ namespace osu.Game.Rulesets.Osu.Tests { private int depthIndex; + private readonly BindableBool snakingIn = new BindableBool(); + private readonly BindableBool snakingOut = new BindableBool(); + + [SetUpSteps] + public void SetUpSteps() + { + AddToggleStep("toggle snaking", v => + { + snakingIn.Value = v; + snakingOut.Value = v; + }); + } + + [BackgroundDependencyLoader] + private void load() + { + var config = (OsuRulesetConfigManager)RulesetConfigs.GetConfigFor(Ruleset.Value.CreateInstance()).AsNonNull(); + config.BindWith(OsuRulesetSetting.SnakingInSliders, snakingIn); + config.BindWith(OsuRulesetSetting.SnakingOutSliders, snakingOut); + } + [Test] public void TestVariousSliders() { diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs index 3446d41fb4..fc4863f164 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderRepeat.cs @@ -87,12 +87,15 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables protected override void UpdateInitialTransforms() { + // When snaking in is enabled, the first end circle needs to be delayed until the snaking completes. + bool delayFadeIn = DrawableSlider.SliderBody?.SnakingIn.Value == true && HitObject.RepeatIndex == 0; + animDuration = Math.Min(300, HitObject.SpanDuration); - this.Animate( - d => d.FadeIn(animDuration), - d => d.ScaleTo(0.5f).ScaleTo(1f, animDuration * 2, Easing.OutElasticHalf) - ); + this + .FadeOut() + .Delay(delayFadeIn ? (Slider?.TimePreempt ?? 0) / 3 : 0) + .FadeIn(HitObject.RepeatIndex == 0 ? HitObject.TimeFadeIn : animDuration); } protected override void UpdateHitStateTransforms(ArmedState state) diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs index 2c1b68e05a..d9501f7d58 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs @@ -91,7 +91,13 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables { base.UpdateInitialTransforms(); - CirclePiece.FadeInFromZero(HitObject.TimeFadeIn); + // When snaking in is enabled, the first end circle needs to be delayed until the snaking completes. + bool delayFadeIn = DrawableSlider.SliderBody?.SnakingIn.Value == true && HitObject.RepeatIndex == 0; + + CirclePiece + .FadeOut() + .Delay(delayFadeIn ? (Slider?.TimePreempt ?? 0) / 3 : 0) + .FadeIn(HitObject.TimeFadeIn); } protected override void UpdateHitStateTransforms(ArmedState state) diff --git a/osu.Game.Rulesets.Osu/Objects/SliderEndCircle.cs b/osu.Game.Rulesets.Osu/Objects/SliderEndCircle.cs index 35bec92354..f52c3ab382 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderEndCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderEndCircle.cs @@ -39,11 +39,8 @@ namespace osu.Game.Rulesets.Osu.Objects } else { - // taken from osu-stable - const float first_end_circle_preempt_adjust = 2 / 3f; - // The first end circle should fade in with the slider. - TimePreempt = (StartTime - slider.StartTime) + slider.TimePreempt * first_end_circle_preempt_adjust; + TimePreempt += StartTime - slider.StartTime; } } diff --git a/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs b/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs index e951197643..555610a3b6 100644 --- a/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs +++ b/osu.Game.Rulesets.Osu/UI/OsuResumeOverlay.cs @@ -64,7 +64,7 @@ namespace osu.Game.Rulesets.Osu.UI localCursorContainer?.Expire(); localCursorContainer = null; - GameplayCursor?.ActiveCursor?.Show(); + GameplayCursor?.ActiveCursor.Show(); } protected override bool OnHover(HoverEvent e) => true; diff --git a/osu.Game.Tests/Mods/ModSettingsEqualityComparison.cs b/osu.Game.Tests/Mods/ModSettingsEqualityComparison.cs index cd6879cf01..a03b29f7bc 100644 --- a/osu.Game.Tests/Mods/ModSettingsEqualityComparison.cs +++ b/osu.Game.Tests/Mods/ModSettingsEqualityComparison.cs @@ -49,5 +49,31 @@ namespace osu.Game.Tests.Mods Assert.That(mod3, Is.EqualTo(mod2)); Assert.That(doubleConvertedMod3, Is.EqualTo(doubleConvertedMod2)); } + + [Test] + public void TestModWithMultipleSettings() + { + var ruleset = new OsuRuleset(); + + var mod1 = new OsuModDifficultyAdjust { OverallDifficulty = { Value = 10 }, CircleSize = { Value = 0 } }; + var mod2 = new OsuModDifficultyAdjust { OverallDifficulty = { Value = 10 }, CircleSize = { Value = 6 } }; + var mod3 = new OsuModDifficultyAdjust { OverallDifficulty = { Value = 10 }, CircleSize = { Value = 6 } }; + + var doubleConvertedMod1 = new APIMod(mod1).ToMod(ruleset); + var doubleConvertedMod2 = new APIMod(mod2).ToMod(ruleset); + var doubleConvertedMod3 = new APIMod(mod3).ToMod(ruleset); + + Assert.That(mod1, Is.Not.EqualTo(mod2)); + Assert.That(doubleConvertedMod1, Is.Not.EqualTo(doubleConvertedMod2)); + + Assert.That(mod2, Is.EqualTo(mod2)); + Assert.That(doubleConvertedMod2, Is.EqualTo(doubleConvertedMod2)); + + Assert.That(mod2, Is.EqualTo(mod3)); + Assert.That(doubleConvertedMod2, Is.EqualTo(doubleConvertedMod3)); + + Assert.That(mod3, Is.EqualTo(mod2)); + Assert.That(doubleConvertedMod3, Is.EqualTo(doubleConvertedMod2)); + } } } diff --git a/osu.Game.Tests/Mods/ModSettingsTest.cs b/osu.Game.Tests/Mods/ModSettingsTest.cs index b9ea1f2567..5ec9629dc2 100644 --- a/osu.Game.Tests/Mods/ModSettingsTest.cs +++ b/osu.Game.Tests/Mods/ModSettingsTest.cs @@ -2,6 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Game.Configuration; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; @@ -10,7 +13,7 @@ namespace osu.Game.Tests.Mods public class ModSettingsTest { [Test] - public void TestModSettingsUnboundWhenCopied() + public void TestModSettingsUnboundWhenCloned() { var original = new OsuModDoubleTime(); var copy = (OsuModDoubleTime)original.DeepClone(); @@ -22,7 +25,7 @@ namespace osu.Game.Tests.Mods } [Test] - public void TestMultiModSettingsUnboundWhenCopied() + public void TestMultiModSettingsUnboundWhenCloned() { var original = new MultiMod(new OsuModDoubleTime()); var copy = (MultiMod)original.DeepClone(); @@ -32,5 +35,67 @@ namespace osu.Game.Tests.Mods Assert.That(((OsuModDoubleTime)original.Mods[0]).SpeedChange.Value, Is.EqualTo(2.0)); Assert.That(((OsuModDoubleTime)copy.Mods[0]).SpeedChange.Value, Is.EqualTo(1.5)); } + + [Test] + public void TestDifferentTypeSettingsKeptWhenCopied() + { + const double setting_change = 50.4; + + var modDouble = new TestNonMatchingSettingTypeModDouble { TestSetting = { Value = setting_change } }; + var modBool = new TestNonMatchingSettingTypeModBool { TestSetting = { Default = false, Value = true } }; + var modInt = new TestNonMatchingSettingTypeModInt { TestSetting = { Value = (int)setting_change / 2 } }; + + modDouble.CopyCommonSettingsFrom(modBool); + modDouble.CopyCommonSettingsFrom(modInt); + modBool.CopyCommonSettingsFrom(modDouble); + modBool.CopyCommonSettingsFrom(modInt); + modInt.CopyCommonSettingsFrom(modDouble); + modInt.CopyCommonSettingsFrom(modBool); + + Assert.That(modDouble.TestSetting.Value, Is.EqualTo(setting_change)); + Assert.That(modBool.TestSetting.Value, Is.EqualTo(true)); + Assert.That(modInt.TestSetting.Value, Is.EqualTo((int)setting_change / 2)); + } + + [Test] + public void TestDefaultValueKeptWhenCopied() + { + var modBoolTrue = new TestNonMatchingSettingTypeModBool { TestSetting = { Default = true, Value = false } }; + var modBoolFalse = new TestNonMatchingSettingTypeModBool { TestSetting = { Default = false, Value = true } }; + + modBoolFalse.CopyCommonSettingsFrom(modBoolTrue); + + Assert.That(modBoolFalse.TestSetting.Default, Is.EqualTo(false)); + Assert.That(modBoolFalse.TestSetting.Value, Is.EqualTo(modBoolTrue.TestSetting.Value)); + } + + private class TestNonMatchingSettingTypeModDouble : TestNonMatchingSettingTypeMod + { + public override string Acronym => "NMD"; + public override BindableNumber TestSetting { get; } = new BindableDouble(); + } + + private class TestNonMatchingSettingTypeModInt : TestNonMatchingSettingTypeMod + { + public override string Acronym => "NMI"; + public override BindableNumber TestSetting { get; } = new BindableInt(); + } + + private class TestNonMatchingSettingTypeModBool : TestNonMatchingSettingTypeMod + { + public override string Acronym => "NMB"; + public override Bindable TestSetting { get; } = new BindableBool(); + } + + private abstract class TestNonMatchingSettingTypeMod : Mod + { + public override string Name => "Non-matching setting type mod"; + public override LocalisableString Description => "Description"; + public override double ScoreMultiplier => 1; + public override ModType Type => ModType.Conversion; + + [SettingSource("Test setting")] + public abstract IBindable TestSetting { get; } + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs index 119b753d70..7b37b6624d 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs @@ -2,12 +2,15 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.IO; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input; using osu.Framework.Testing; using osu.Game.Overlays; using osu.Game.Overlays.Settings; @@ -182,6 +185,64 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("all boxes still selected", () => skinEditor.SelectedComponents, () => Has.Count.EqualTo(2)); } + [Test] + public void TestUndoEditHistory() + { + SkinComponentsContainer firstTarget = null!; + TestSkinEditorChangeHandler changeHandler = null!; + byte[] defaultState = null!; + IEnumerable testComponents = null!; + + AddStep("Load necessary things", () => + { + firstTarget = Player.ChildrenOfType().First(); + changeHandler = new TestSkinEditorChangeHandler(firstTarget); + + changeHandler.SaveState(); + defaultState = changeHandler.GetCurrentState(); + + testComponents = new[] + { + targetContainer.Components.First(), + targetContainer.Components[targetContainer.Components.Count / 2], + targetContainer.Components.Last() + }; + }); + + AddStep("Press undo", () => InputManager.Keys(PlatformAction.Undo)); + AddAssert("Nothing changed", () => defaultState.SequenceEqual(changeHandler.GetCurrentState())); + + AddStep("Add components", () => + { + InputManager.MoveMouseTo(skinEditor.ChildrenOfType().First()); + InputManager.Click(MouseButton.Left); + InputManager.Click(MouseButton.Left); + InputManager.Click(MouseButton.Left); + }); + revertAndCheckUnchanged(); + + AddStep("Move components", () => + { + changeHandler.BeginChange(); + testComponents.ForEach(c => ((Drawable)c).Position += Vector2.One); + changeHandler.EndChange(); + }); + revertAndCheckUnchanged(); + + AddStep("Select components", () => skinEditor.SelectedComponents.AddRange(testComponents)); + AddStep("Bring to front", () => skinEditor.BringSelectionToFront()); + revertAndCheckUnchanged(); + + AddStep("Remove components", () => testComponents.ForEach(c => firstTarget.Remove(c, false))); + revertAndCheckUnchanged(); + + void revertAndCheckUnchanged() + { + AddStep("Revert changes", () => changeHandler.RestoreState(int.MinValue)); + AddAssert("Current state is same as default", () => defaultState.SequenceEqual(changeHandler.GetCurrentState())); + } + } + [TestCase(false)] [TestCase(true)] public void TestBringToFront(bool alterSelectionOrder) @@ -269,5 +330,23 @@ namespace osu.Game.Tests.Visual.Gameplay } protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + + private partial class TestSkinEditorChangeHandler : SkinEditorChangeHandler + { + public TestSkinEditorChangeHandler(Drawable targetScreen) + : base(targetScreen) + { + } + + public byte[] GetCurrentState() + { + using var stream = new MemoryStream(); + + WriteCurrentStateToStream(stream); + byte[] newState = stream.ToArray(); + + return newState; + } + } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs index a8369dd6d9..55e6b54af7 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs @@ -8,10 +8,12 @@ using System.Linq; using System.Collections.Generic; using System.Net; using System.Threading; +using System.Threading.Tasks; using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input; @@ -30,6 +32,7 @@ using osu.Game.Overlays.Chat.Listing; using osu.Game.Overlays.Chat.ChannelList; using osuTK; using osuTK.Input; +using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Tests.Visual.Online { @@ -53,6 +56,9 @@ namespace osu.Game.Tests.Visual.Online private int currentMessageId; + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + private readonly ManualResetEventSlim requestLock = new ManualResetEventSlim(); + [SetUp] public void SetUp() => Schedule(() => { @@ -576,6 +582,75 @@ namespace osu.Game.Tests.Visual.Online }); } + [Test] + public void TestChatReport() + { + ChatReportRequest request = null; + + AddStep("Show overlay with channel", () => + { + chatOverlay.Show(); + channelManager.CurrentChannel.Value = channelManager.JoinChannel(testChannel1); + }); + + AddAssert("Overlay is visible", () => chatOverlay.State.Value == Visibility.Visible); + waitForChannel1Visible(); + + AddStep("Setup request handling", () => + { + requestLock.Reset(); + + dummyAPI.HandleRequest = r => + { + if (!(r is ChatReportRequest req)) + return false; + + Task.Run(() => + { + request = req; + requestLock.Wait(10000); + req.TriggerSuccess(); + }); + + return true; + }; + }); + + AddStep("Show report popover", () => this.ChildrenOfType().First().ShowPopover()); + + AddStep("Set report reason to other", () => + { + var reason = this.ChildrenOfType>().Single(); + reason.Current.Value = ChatReportReason.Other; + }); + + AddStep("Try to report", () => + { + var btn = this.ChildrenOfType().Single().ChildrenOfType().Single(); + InputManager.MoveMouseTo(btn); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("Nothing happened", () => this.ChildrenOfType().Any()); + AddStep("Set report data", () => + { + var field = this.ChildrenOfType().Single().ChildrenOfType().Single(); + field.Current.Value = "test other"; + }); + + AddStep("Try to report", () => + { + var btn = this.ChildrenOfType().Single().ChildrenOfType().Single(); + InputManager.MoveMouseTo(btn); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("Overlay closed", () => !this.ChildrenOfType().Any()); + AddStep("Complete request", () => requestLock.Set()); + AddUntilStep("Request sent", () => request != null); + AddUntilStep("Info message displayed", () => channelManager.CurrentChannel.Value.Messages.Last(), () => Is.InstanceOf(typeof(InfoMessage))); + } + private void joinTestChannel(int i) { AddStep($"Join test channel {i}", () => channelManager.JoinChannel(testChannels[i])); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs index 1090764788..3efdba8754 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs @@ -243,7 +243,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddUntilStep("wait for context menu", () => this.ChildrenOfType().Any()); AddStep("click delete", () => { - var deleteItem = this.ChildrenOfType().Single(); + var deleteItem = this.ChildrenOfType().ElementAt(1); InputManager.MoveMouseTo(deleteItem); InputManager.Click(MouseButton.Left); }); @@ -261,6 +261,137 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("preset soft-deleted", () => Realm.Run(r => r.All().Count(preset => preset.DeletePending) == 1)); } + [Test] + public void TestEditPresetName() + { + ModPresetColumn modPresetColumn = null!; + string presetName = null!; + ModPresetPanel panel = null!; + + AddStep("clear mods", () => SelectedMods.Value = Array.Empty()); + AddStep("create content", () => Child = modPresetColumn = new ModPresetColumn + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + AddUntilStep("items loaded", () => modPresetColumn.IsLoaded && modPresetColumn.ItemsLoaded); + AddStep("right click first panel", () => + { + panel = this.ChildrenOfType().First(); + presetName = panel.Preset.Value.Name; + InputManager.MoveMouseTo(panel); + InputManager.Click(MouseButton.Right); + }); + + AddUntilStep("wait for context menu", () => this.ChildrenOfType().Any()); + AddStep("click edit", () => + { + var editItem = this.ChildrenOfType().ElementAt(0); + InputManager.MoveMouseTo(editItem); + InputManager.Click(MouseButton.Left); + }); + + OsuPopover? popover = null; + AddUntilStep("wait for popover", () => (popover = this.ChildrenOfType().FirstOrDefault()) != null); + AddStep("clear preset name", () => popover.ChildrenOfType().First().Current.Value = ""); + AddStep("attempt preset edit", () => + { + InputManager.MoveMouseTo(popover.ChildrenOfType().ElementAt(1)); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("preset is not changed", () => panel.Preset.Value.Name == presetName); + AddUntilStep("popover is unchanged", () => this.ChildrenOfType().FirstOrDefault() == popover); + AddStep("edit preset name", () => popover.ChildrenOfType().First().Current.Value = "something new"); + AddStep("attempt preset edit", () => + { + InputManager.MoveMouseTo(popover.ChildrenOfType().ElementAt(1)); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("popover closed", () => !this.ChildrenOfType().Any()); + AddAssert("preset is changed", () => panel.Preset.Value.Name != presetName); + } + + [Test] + public void TestEditPresetMod() + { + ModPresetColumn modPresetColumn = null!; + var mods = new Mod[] { new OsuModHidden(), new OsuModHardRock() }; + List previousMod = null!; + + AddStep("clear mods", () => SelectedMods.Value = Array.Empty()); + AddStep("create content", () => Child = modPresetColumn = new ModPresetColumn + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + AddUntilStep("items loaded", () => modPresetColumn.IsLoaded && modPresetColumn.ItemsLoaded); + + AddStep("right click first panel", () => + { + var panel = this.ChildrenOfType().First(); + previousMod = panel.Preset.Value.Mods.ToList(); + InputManager.MoveMouseTo(panel); + InputManager.Click(MouseButton.Right); + }); + AddUntilStep("wait for context menu", () => this.ChildrenOfType().Any()); + AddStep("click edit", () => + { + var editItem = this.ChildrenOfType().ElementAt(0); + InputManager.MoveMouseTo(editItem); + InputManager.Click(MouseButton.Left); + }); + + OsuPopover? popover = null; + AddUntilStep("wait for popover", () => (popover = this.ChildrenOfType().FirstOrDefault()) != null); + AddStep("click use current mods", () => + { + InputManager.MoveMouseTo(popover.ChildrenOfType().ElementAt(0)); + InputManager.Click(MouseButton.Left); + }); + AddStep("attempt preset edit", () => + { + InputManager.MoveMouseTo(popover.ChildrenOfType().ElementAt(1)); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("preset mod not changed", () => + new HashSet(this.ChildrenOfType().First().Preset.Value.Mods).SetEquals(previousMod)); + + AddStep("select mods", () => SelectedMods.Value = mods); + AddStep("right click first panel", () => + { + var panel = this.ChildrenOfType().First(); + InputManager.MoveMouseTo(panel); + InputManager.Click(MouseButton.Right); + }); + + AddUntilStep("wait for context menu", () => this.ChildrenOfType().Any()); + AddStep("click edit", () => + { + var editItem = this.ChildrenOfType().ElementAt(0); + InputManager.MoveMouseTo(editItem); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("wait for popover", () => (popover = this.ChildrenOfType().FirstOrDefault()) != null); + AddStep("click use current mods", () => + { + InputManager.MoveMouseTo(popover.ChildrenOfType().ElementAt(0)); + InputManager.Click(MouseButton.Left); + }); + AddStep("attempt preset edit", () => + { + InputManager.MoveMouseTo(popover.ChildrenOfType().ElementAt(1)); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("preset mod is changed", () => + new HashSet(this.ChildrenOfType().First().Preset.Value.Mods).SetEquals(mods)); + } + private ICollection createTestPresets() => new[] { new ModPreset diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index f99fe1d8d4..5cf24c1960 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs @@ -22,6 +22,7 @@ using osu.Game.Rulesets.Catch.Mods; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Tests.Mods; using osuTK; using osuTK.Input; @@ -385,6 +386,50 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("no mod selected", () => SelectedMods.Value.Count == 0); } + [Test] + public void TestKeepSharedSettingsFromSimilarMods() + { + const float setting_change = 1.2f; + + createScreen(); + changeRuleset(0); + + AddStep("select difficulty adjust mod", () => SelectedMods.Value = new[] { Ruleset.Value.CreateInstance().CreateMod()! }); + + changeRuleset(0); + AddAssert("ensure mod still selected", () => SelectedMods.Value.SingleOrDefault() is OsuModDifficultyAdjust); + + AddStep("change mod settings", () => + { + var osuMod = getSelectedMod(); + + osuMod.ExtendedLimits.Value = true; + osuMod.CircleSize.Value = setting_change; + osuMod.DrainRate.Value = setting_change; + osuMod.OverallDifficulty.Value = setting_change; + osuMod.ApproachRate.Value = setting_change; + }); + + changeRuleset(1); + AddAssert("taiko variant selected", () => SelectedMods.Value.SingleOrDefault() is TaikoModDifficultyAdjust); + + AddAssert("shared settings preserved", () => + { + var taikoMod = getSelectedMod(); + + return taikoMod.ExtendedLimits.Value && + taikoMod.DrainRate.Value == setting_change && + taikoMod.OverallDifficulty.Value == setting_change; + }); + + AddAssert("non-shared settings remain default", () => + { + var taikoMod = getSelectedMod(); + + return taikoMod.ScrollSpeed.IsDefault; + }); + } + [Test] public void TestExternallySetCustomizedMod() { @@ -617,6 +662,8 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert($"customisation toggle is {(active ? "" : "not ")}active", () => modSelectOverlay.CustomisationButton.AsNonNull().Active.Value == active); } + private T getSelectedMod() where T : Mod => SelectedMods.Value.OfType().Single(); + private ModPanel getPanelForMod(Type modType) => modSelectOverlay.ChildrenOfType().Single(panel => panel.Mod.GetType() == modType); diff --git a/osu.Game/Graphics/UserInterfaceV2/ReportPopover.cs b/osu.Game/Graphics/UserInterfaceV2/ReportPopover.cs new file mode 100644 index 0000000000..7b3c32d60d --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/ReportPopover.cs @@ -0,0 +1,133 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Resources.Localisation.Web; +using osuTK; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + /// + /// A generic popover for sending an online report about something. + /// + /// An enumeration type with all valid reasons for the report. + public abstract partial class ReportPopover : OsuPopover + where TReportReason : struct, Enum + { + /// + /// The action to run when the report is finalised. + /// The arguments to this action are: the reason for the report, and an optional additional comment. + /// + public Action? Action; + + private OsuEnumDropdown reasonDropdown = null!; + private OsuTextBox commentsTextBox = null!; + private RoundedButton submitButton = null!; + + private readonly LocalisableString header; + + /// + /// Creates a new . + /// + /// The text to display in the header of the popover. + protected ReportPopover(LocalisableString headerString) + { + header = headerString; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Child = new ReverseChildIDFillFlowContainer + { + Direction = FillDirection.Vertical, + Width = 500, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(7), + Children = new Drawable[] + { + new SpriteIcon + { + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Icon = FontAwesome.Solid.ExclamationTriangle, + Size = new Vector2(36), + }, + new OsuSpriteText + { + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Text = header, + Font = OsuFont.Torus.With(size: 25), + Margin = new MarginPadding { Bottom = 10 } + }, + new OsuSpriteText + { + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Text = UsersStrings.ReportReason, + }, + new Container + { + RelativeSizeAxes = Axes.X, + Height = 40, + Child = reasonDropdown = new OsuEnumDropdown + { + RelativeSizeAxes = Axes.X + } + }, + new OsuSpriteText + { + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Text = UsersStrings.ReportComments, + }, + commentsTextBox = new OsuTextBox + { + RelativeSizeAxes = Axes.X, + PlaceholderText = UsersStrings.ReportPlaceholder, + }, + submitButton = new RoundedButton + { + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Width = 200, + BackgroundColour = colours.Red3, + Text = UsersStrings.ReportActionsSend, + Action = () => + { + Action?.Invoke(reasonDropdown.Current.Value, commentsTextBox.Text); + this.HidePopover(); + }, + Margin = new MarginPadding { Bottom = 5, Top = 10 }, + } + } + }; + + commentsTextBox.Current.BindValueChanged(_ => updateStatus()); + + reasonDropdown.Current.BindValueChanged(_ => updateStatus()); + + updateStatus(); + } + + private void updateStatus() + { + submitButton.Enabled.Value = !string.IsNullOrWhiteSpace(commentsTextBox.Current.Value) || !IsCommentRequired(reasonDropdown.Current.Value); + } + + /// + /// Determines whether an additional comment is required for submitting the report with the supplied . + /// + protected virtual bool IsCommentRequired(TReportReason reason) => true; + } +} diff --git a/osu.Game/Localisation/ModSelectOverlayStrings.cs b/osu.Game/Localisation/ModSelectOverlayStrings.cs index d6a01c4794..f11c52ee20 100644 --- a/osu.Game/Localisation/ModSelectOverlayStrings.cs +++ b/osu.Game/Localisation/ModSelectOverlayStrings.cs @@ -34,6 +34,11 @@ namespace osu.Game.Localisation /// public static LocalisableString AddPreset => new TranslatableString(getKey(@"add_preset"), @"Add preset"); + /// + /// "Use current mods" + /// + public static LocalisableString UseCurrentMods => new TranslatableString(getKey(@"use_current_mods"), @"Use current mods"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Online/API/Requests/ChatReportRequest.cs b/osu.Game/Online/API/Requests/ChatReportRequest.cs new file mode 100644 index 0000000000..85e5559e01 --- /dev/null +++ b/osu.Game/Online/API/Requests/ChatReportRequest.cs @@ -0,0 +1,38 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Net.Http; +using osu.Framework.IO.Network; +using osu.Game.Overlays.Chat; + +namespace osu.Game.Online.API.Requests +{ + public class ChatReportRequest : APIRequest + { + public readonly long? MessageId; + public readonly ChatReportReason Reason; + public readonly string Comment; + + public ChatReportRequest(long? id, ChatReportReason reason, string comment) + { + MessageId = id; + Reason = reason; + Comment = comment; + } + + protected override WebRequest CreateWebRequest() + { + var req = base.CreateWebRequest(); + req.Method = HttpMethod.Post; + + req.AddParameter(@"reportable_type", @"message"); + req.AddParameter(@"reportable_id", $"{MessageId}"); + req.AddParameter(@"reason", Reason.ToString()); + req.AddParameter(@"comments", Comment); + + return req; + } + + protected override string Target => @"reports"; + } +} diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 34e31b0d61..c55b6c249f 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -58,7 +58,6 @@ using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Skinning; using osu.Game.Utils; -using File = System.IO.File; using RuntimeInfo = osu.Framework.RuntimeInfo; namespace osu.Game @@ -626,15 +625,22 @@ namespace osu.Game return; } - var previouslySelectedMods = SelectedMods.Value.ToArray(); - - if (!SelectedMods.Disabled) - SelectedMods.Value = Array.Empty(); - AvailableMods.Value = dict; - if (!SelectedMods.Disabled) - SelectedMods.Value = previouslySelectedMods.Select(m => instance.CreateModFromAcronym(m.Acronym)).Where(m => m != null).ToArray(); + if (SelectedMods.Disabled) + return; + + var convertedMods = SelectedMods.Value.Select(mod => + { + var newMod = instance.CreateModFromAcronym(mod.Acronym); + newMod?.CopyCommonSettingsFrom(mod); + return newMod; + }).Where(newMod => newMod != null).ToList(); + + if (!ModUtils.CheckValidForGameplay(convertedMods, out var invalid)) + invalid.ForEach(newMod => convertedMods.Remove(newMod)); + + SelectedMods.Value = convertedMods; void revertRulesetChange() => Ruleset.Value = r.OldValue?.Available == true ? r.OldValue : RulesetStore.AvailableRulesets.First(); } diff --git a/osu.Game/Overlays/Chat/ChatLine.cs b/osu.Game/Overlays/Chat/ChatLine.cs index 70c3bf181c..2f4c175ac4 100644 --- a/osu.Game/Overlays/Chat/ChatLine.cs +++ b/osu.Game/Overlays/Chat/ChatLine.cs @@ -1,25 +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 System.Linq; using System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Online.Chat; -using osu.Framework.Graphics.Sprites; namespace osu.Game.Overlays.Chat { - public partial class ChatLine : CompositeDrawable + public partial class ChatLine : CompositeDrawable, IHasPopover { private Message message = null!; @@ -55,7 +58,7 @@ namespace osu.Game.Overlays.Chat private readonly OsuSpriteText drawableTimestamp; - private readonly DrawableUsername drawableUsername; + private readonly DrawableChatUsername drawableUsername; private readonly LinkFlowContainer drawableContentFlow; @@ -92,7 +95,7 @@ namespace osu.Game.Overlays.Chat Font = OsuFont.GetFont(size: FontSize * 0.75f, weight: FontWeight.SemiBold, fixedWidth: true), AlwaysPresent = true, }, - drawableUsername = new DrawableUsername(message.Sender) + drawableUsername = new DrawableChatUsername(message.Sender) { Width = UsernameWidth, FontSize = FontSize, @@ -100,6 +103,7 @@ namespace osu.Game.Overlays.Chat Origin = Anchor.TopRight, Anchor = Anchor.TopRight, Margin = new MarginPadding { Horizontal = Spacing }, + ReportRequested = this.ShowPopover, }, drawableContentFlow = new LinkFlowContainer(styleMessageContent) { @@ -128,6 +132,8 @@ namespace osu.Game.Overlays.Chat FinishTransforms(true); } + public Popover GetPopover() => new ReportChatPopover(message); + /// /// Performs a highlight animation on this . /// diff --git a/osu.Game/Overlays/Chat/ChatReportReason.cs b/osu.Game/Overlays/Chat/ChatReportReason.cs new file mode 100644 index 0000000000..5fda11b61e --- /dev/null +++ b/osu.Game/Overlays/Chat/ChatReportReason.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.ComponentModel; +using osu.Framework.Localisation; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Overlays.Chat +{ + /// + /// References: + /// https://github.com/ppy/osu-web/blob/0a41b13acf5f47bb0d2b08bab42a9646b7ab5821/app/Models/UserReport.php#L50 + /// https://github.com/ppy/osu-web/blob/0a41b13acf5f47bb0d2b08bab42a9646b7ab5821/app/Models/UserReport.php#L39 + /// + public enum ChatReportReason + { + [Description("Insulting People")] + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.ReportOptionsInsults))] + Insults, + + [Description("Spam")] + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.ReportOptionsSpam))] + Spam, + + [Description("Unwanted Content")] + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.ReportOptionsUnwantedContent))] + UnwantedContent, + + [Description("Nonsense")] + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.ReportOptionsNonsense))] + Nonsense, + + [Description("Other")] + [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.ReportOptionsOther))] + Other + } +} diff --git a/osu.Game/Overlays/Chat/DrawableUsername.cs b/osu.Game/Overlays/Chat/DrawableChatUsername.cs similarity index 95% rename from osu.Game/Overlays/Chat/DrawableUsername.cs rename to osu.Game/Overlays/Chat/DrawableChatUsername.cs index 031a0b6ae2..4b4afc204c 100644 --- a/osu.Game/Overlays/Chat/DrawableUsername.cs +++ b/osu.Game/Overlays/Chat/DrawableChatUsername.cs @@ -29,8 +29,10 @@ using ChatStrings = osu.Game.Localisation.ChatStrings; namespace osu.Game.Overlays.Chat { - public partial class DrawableUsername : OsuClickableContainer, IHasContextMenu + public partial class DrawableChatUsername : OsuClickableContainer, IHasContextMenu { + public Action? ReportRequested; + public Color4 AccentColour { get; } public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => @@ -75,7 +77,7 @@ namespace osu.Game.Overlays.Chat private readonly Drawable colouredDrawable; - public DrawableUsername(APIUser user) + public DrawableChatUsername(APIUser user) { this.user = user; @@ -169,6 +171,9 @@ namespace osu.Game.Overlays.Chat })); } + if (!user.Equals(api.LocalUser.Value)) + items.Add(new OsuMenuItem("Report", MenuItemType.Destructive, ReportRequested)); + return items.ToArray(); } } diff --git a/osu.Game/Overlays/Chat/ReportChatPopover.cs b/osu.Game/Overlays/Chat/ReportChatPopover.cs new file mode 100644 index 0000000000..265a17c799 --- /dev/null +++ b/osu.Game/Overlays/Chat/ReportChatPopover.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 osu.Framework.Allocation; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.Chat; +using osu.Game.Resources.Localisation.Web; + +namespace osu.Game.Overlays.Chat +{ + public partial class ReportChatPopover : ReportPopover + { + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private ChannelManager channelManager { get; set; } = null!; + + private readonly Message message; + + public ReportChatPopover(Message message) + : base(ReportStrings.UserTitle(message.Sender?.Username ?? @"Someone")) + { + this.message = message; + Action = report; + } + + protected override bool IsCommentRequired(ChatReportReason reason) => reason == ChatReportReason.Other; + + private void report(ChatReportReason reason, string comments) + { + var request = new ChatReportRequest(message.Id, reason, comments); + + request.Success += () => channelManager.CurrentChannel.Value.AddNewMessages(new InfoMessage(UsersStrings.ReportThanks.ToString())); + + api.Queue(request); + } + } +} diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index b44c7c48f5..96dbfe31f3 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -9,6 +9,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.Graphics.Shapes; using osu.Framework.Input; using osu.Framework.Input.Bindings; @@ -134,9 +135,13 @@ namespace osu.Game.Overlays }, Children = new Drawable[] { - currentChannelContainer = new Container + new PopoverContainer { RelativeSizeAxes = Axes.Both, + Child = currentChannelContainer = new Container + { + RelativeSizeAxes = Axes.Both, + } }, loading = new LoadingLayer(true), channelListing = new ChannelListing diff --git a/osu.Game/Overlays/Comments/CommentReportButton.cs b/osu.Game/Overlays/Comments/CommentReportButton.cs index ba5319094b..e4d4d671da 100644 --- a/osu.Game/Overlays/Comments/CommentReportButton.cs +++ b/osu.Game/Overlays/Comments/CommentReportButton.cs @@ -57,6 +57,11 @@ namespace osu.Game.Overlays.Comments link.AddLink(ReportStrings.CommentButton.ToLower(), this.ShowPopover); } + public Popover GetPopover() => new ReportCommentPopover(comment) + { + Action = report + }; + private void report(CommentReportReason reason, string comments) { var request = new CommentReportRequest(comment.Id, reason, comments); @@ -83,10 +88,5 @@ namespace osu.Game.Overlays.Comments api.Queue(request); } - - public Popover GetPopover() => new ReportCommentPopover(comment) - { - Action = report - }; } } diff --git a/osu.Game/Overlays/Comments/ReportCommentPopover.cs b/osu.Game/Overlays/Comments/ReportCommentPopover.cs index f3b2a2f97c..e688dad755 100644 --- a/osu.Game/Overlays/Comments/ReportCommentPopover.cs +++ b/osu.Game/Overlays/Comments/ReportCommentPopover.cs @@ -1,111 +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.Allocation; -using osu.Framework.Extensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -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.Online.API.Requests.Responses; using osu.Game.Resources.Localisation.Web; -using osuTK; namespace osu.Game.Overlays.Comments { - public partial class ReportCommentPopover : OsuPopover + public partial class ReportCommentPopover : ReportPopover { - public Action? Action; - - private readonly Comment? comment; - - private OsuEnumDropdown reasonDropdown = null!; - private OsuTextBox commentsTextBox = null!; - private RoundedButton submitButton = null!; - public ReportCommentPopover(Comment? comment) + : base(ReportStrings.CommentTitle(comment?.User?.Username ?? comment?.LegacyName ?? @"Someone")) { - this.comment = comment; - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Child = new ReverseChildIDFillFlowContainer - { - Direction = FillDirection.Vertical, - Width = 500, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(7), - Children = new Drawable[] - { - new SpriteIcon - { - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Icon = FontAwesome.Solid.ExclamationTriangle, - Size = new Vector2(36), - }, - new OsuSpriteText - { - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Text = ReportStrings.CommentTitle(comment?.User?.Username ?? comment?.LegacyName ?? @"Someone"), - Font = OsuFont.Torus.With(size: 25), - Margin = new MarginPadding { Bottom = 10 } - }, - new OsuSpriteText - { - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Text = UsersStrings.ReportReason, - }, - new Container - { - RelativeSizeAxes = Axes.X, - Height = 40, - Child = reasonDropdown = new OsuEnumDropdown - { - RelativeSizeAxes = Axes.X - } - }, - new OsuSpriteText - { - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Text = UsersStrings.ReportComments, - }, - commentsTextBox = new OsuTextBox - { - RelativeSizeAxes = Axes.X, - PlaceholderText = UsersStrings.ReportPlaceholder, - }, - submitButton = new RoundedButton - { - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Width = 200, - BackgroundColour = colours.Red3, - Text = UsersStrings.ReportActionsSend, - Action = () => - { - Action?.Invoke(reasonDropdown.Current.Value, commentsTextBox.Text); - this.HidePopover(); - }, - Margin = new MarginPadding { Bottom = 5, Top = 10 }, - } - } - }; - - commentsTextBox.Current.BindValueChanged(e => - { - submitButton.Enabled.Value = !string.IsNullOrWhiteSpace(e.NewValue); - }, true); } } } diff --git a/osu.Game/Overlays/Mods/AddPresetPopover.cs b/osu.Game/Overlays/Mods/AddPresetPopover.cs index 33d72ff383..d9e350e560 100644 --- a/osu.Game/Overlays/Mods/AddPresetPopover.cs +++ b/osu.Game/Overlays/Mods/AddPresetPopover.cs @@ -9,7 +9,6 @@ using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Database; -using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; @@ -67,7 +66,7 @@ namespace osu.Game.Overlays.Mods Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Text = ModSelectOverlayStrings.AddPreset, - Action = tryCreatePreset + Action = createPreset } } }; @@ -89,16 +88,15 @@ namespace osu.Game.Overlays.Mods base.LoadComplete(); ScheduleAfterChildren(() => GetContainingInputManager().ChangeFocus(nameTextBox)); + + nameTextBox.Current.BindValueChanged(s => + { + createButton.Enabled.Value = !string.IsNullOrWhiteSpace(s.NewValue); + }, true); } - private void tryCreatePreset() + private void createPreset() { - if (string.IsNullOrWhiteSpace(nameTextBox.Current.Value)) - { - Body.Shake(); - return; - } - realm.Write(r => r.Add(new ModPreset { Name = nameTextBox.Current.Value, diff --git a/osu.Game/Overlays/Mods/EditPresetPopover.cs b/osu.Game/Overlays/Mods/EditPresetPopover.cs new file mode 100644 index 0000000000..5220f6a391 --- /dev/null +++ b/osu.Game/Overlays/Mods/EditPresetPopover.cs @@ -0,0 +1,172 @@ +// 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.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Localisation; +using osu.Game.Rulesets.Mods; +using osuTK; + +namespace osu.Game.Overlays.Mods +{ + internal partial class EditPresetPopover : OsuPopover + { + private LabelledTextBox nameTextBox = null!; + private LabelledTextBox descriptionTextBox = null!; + private ShearedButton useCurrentModsButton = null!; + private ShearedButton saveButton = null!; + private FillFlowContainer scrollContent = null!; + + private readonly Live preset; + + private HashSet saveableMods; + + [Resolved] + private Bindable> selectedMods { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public EditPresetPopover(Live preset) + { + this.preset = preset; + saveableMods = preset.PerformRead(p => p.Mods).ToHashSet(); + } + + [BackgroundDependencyLoader] + private void load() + { + Child = new FillFlowContainer + { + Width = 300, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(7), + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + nameTextBox = new LabelledTextBox + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Label = CommonStrings.Name, + TabbableContentContainer = this, + Current = { Value = preset.PerformRead(p => p.Name) }, + }, + descriptionTextBox = new LabelledTextBox + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Label = CommonStrings.Description, + TabbableContentContainer = this, + Current = { Value = preset.PerformRead(p => p.Description) }, + }, + new OsuScrollContainer + { + RelativeSizeAxes = Axes.X, + Height = 100, + Padding = new MarginPadding(7), + Child = scrollContent = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(7), + Spacing = new Vector2(7), + } + }, + new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Direction = FillDirection.Horizontal, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(7), + Children = new Drawable[] + { + useCurrentModsButton = new ShearedButton + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = ModSelectOverlayStrings.UseCurrentMods, + DarkerColour = colours.Blue1, + LighterColour = colours.Blue0, + TextColour = colourProvider.Background6, + Action = useCurrentMods, + }, + saveButton = new ShearedButton + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = Resources.Localisation.Web.CommonStrings.ButtonsSave, + DarkerColour = colours.Orange1, + LighterColour = colours.Orange0, + TextColour = colourProvider.Background6, + Action = save, + }, + } + } + } + }; + + Body.BorderThickness = 3; + Body.BorderColour = colours.Orange1; + + selectedMods.BindValueChanged(_ => updateState(), true); + nameTextBox.Current.BindValueChanged(s => + { + saveButton.Enabled.Value = !string.IsNullOrWhiteSpace(s.NewValue); + }, true); + } + + private void useCurrentMods() + { + saveableMods = selectedMods.Value.ToHashSet(); + updateState(); + } + + private void updateState() + { + scrollContent.ChildrenEnumerable = saveableMods.Select(mod => new ModPresetRow(mod)); + useCurrentModsButton.Enabled.Value = checkSelectedModsDiffersFromSaved(); + } + + private bool checkSelectedModsDiffersFromSaved() + { + if (!selectedMods.Value.Any()) + return false; + + return !saveableMods.SetEquals(selectedMods.Value); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ScheduleAfterChildren(() => GetContainingInputManager().ChangeFocus(nameTextBox)); + } + + private void save() + { + preset.PerformWrite(s => + { + s.Name = nameTextBox.Current.Value; + s.Description = descriptionTextBox.Current.Value; + s.Mods = saveableMods; + }); + + this.HidePopover(); + } + } +} diff --git a/osu.Game/Overlays/Mods/ModPresetPanel.cs b/osu.Game/Overlays/Mods/ModPresetPanel.cs index 6e12e34124..8bcb5e4e4e 100644 --- a/osu.Game/Overlays/Mods/ModPresetPanel.cs +++ b/osu.Game/Overlays/Mods/ModPresetPanel.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.UserInterface; using osu.Game.Configuration; @@ -17,7 +18,7 @@ using osu.Game.Rulesets.Mods; namespace osu.Game.Overlays.Mods { - public partial class ModPresetPanel : ModSelectPanel, IHasCustomTooltip, IHasContextMenu + public partial class ModPresetPanel : ModSelectPanel, IHasCustomTooltip, IHasContextMenu, IHasPopover { public readonly Live Preset; @@ -91,7 +92,8 @@ namespace osu.Game.Overlays.Mods public MenuItem[] ContextMenuItems => new MenuItem[] { - new OsuMenuItem(CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => dialogOverlay?.Push(new DeleteModPresetDialog(Preset))) + new OsuMenuItem(CommonStrings.ButtonsEdit, MenuItemType.Highlighted, this.ShowPopover), + new OsuMenuItem(CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => dialogOverlay?.Push(new DeleteModPresetDialog(Preset))), }; #endregion @@ -102,5 +104,7 @@ namespace osu.Game.Overlays.Mods settingChangeTracker?.Dispose(); } + + public Popover GetPopover() => new EditPresetPopover(Preset); } } diff --git a/osu.Game/Overlays/Mods/ModPresetRow.cs b/osu.Game/Overlays/Mods/ModPresetRow.cs new file mode 100644 index 0000000000..4829e93b87 --- /dev/null +++ b/osu.Game/Overlays/Mods/ModPresetRow.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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Overlays.Mods +{ + public partial class ModPresetRow : FillFlowContainer + { + public ModPresetRow(Mod mod) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + Direction = FillDirection.Vertical; + Spacing = new Vector2(4); + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(7), + Children = new Drawable[] + { + new ModSwitchTiny(mod) + { + Active = { Value = true }, + Scale = new Vector2(0.6f), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + }, + new OsuSpriteText + { + Text = mod.Name, + Font = OsuFont.Default.With(size: 16, weight: FontWeight.SemiBold), + Origin = Anchor.CentreLeft, + Anchor = Anchor.CentreLeft, + Margin = new MarginPadding { Bottom = 2 } + } + } + } + }; + + if (!string.IsNullOrEmpty(mod.SettingDescription)) + { + AddInternal(new OsuTextFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Left = 14 }, + Text = mod.SettingDescription + }); + } + } + } +} diff --git a/osu.Game/Overlays/Mods/ModPresetTooltip.cs b/osu.Game/Overlays/Mods/ModPresetTooltip.cs index ff4f00da69..8e8259de45 100644 --- a/osu.Game/Overlays/Mods/ModPresetTooltip.cs +++ b/osu.Game/Overlays/Mods/ModPresetTooltip.cs @@ -6,11 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.UI; using osuTK; namespace osu.Game.Overlays.Mods @@ -61,55 +57,5 @@ namespace osu.Game.Overlays.Mods protected override void PopOut() => this.FadeOut(transition_duration, Easing.OutQuint); public void Move(Vector2 pos) => Position = pos; - - private partial class ModPresetRow : FillFlowContainer - { - public ModPresetRow(Mod mod) - { - RelativeSizeAxes = Axes.X; - AutoSizeAxes = Axes.Y; - Direction = FillDirection.Vertical; - Spacing = new Vector2(4); - InternalChildren = new Drawable[] - { - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(7), - Children = new Drawable[] - { - new ModSwitchTiny(mod) - { - Active = { Value = true }, - Scale = new Vector2(0.6f), - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft - }, - new OsuSpriteText - { - Text = mod.Name, - Font = OsuFont.Default.With(size: 16, weight: FontWeight.SemiBold), - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - Margin = new MarginPadding { Bottom = 2 } - } - } - } - }; - - if (!string.IsNullOrEmpty(mod.SettingDescription)) - { - AddInternal(new OsuTextFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Left = 14 }, - Text = mod.SettingDescription - }); - } - } - } } } diff --git a/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs b/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs index d1a1850796..673ba873c4 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditorChangeHandler.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.IO; using System.Linq; @@ -56,20 +57,53 @@ namespace osu.Game.Overlays.SkinEditor if (deserializedContent == null) return; - SerialisedDrawableInfo[] skinnableInfo = deserializedContent.ToArray(); - Drawable[] targetComponents = firstTarget.Components.OfType().ToArray(); + SerialisedDrawableInfo[] skinnableInfos = deserializedContent.ToArray(); + ISerialisableDrawable[] targetComponents = firstTarget.Components.ToArray(); - if (!skinnableInfo.Select(s => s.Type).SequenceEqual(targetComponents.Select(d => d.GetType()))) + // Store components based on type for later reuse + var componentsPerTypeLookup = new Dictionary>(); + + foreach (ISerialisableDrawable component in targetComponents) { - // Perform a naive full reload for now. - firstTarget.Reload(skinnableInfo); + Type lookup = component.GetType(); + + if (!componentsPerTypeLookup.TryGetValue(lookup, out Queue? componentsOfSameType)) + componentsPerTypeLookup.Add(lookup, componentsOfSameType = new Queue()); + + componentsOfSameType.Enqueue((Drawable)component); } - else - { - int i = 0; - foreach (var drawable in targetComponents) - drawable.ApplySerialisedInfo(skinnableInfo[i++]); + for (int i = targetComponents.Length - 1; i >= 0; i--) + firstTarget.Remove(targetComponents[i], false); + + foreach (var skinnableInfo in skinnableInfos) + { + Type lookup = skinnableInfo.Type; + + if (!componentsPerTypeLookup.TryGetValue(lookup, out Queue? componentsOfSameType)) + { + firstTarget.Add((ISerialisableDrawable)skinnableInfo.CreateInstance()); + continue; + } + + // Wherever possible, attempt to reuse existing component instances. + if (componentsOfSameType.TryDequeue(out Drawable? component)) + { + component.ApplySerialisedInfo(skinnableInfo); + } + else + { + component = skinnableInfo.CreateInstance(); + } + + firstTarget.Add((ISerialisableDrawable)component); + } + + // Dispose components which were not reused. + foreach ((Type _, Queue typeComponents) in componentsPerTypeLookup) + { + foreach (var component in typeComponents) + component.Dispose(); } } } diff --git a/osu.Game/Rulesets/Edit/HitObjectInspector.cs b/osu.Game/Rulesets/Edit/HitObjectInspector.cs deleted file mode 100644 index 977d00ede2..0000000000 --- a/osu.Game/Rulesets/Edit/HitObjectInspector.cs +++ /dev/null @@ -1,146 +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.Linq; -using osu.Framework.Allocation; -using osu.Framework.Extensions.TypeExtensions; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Threading; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Overlays; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Types; -using osu.Game.Screens.Edit; - -namespace osu.Game.Rulesets.Edit -{ - internal partial class HitObjectInspector : CompositeDrawable - { - private OsuTextFlowContainer inspectorText = null!; - - [Resolved] - protected EditorBeatmap EditorBeatmap { get; private set; } = null!; - - [Resolved] - private OverlayColourProvider colourProvider { get; set; } = null!; - - [BackgroundDependencyLoader] - private void load() - { - AutoSizeAxes = Axes.Y; - RelativeSizeAxes = Axes.X; - - InternalChild = inspectorText = new OsuTextFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - }; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - EditorBeatmap.SelectedHitObjects.CollectionChanged += (_, _) => updateInspectorText(); - EditorBeatmap.TransactionBegan += updateInspectorText; - EditorBeatmap.TransactionEnded += updateInspectorText; - updateInspectorText(); - } - - private ScheduledDelegate? rollingTextUpdate; - - private void updateInspectorText() - { - inspectorText.Clear(); - rollingTextUpdate?.Cancel(); - rollingTextUpdate = null; - - switch (EditorBeatmap.SelectedHitObjects.Count) - { - case 0: - addValue("No selection"); - break; - - case 1: - var selected = EditorBeatmap.SelectedHitObjects.Single(); - - addHeader("Type"); - addValue($"{selected.GetType().ReadableName()}"); - - addHeader("Time"); - addValue($"{selected.StartTime:#,0.##}ms"); - - switch (selected) - { - case IHasPosition pos: - addHeader("Position"); - addValue($"x:{pos.X:#,0.##} y:{pos.Y:#,0.##}"); - break; - - case IHasXPosition x: - addHeader("Position"); - - addValue($"x:{x.X:#,0.##} "); - break; - - case IHasYPosition y: - addHeader("Position"); - - addValue($"y:{y.Y:#,0.##}"); - break; - } - - if (selected is IHasDistance distance) - { - addHeader("Distance"); - addValue($"{distance.Distance:#,0.##}px"); - } - - if (selected is IHasRepeats repeats) - { - addHeader("Repeats"); - addValue($"{repeats.RepeatCount:#,0.##}"); - } - - if (selected is IHasDuration duration) - { - addHeader("End Time"); - addValue($"{duration.EndTime:#,0.##}ms"); - addHeader("Duration"); - addValue($"{duration.Duration:#,0.##}ms"); - } - - // I'd hope there's a better way to do this, but I don't want to bind to each and every property above to watch for changes. - // This is a good middle-ground for the time being. - rollingTextUpdate ??= Scheduler.AddDelayed(updateInspectorText, 250); - break; - - default: - addHeader("Selected Objects"); - addValue($"{EditorBeatmap.SelectedHitObjects.Count:#,0.##}"); - - addHeader("Start Time"); - addValue($"{EditorBeatmap.SelectedHitObjects.Min(o => o.StartTime):#,0.##}ms"); - - addHeader("End Time"); - addValue($"{EditorBeatmap.SelectedHitObjects.Max(o => o.GetEndTime()):#,0.##}ms"); - break; - } - - void addHeader(string header) => inspectorText.AddParagraph($"{header}: ", s => - { - s.Padding = new MarginPadding { Top = 2 }; - s.Font = s.Font.With(size: 12); - s.Colour = colourProvider.Content2; - }); - - void addValue(string value) => inspectorText.AddParagraph(value, s => - { - s.Font = s.Font.With(weight: FontWeight.SemiBold); - s.Colour = colourProvider.Content1; - }); - } - } -} diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index 04d55bc5fe..787da926ea 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Framework.Testing; using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Rulesets.UI; using osu.Game.Utils; @@ -113,21 +114,29 @@ namespace osu.Game.Rulesets.Mods [JsonIgnore] public virtual Type[] IncompatibleMods => Array.Empty(); - private IReadOnlyList? settingsBacking; + private IReadOnlyDictionary? settingsBacking; /// - /// A list of the all settings within this mod. + /// All settings within this mod. /// - internal IReadOnlyList Settings => + /// + /// The settings are returned in ascending key order as per . + /// The ordering is intentionally enforced manually, as ordering of is unspecified. + /// + internal IEnumerable SettingsBindables => SettingsMap.OrderBy(pair => pair.Key).Select(pair => pair.Value); + + /// + /// Provides mapping of names to s of all settings within this mod. + /// + internal IReadOnlyDictionary SettingsMap => settingsBacking ??= this.GetSettingsSourceProperties() - .Select(p => p.Item2.GetValue(this)) - .Cast() - .ToList(); + .Select(p => p.Item2) + .ToDictionary(property => property.Name.ToSnakeCase(), property => (IBindable)property.GetValue(this)!); /// /// Whether all settings in this mod are set to their default state. /// - protected virtual bool UsesDefaultConfiguration => Settings.All(s => s.IsDefault); + protected virtual bool UsesDefaultConfiguration => SettingsBindables.All(s => s.IsDefault); /// /// Creates a copy of this initialised to a default state. @@ -148,15 +157,53 @@ namespace osu.Game.Rulesets.Mods if (source.GetType() != GetType()) throw new ArgumentException($"Expected mod of type {GetType()}, got {source.GetType()}.", nameof(source)); - foreach (var (_, prop) in this.GetSettingsSourceProperties()) + foreach (var (_, property) in this.GetSettingsSourceProperties()) { - var targetBindable = (IBindable)prop.GetValue(this)!; - var sourceBindable = (IBindable)prop.GetValue(source)!; + var targetBindable = (IBindable)property.GetValue(this)!; + var sourceBindable = (IBindable)property.GetValue(source)!; CopyAdjustedSetting(targetBindable, sourceBindable); } } + /// + /// This method copies the values of all settings from that share the same names with this mod instance. + /// The most frequent use of this is when switching rulesets, in order to preserve values of common settings during the switch. + /// + /// + /// The values are copied directly, without adjusting for possibly different allowed ranges of values. + /// If the value of a setting is not valid for this instance due to not falling inside of the allowed range, it will be clamped accordingly. + /// + /// The mod to extract settings from. + public void CopyCommonSettingsFrom(Mod source) + { + if (source.UsesDefaultConfiguration) + return; + + foreach (var (name, targetSetting) in SettingsMap) + { + if (!source.SettingsMap.TryGetValue(name, out IBindable? sourceSetting)) + continue; + + if (sourceSetting.IsDefault) + continue; + + var targetBindableType = targetSetting.GetType(); + var sourceBindableType = sourceSetting.GetType(); + + // if either the target is assignable to the source or the source is assignable to the target, + // then we presume that the data types contained in both bindables are compatible and we can proceed with the copy. + // this handles cases like `Bindable` and `BindableInt`. + if (!targetBindableType.IsAssignableFrom(sourceBindableType) && !sourceBindableType.IsAssignableFrom(targetBindableType)) + continue; + + // TODO: special case for handling number types + + PropertyInfo property = targetSetting.GetType().GetProperty(nameof(Bindable.Value))!; + property.SetValue(targetSetting, property.GetValue(sourceSetting)); + } + } + /// /// When creating copies or clones of a Mod, this method will be called /// to copy explicitly adjusted user settings from . @@ -191,7 +238,7 @@ namespace osu.Game.Rulesets.Mods if (ReferenceEquals(this, other)) return true; return GetType() == other.GetType() && - Settings.SequenceEqual(other.Settings, ModSettingsEqualityComparer.Default); + SettingsBindables.SequenceEqual(other.SettingsBindables, ModSettingsEqualityComparer.Default); } public override int GetHashCode() @@ -200,7 +247,7 @@ namespace osu.Game.Rulesets.Mods hashCode.Add(GetType()); - foreach (var setting in Settings) + foreach (var setting in SettingsBindables) hashCode.Add(setting.GetUnderlyingSettingValue()); return hashCode.ToHashCode(); diff --git a/osu.Game/Rulesets/Objects/Types/IHasSliderVelocity.cs b/osu.Game/Rulesets/Objects/Types/IHasSliderVelocity.cs index c0ac5036ee..80fd8dd8dc 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasSliderVelocity.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasSliderVelocity.cs @@ -3,17 +3,18 @@ using osu.Framework.Bindables; -namespace osu.Game.Rulesets.Objects.Types; - -/// -/// A HitObject that has a slider velocity multiplier. -/// -public interface IHasSliderVelocity +namespace osu.Game.Rulesets.Objects.Types { /// - /// The slider velocity multiplier. + /// A HitObject that has a slider velocity multiplier. /// - double SliderVelocity { get; set; } + public interface IHasSliderVelocity + { + /// + /// The slider velocity multiplier. + /// + double SliderVelocity { get; set; } - BindableNumber SliderVelocityBindable { get; } + BindableNumber SliderVelocityBindable { get; } + } } diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorInspector.cs b/osu.Game/Screens/Edit/Compose/Components/EditorInspector.cs new file mode 100644 index 0000000000..442454f97a --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/EditorInspector.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Overlays; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + internal partial class EditorInspector : CompositeDrawable + { + protected OsuTextFlowContainer InspectorText = null!; + + [Resolved] + protected EditorBeatmap EditorBeatmap { get; private set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Y; + RelativeSizeAxes = Axes.X; + + InternalChild = InspectorText = new OsuTextFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }; + } + + protected void AddHeader(string header) => InspectorText.AddParagraph($"{header}: ", s => + { + s.Padding = new MarginPadding { Top = 2 }; + s.Font = s.Font.With(size: 12); + s.Colour = colourProvider.Content2; + }); + + protected void AddValue(string value) => InspectorText.AddParagraph(value, s => + { + s.Font = s.Font.With(weight: FontWeight.SemiBold); + s.Colour = colourProvider.Content1; + }); + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/HitObjectInspector.cs b/osu.Game/Screens/Edit/Compose/Components/HitObjectInspector.cs new file mode 100644 index 0000000000..597925e3e2 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/HitObjectInspector.cs @@ -0,0 +1,111 @@ +// 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.Extensions.TypeExtensions; +using osu.Framework.Threading; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + internal partial class HitObjectInspector : EditorInspector + { + protected override void LoadComplete() + { + base.LoadComplete(); + + EditorBeatmap.SelectedHitObjects.CollectionChanged += (_, _) => updateInspectorText(); + EditorBeatmap.TransactionBegan += updateInspectorText; + EditorBeatmap.TransactionEnded += updateInspectorText; + updateInspectorText(); + } + + private ScheduledDelegate? rollingTextUpdate; + + private void updateInspectorText() + { + InspectorText.Clear(); + rollingTextUpdate?.Cancel(); + rollingTextUpdate = null; + + switch (EditorBeatmap.SelectedHitObjects.Count) + { + case 0: + AddValue("No selection"); + break; + + case 1: + var selected = EditorBeatmap.SelectedHitObjects.Single(); + + AddHeader("Type"); + AddValue($"{selected.GetType().ReadableName()}"); + + AddHeader("Time"); + AddValue($"{selected.StartTime:#,0.##}ms"); + + switch (selected) + { + case IHasPosition pos: + AddHeader("Position"); + AddValue($"x:{pos.X:#,0.##} y:{pos.Y:#,0.##}"); + break; + + case IHasXPosition x: + AddHeader("Position"); + + AddValue($"x:{x.X:#,0.##} "); + break; + + case IHasYPosition y: + AddHeader("Position"); + + AddValue($"y:{y.Y:#,0.##}"); + break; + } + + if (selected is IHasDistance distance) + { + AddHeader("Distance"); + AddValue($"{distance.Distance:#,0.##}px"); + } + + if (selected is IHasSliderVelocity sliderVelocity) + { + AddHeader("Slider Velocity"); + AddValue($"{sliderVelocity.SliderVelocity:#,0.00}x"); + } + + if (selected is IHasRepeats repeats) + { + AddHeader("Repeats"); + AddValue($"{repeats.RepeatCount:#,0.##}"); + } + + if (selected is IHasDuration duration) + { + AddHeader("End Time"); + AddValue($"{duration.EndTime:#,0.##}ms"); + AddHeader("Duration"); + AddValue($"{duration.Duration:#,0.##}ms"); + } + + // I'd hope there's a better way to do this, but I don't want to bind to each and every property above to watch for changes. + // This is a good middle-ground for the time being. + rollingTextUpdate ??= Scheduler.AddDelayed(updateInspectorText, 250); + break; + + default: + AddHeader("Selected Objects"); + AddValue($"{EditorBeatmap.SelectedHitObjects.Count:#,0.##}"); + + AddHeader("Start Time"); + AddValue($"{EditorBeatmap.SelectedHitObjects.Min(o => o.StartTime):#,0.##}ms"); + + AddHeader("End Time"); + AddValue($"{EditorBeatmap.SelectedHitObjects.Max(o => o.GetEndTime()):#,0.##}ms"); + break; + } + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs index 4741b75641..13a1c30cfe 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs @@ -95,7 +95,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, Text = "Hold shift while dragging the end of an object to adjust velocity while snapping." - } + }, + new SliderVelocityInspector(), } } }; @@ -105,7 +106,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline var relevantObjects = (beatmap.SelectedHitObjects.Contains(hitObject) ? beatmap.SelectedHitObjects : hitObject.Yield()).Where(o => o is IHasSliderVelocity).ToArray(); // even if there are multiple objects selected, we can still display a value if they all have the same value. - var selectedPointBindable = relevantObjects.Select(point => ((IHasSliderVelocity)point).SliderVelocity).Distinct().Count() == 1 ? ((IHasSliderVelocity)relevantObjects.First()).SliderVelocityBindable : null; + var selectedPointBindable = relevantObjects.Select(point => ((IHasSliderVelocity)point).SliderVelocity).Distinct().Count() == 1 + ? ((IHasSliderVelocity)relevantObjects.First()).SliderVelocityBindable + : null; if (selectedPointBindable != null) { @@ -139,4 +142,45 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } } } + + internal partial class SliderVelocityInspector : EditorInspector + { + [BackgroundDependencyLoader] + private void load() + { + EditorBeatmap.TransactionBegan += updateInspectorText; + EditorBeatmap.TransactionEnded += updateInspectorText; + updateInspectorText(); + } + + private void updateInspectorText() + { + InspectorText.Clear(); + + double[] sliderVelocities = EditorBeatmap.HitObjects.OfType().Select(sv => sv.SliderVelocity).OrderBy(v => v).ToArray(); + + if (sliderVelocities.Length < 2) + return; + + double? modeSliderVelocity = sliderVelocities.GroupBy(v => v).MaxBy(v => v.Count())?.Key; + double? medianSliderVelocity = sliderVelocities[sliderVelocities.Length / 2]; + + AddHeader("Average velocity"); + AddValue($"{medianSliderVelocity:#,0.00}x"); + + AddHeader("Most used velocity"); + AddValue($"{modeSliderVelocity:#,0.00}x"); + + AddHeader("Velocity range"); + AddValue($"{sliderVelocities.First():#,0.00}x - {sliderVelocities.Last():#,0.00}x"); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + EditorBeatmap.TransactionBegan -= updateInspectorText; + EditorBeatmap.TransactionEnded -= updateInspectorText; + } + } } diff --git a/osu.Game/Screens/Menu/IntroWelcome.cs b/osu.Game/Screens/Menu/IntroWelcome.cs index da44161507..2a6ebecb92 100644 --- a/osu.Game/Screens/Menu/IntroWelcome.cs +++ b/osu.Game/Screens/Menu/IntroWelcome.cs @@ -131,7 +131,7 @@ namespace osu.Game.Screens.Menu Anchor = Anchor.Centre, Origin = Anchor.Centre, Colour = Color4.DarkBlue, - Size = new Vector2(0.96f) + Size = OsuLogo.SCALE_ADJUST, }, new Circle { diff --git a/osu.Game/Screens/Menu/OsuLogo.cs b/osu.Game/Screens/Menu/OsuLogo.cs index 9430a1cda8..277b8bf888 100644 --- a/osu.Game/Screens/Menu/OsuLogo.cs +++ b/osu.Game/Screens/Menu/OsuLogo.cs @@ -35,6 +35,12 @@ namespace osu.Game.Screens.Menu private const double transition_length = 300; + /// + /// The osu! logo sprite has a shadow included in its texture. + /// This adjustment vector is used to match the precise edge of the border of the logo. + /// + public static readonly Vector2 SCALE_ADJUST = new Vector2(0.96f); + private readonly Sprite logo; private readonly CircularContainer logoContainer; private readonly Container logoBounceContainer; @@ -150,7 +156,7 @@ namespace osu.Game.Screens.Menu Origin = Anchor.Centre, Anchor = Anchor.Centre, Alpha = visualizer_default_alpha, - Size = new Vector2(0.96f) + Size = SCALE_ADJUST }, new Container { @@ -162,7 +168,7 @@ namespace osu.Game.Screens.Menu Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - Scale = new Vector2(0.88f), + Scale = SCALE_ADJUST, Masking = true, Children = new Drawable[] { @@ -406,7 +412,7 @@ namespace osu.Game.Screens.Menu public void Impact() { impactContainer.FadeOutFromOne(250, Easing.In); - impactContainer.ScaleTo(0.96f); + impactContainer.ScaleTo(SCALE_ADJUST); impactContainer.ScaleTo(1.12f, 250); } diff --git a/osu.Game/Screens/Play/Break/BlurredIcon.cs b/osu.Game/Screens/Play/Break/BlurredIcon.cs index cd38390324..6ce1c2e686 100644 --- a/osu.Game/Screens/Play/Break/BlurredIcon.cs +++ b/osu.Game/Screens/Play/Break/BlurredIcon.cs @@ -27,7 +27,7 @@ namespace osu.Game.Screens.Play.Break set { icon.Size = value; - base.Size = value + BlurSigma * 2.5f; + base.Size = value + BlurSigma * 5; ForceRedraw(); } get => base.Size; diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 085f78b27b..7ab9810ab5 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 127994c670..bfa0dc63bb 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -16,6 +16,6 @@ iossimulator-x64 - + diff --git a/osu.sln.DotSettings b/osu.sln.DotSettings index 367dfccb71..d7486273fb 100644 --- a/osu.sln.DotSettings +++ b/osu.sln.DotSettings @@ -277,6 +277,7 @@ Explicit ExpressionBody BlockBody + BlockScoped ExplicitlyTyped True NEXT_LINE