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