Merge branch 'master' into mania-fix-hold-note-rewind

This commit is contained in:
Dean Herbert
2023-05-08 15:42:45 +09:00
63 changed files with 1647 additions and 517 deletions

View File

@ -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

View File

@ -11,7 +11,7 @@
<AndroidManifestMerger>manifestmerger.jar</AndroidManifestMerger>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2023.418.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2023.506.0" />
</ItemGroup>
<ItemGroup>
<AndroidManifestOverlay Include="$(MSBuildThisFileDirectory)osu.Android\Properties\AndroidManifestOverlay.xml" />

View File

@ -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()
{

View File

@ -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)

View File

@ -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)

View File

@ -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;
}
}

View File

@ -3,11 +3,13 @@
using System.Diagnostics;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Skinning;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{
@ -18,6 +20,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
private Drawable proxy = null!;
private Bindable<Color4> accentColour = null!;
private bool textureIsDefaultSkin;
private Drawable arrow = null!;
[BackgroundDependencyLoader]
private void load(ISkinSource skinSource)
{
@ -26,7 +34,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
string lookupName = new OsuSkinComponentLookup(OsuSkinComponents.ReverseArrow).LookupName;
var skin = skinSource.FindProvider(s => s.GetTexture(lookupName) != null);
InternalChild = skin?.GetAnimation(lookupName, true, true) ?? Empty();
InternalChild = arrow = (skin?.GetAnimation(lookupName, true, true) ?? Empty());
textureIsDefaultSkin = skin is ISkinTransformer transformer && transformer.Skin is DefaultLegacySkin;
}
protected override void LoadComplete()
@ -39,6 +49,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{
drawableHitObject.HitObjectApplied += onHitObjectApplied;
onHitObjectApplied(drawableHitObject);
accentColour = drawableHitObject.AccentColour.GetBoundCopy();
accentColour.BindValueChanged(c =>
{
arrow.Colour = textureIsDefaultSkin && c.NewValue.R + c.NewValue.G + c.NewValue.B > (600 / 255f) ? Color4.Black : Color4.White;
}, true);
}
}

View File

@ -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;

View File

@ -1,19 +1,26 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Game.Database;
using osu.Game.Overlays.Notifications;
using Realms;
namespace osu.Game.Tests.Database
{
[TestFixture]
public class LegacyExporterTest
public class LegacyModelExporterTest
{
private TestLegacyExporter legacyExporter = null!;
private TestLegacyModelExporter legacyExporter = null!;
private TemporaryNativeStorage storage = null!;
private const string short_filename = "normal file name";
@ -25,15 +32,15 @@ namespace osu.Game.Tests.Database
public void SetUp()
{
storage = new TemporaryNativeStorage("export-storage");
legacyExporter = new TestLegacyExporter(storage);
legacyExporter = new TestLegacyModelExporter(storage);
}
[Test]
public void ExportFileWithNormalNameTest()
{
var item = new TestPathInfo(short_filename);
var item = new TestModel(short_filename);
Assert.That(item.Filename.Length, Is.LessThan(TestLegacyExporter.MAX_FILENAME_LENGTH));
Assert.That(item.Filename.Length, Is.LessThan(TestLegacyModelExporter.MAX_FILENAME_LENGTH));
exportItemAndAssert(item, short_filename);
}
@ -41,9 +48,9 @@ namespace osu.Game.Tests.Database
[Test]
public void ExportFileWithNormalNameMultipleTimesTest()
{
var item = new TestPathInfo(short_filename);
var item = new TestModel(short_filename);
Assert.That(item.Filename.Length, Is.LessThan(TestLegacyExporter.MAX_FILENAME_LENGTH));
Assert.That(item.Filename.Length, Is.LessThan(TestLegacyModelExporter.MAX_FILENAME_LENGTH));
//Export multiple times
for (int i = 0; i < 100; i++)
@ -56,24 +63,24 @@ namespace osu.Game.Tests.Database
[Test]
public void ExportFileWithSuperLongNameTest()
{
int expectedLength = TestLegacyExporter.MAX_FILENAME_LENGTH - (legacyExporter.GetExtension().Length);
int expectedLength = TestLegacyModelExporter.MAX_FILENAME_LENGTH - (legacyExporter.GetExtension().Length);
string expectedName = long_filename.Remove(expectedLength);
var item = new TestPathInfo(long_filename);
var item = new TestModel(long_filename);
Assert.That(item.Filename.Length, Is.GreaterThan(TestLegacyExporter.MAX_FILENAME_LENGTH));
Assert.That(item.Filename.Length, Is.GreaterThan(TestLegacyModelExporter.MAX_FILENAME_LENGTH));
exportItemAndAssert(item, expectedName);
}
[Test]
public void ExportFileWithSuperLongNameMultipleTimesTest()
{
int expectedLength = TestLegacyExporter.MAX_FILENAME_LENGTH - (legacyExporter.GetExtension().Length);
int expectedLength = TestLegacyModelExporter.MAX_FILENAME_LENGTH - (legacyExporter.GetExtension().Length);
string expectedName = long_filename.Remove(expectedLength);
var item = new TestPathInfo(long_filename);
var item = new TestModel(long_filename);
Assert.That(item.Filename.Length, Is.GreaterThan(TestLegacyExporter.MAX_FILENAME_LENGTH));
Assert.That(item.Filename.Length, Is.GreaterThan(TestLegacyModelExporter.MAX_FILENAME_LENGTH));
//Export multiple times
for (int i = 0; i < 100; i++)
@ -83,9 +90,12 @@ namespace osu.Game.Tests.Database
}
}
private void exportItemAndAssert(IHasNamedFiles item, string expectedName)
private void exportItemAndAssert(TestModel item, string expectedName)
{
Assert.DoesNotThrow(() => legacyExporter.Export(item));
Assert.DoesNotThrow(() =>
{
Task.Run(() => legacyExporter.ExportAsync(new RealmLiveUnmanaged<TestModel>(item))).WaitSafely();
});
Assert.That(storage.Exists($"exports/{expectedName}{legacyExporter.GetExtension()}"), Is.True);
}
@ -96,30 +106,36 @@ namespace osu.Game.Tests.Database
storage.Dispose();
}
private class TestPathInfo : IHasNamedFiles
private class TestLegacyModelExporter : LegacyExporter<TestModel>
{
public string Filename { get; }
public IEnumerable<INamedFileUsage> Files { get; } = new List<INamedFileUsage>();
public TestPathInfo(string filename)
{
Filename = filename;
}
public override string ToString() => Filename;
}
private class TestLegacyExporter : LegacyExporter<IHasNamedFiles>
{
public TestLegacyExporter(Storage storage)
public TestLegacyModelExporter(Storage storage)
: base(storage)
{
}
public string GetExtension() => FileExtension;
public override void ExportToStream(TestModel model, Stream outputStream, ProgressNotification? notification, CancellationToken cancellationToken = default)
{
}
protected override string FileExtension => ".test";
}
private class TestModel : RealmObject, IHasNamedFiles, IHasGuidPrimaryKey
{
public Guid ID => Guid.Empty;
public string Filename { get; }
public IEnumerable<INamedFileUsage> Files { get; } = new List<INamedFileUsage>();
public TestModel(string filename)
{
Filename = filename;
}
public override string ToString() => Filename;
}
}
}

View File

@ -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));
}
}
}

View File

@ -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<double> TestSetting { get; } = new BindableDouble();
}
private class TestNonMatchingSettingTypeModInt : TestNonMatchingSettingTypeMod
{
public override string Acronym => "NMI";
public override BindableNumber<int> TestSetting { get; } = new BindableInt();
}
private class TestNonMatchingSettingTypeModBool : TestNonMatchingSettingTypeMod
{
public override string Acronym => "NMB";
public override Bindable<bool> 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; }
}
}
}

View File

@ -10,7 +10,6 @@ using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions;
using osu.Framework.Platform;
using osu.Game.Database;
using osu.Game.Extensions;
@ -120,10 +119,7 @@ namespace osu.Game.Tests.Skins.IO
var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 1", "author 1"), "custom.osk"));
assertCorrectMetadata(import1, "name 1 [custom]", "author 1", osu);
import1.PerformRead(s =>
{
new LegacySkinExporter(osu.Dependencies.Get<Storage>()).ExportModelTo(s, exportStream);
});
await new LegacySkinExporter(osu.Dependencies.Get<Storage>()).ExportToStreamAsync(import1, exportStream);
string exportFilename = import1.GetDisplayString();
@ -141,10 +137,7 @@ namespace osu.Game.Tests.Skins.IO
var import1 = await loadSkinIntoOsu(osu, new ImportTask(createOskWithIni("name 『1』", "author 1"), "custom.osk"));
assertCorrectMetadata(import1, "name 『1』 [custom]", "author 1", osu);
import1.PerformRead(s =>
{
new LegacySkinExporter(osu.Dependencies.Get<Storage>()).ExportModelTo(s, exportStream);
});
await new LegacySkinExporter(osu.Dependencies.Get<Storage>()).ExportToStreamAsync(import1, exportStream);
string exportFilename = import1.GetDisplayString().GetValidFilename();
@ -208,7 +201,7 @@ namespace osu.Game.Tests.Skins.IO
});
[Test]
public Task TestExportThenImportDefaultSkin() => runSkinTest(osu =>
public Task TestExportThenImportDefaultSkin() => runSkinTest(async osu =>
{
var skinManager = osu.Dependencies.Get<SkinManager>();
@ -218,30 +211,28 @@ namespace osu.Game.Tests.Skins.IO
Guid originalSkinId = skinManager.CurrentSkinInfo.Value.ID;
skinManager.CurrentSkinInfo.Value.PerformRead(s =>
await skinManager.CurrentSkinInfo.Value.PerformRead(async s =>
{
Assert.IsFalse(s.Protected);
Assert.AreEqual(typeof(ArgonSkin), s.CreateInstance(skinManager).GetType());
new LegacySkinExporter(osu.Dependencies.Get<Storage>()).ExportModelTo(s, exportStream);
await new LegacySkinExporter(osu.Dependencies.Get<Storage>()).ExportToStreamAsync(skinManager.CurrentSkinInfo.Value, exportStream);
Assert.Greater(exportStream.Length, 0);
});
var imported = skinManager.Import(new ImportTask(exportStream, "exported.osk"));
var imported = await skinManager.Import(new ImportTask(exportStream, "exported.osk"));
imported.GetResultSafely().PerformRead(s =>
imported.PerformRead(s =>
{
Assert.IsFalse(s.Protected);
Assert.AreNotEqual(originalSkinId, s.ID);
Assert.AreEqual(typeof(ArgonSkin), s.CreateInstance(skinManager).GetType());
});
return Task.CompletedTask;
});
[Test]
public Task TestExportThenImportClassicSkin() => runSkinTest(osu =>
public Task TestExportThenImportClassicSkin() => runSkinTest(async osu =>
{
var skinManager = osu.Dependencies.Get<SkinManager>();
@ -253,26 +244,24 @@ namespace osu.Game.Tests.Skins.IO
Guid originalSkinId = skinManager.CurrentSkinInfo.Value.ID;
skinManager.CurrentSkinInfo.Value.PerformRead(s =>
await skinManager.CurrentSkinInfo.Value.PerformRead(async s =>
{
Assert.IsFalse(s.Protected);
Assert.AreEqual(typeof(DefaultLegacySkin), s.CreateInstance(skinManager).GetType());
new LegacySkinExporter(osu.Dependencies.Get<Storage>()).ExportModelTo(s, exportStream);
await new LegacySkinExporter(osu.Dependencies.Get<Storage>()).ExportToStreamAsync(skinManager.CurrentSkinInfo.Value, exportStream);
Assert.Greater(exportStream.Length, 0);
});
var imported = skinManager.Import(new ImportTask(exportStream, "exported.osk"));
var imported = await skinManager.Import(new ImportTask(exportStream, "exported.osk"));
imported.GetResultSafely().PerformRead(s =>
imported.PerformRead(s =>
{
Assert.IsFalse(s.Protected);
Assert.AreNotEqual(originalSkinId, s.ID);
Assert.AreEqual(typeof(DefaultLegacySkin), s.CreateInstance(skinManager).GetType());
});
return Task.CompletedTask;
});
#endregion

View File

@ -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<ISerialisableDrawable> testComponents = null!;
AddStep("Load necessary things", () =>
{
firstTarget = Player.ChildrenOfType<SkinComponentsContainer>().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<BigBlackBox>().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;
}
}
}
}

View File

@ -98,6 +98,10 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddStep($"select {requiredMod.ReadableName()} as required", () => songSelect.Mods.Value = new[] { (Mod)Activator.CreateInstance(requiredMod) });
AddAssert("freemods empty", () => songSelect.FreeMods.Value.Count == 0);
// A previous test's mod overlay could still be fading out.
AddUntilStep("wait for only one freemod overlay", () => this.ChildrenOfType<FreeModSelectOverlay>().Count() == 1);
assertHasFreeModButton(allowedMod, false);
assertHasFreeModButton(requiredMod, false);
}

View File

@ -700,7 +700,7 @@ namespace osu.Game.Tests.Visual.Navigation
AddStep("press escape twice rapidly", () =>
{
InputManager.Key(Key.Escape);
InputManager.Key(Key.Escape);
Schedule(InputManager.Key, Key.Escape);
});
pushEscape();

View File

@ -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<ChatLine>().First().ShowPopover());
AddStep("Set report reason to other", () =>
{
var reason = this.ChildrenOfType<OsuEnumDropdown<ChatReportReason>>().Single();
reason.Current.Value = ChatReportReason.Other;
});
AddStep("Try to report", () =>
{
var btn = this.ChildrenOfType<ReportChatPopover>().Single().ChildrenOfType<RoundedButton>().Single();
InputManager.MoveMouseTo(btn);
InputManager.Click(MouseButton.Left);
});
AddAssert("Nothing happened", () => this.ChildrenOfType<ReportChatPopover>().Any());
AddStep("Set report data", () =>
{
var field = this.ChildrenOfType<ReportChatPopover>().Single().ChildrenOfType<OsuTextBox>().Single();
field.Current.Value = "test other";
});
AddStep("Try to report", () =>
{
var btn = this.ChildrenOfType<ReportChatPopover>().Single().ChildrenOfType<RoundedButton>().Single();
InputManager.MoveMouseTo(btn);
InputManager.Click(MouseButton.Left);
});
AddUntilStep("Overlay closed", () => !this.ChildrenOfType<ReportChatPopover>().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]));

View File

@ -243,7 +243,7 @@ namespace osu.Game.Tests.Visual.UserInterface
AddUntilStep("wait for context menu", () => this.ChildrenOfType<OsuContextMenu>().Any());
AddStep("click delete", () =>
{
var deleteItem = this.ChildrenOfType<DrawableOsuMenuItem>().Single();
var deleteItem = this.ChildrenOfType<DrawableOsuMenuItem>().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<ModPreset>().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<Mod>());
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<ModPresetPanel>().First();
presetName = panel.Preset.Value.Name;
InputManager.MoveMouseTo(panel);
InputManager.Click(MouseButton.Right);
});
AddUntilStep("wait for context menu", () => this.ChildrenOfType<OsuContextMenu>().Any());
AddStep("click edit", () =>
{
var editItem = this.ChildrenOfType<DrawableOsuMenuItem>().ElementAt(0);
InputManager.MoveMouseTo(editItem);
InputManager.Click(MouseButton.Left);
});
OsuPopover? popover = null;
AddUntilStep("wait for popover", () => (popover = this.ChildrenOfType<OsuPopover>().FirstOrDefault()) != null);
AddStep("clear preset name", () => popover.ChildrenOfType<LabelledTextBox>().First().Current.Value = "");
AddStep("attempt preset edit", () =>
{
InputManager.MoveMouseTo(popover.ChildrenOfType<ShearedButton>().ElementAt(1));
InputManager.Click(MouseButton.Left);
});
AddAssert("preset is not changed", () => panel.Preset.Value.Name == presetName);
AddUntilStep("popover is unchanged", () => this.ChildrenOfType<OsuPopover>().FirstOrDefault() == popover);
AddStep("edit preset name", () => popover.ChildrenOfType<LabelledTextBox>().First().Current.Value = "something new");
AddStep("attempt preset edit", () =>
{
InputManager.MoveMouseTo(popover.ChildrenOfType<ShearedButton>().ElementAt(1));
InputManager.Click(MouseButton.Left);
});
AddUntilStep("popover closed", () => !this.ChildrenOfType<OsuPopover>().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<Mod> previousMod = null!;
AddStep("clear mods", () => SelectedMods.Value = Array.Empty<Mod>());
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<ModPresetPanel>().First();
previousMod = panel.Preset.Value.Mods.ToList();
InputManager.MoveMouseTo(panel);
InputManager.Click(MouseButton.Right);
});
AddUntilStep("wait for context menu", () => this.ChildrenOfType<OsuContextMenu>().Any());
AddStep("click edit", () =>
{
var editItem = this.ChildrenOfType<DrawableOsuMenuItem>().ElementAt(0);
InputManager.MoveMouseTo(editItem);
InputManager.Click(MouseButton.Left);
});
OsuPopover? popover = null;
AddUntilStep("wait for popover", () => (popover = this.ChildrenOfType<OsuPopover>().FirstOrDefault()) != null);
AddStep("click use current mods", () =>
{
InputManager.MoveMouseTo(popover.ChildrenOfType<ShearedButton>().ElementAt(0));
InputManager.Click(MouseButton.Left);
});
AddStep("attempt preset edit", () =>
{
InputManager.MoveMouseTo(popover.ChildrenOfType<ShearedButton>().ElementAt(1));
InputManager.Click(MouseButton.Left);
});
AddUntilStep("preset mod not changed", () =>
new HashSet<Mod>(this.ChildrenOfType<ModPresetPanel>().First().Preset.Value.Mods).SetEquals(previousMod));
AddStep("select mods", () => SelectedMods.Value = mods);
AddStep("right click first panel", () =>
{
var panel = this.ChildrenOfType<ModPresetPanel>().First();
InputManager.MoveMouseTo(panel);
InputManager.Click(MouseButton.Right);
});
AddUntilStep("wait for context menu", () => this.ChildrenOfType<OsuContextMenu>().Any());
AddStep("click edit", () =>
{
var editItem = this.ChildrenOfType<DrawableOsuMenuItem>().ElementAt(0);
InputManager.MoveMouseTo(editItem);
InputManager.Click(MouseButton.Left);
});
AddUntilStep("wait for popover", () => (popover = this.ChildrenOfType<OsuPopover>().FirstOrDefault()) != null);
AddStep("click use current mods", () =>
{
InputManager.MoveMouseTo(popover.ChildrenOfType<ShearedButton>().ElementAt(0));
InputManager.Click(MouseButton.Left);
});
AddStep("attempt preset edit", () =>
{
InputManager.MoveMouseTo(popover.ChildrenOfType<ShearedButton>().ElementAt(1));
InputManager.Click(MouseButton.Left);
});
AddUntilStep("preset mod is changed", () =>
new HashSet<Mod>(this.ChildrenOfType<ModPresetPanel>().First().Preset.Value.Mods).SetEquals(mods));
}
private ICollection<ModPreset> createTestPresets() => new[]
{
new ModPreset

View File

@ -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<ModDifficultyAdjust>()! });
changeRuleset(0);
AddAssert("ensure mod still selected", () => SelectedMods.Value.SingleOrDefault() is OsuModDifficultyAdjust);
AddStep("change mod settings", () =>
{
var osuMod = getSelectedMod<OsuModDifficultyAdjust>();
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<TaikoModDifficultyAdjust>();
return taikoMod.ExtendedLimits.Value &&
taikoMod.DrainRate.Value == setting_change &&
taikoMod.OverallDifficulty.Value == setting_change;
});
AddAssert("non-shared settings remain default", () =>
{
var taikoMod = getSelectedMod<TaikoModDifficultyAdjust>();
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<T>() where T : Mod => SelectedMods.Value.OfType<T>().Single();
private ModPanel getPanelForMod(Type modType)
=> modSelectOverlay.ChildrenOfType<ModPanel>().Single(panel => panel.Mod.GetType() == modType);

View File

@ -42,6 +42,8 @@ namespace osu.Game.Beatmaps
private readonly WorkingBeatmapCache workingBeatmapCache;
private readonly LegacyBeatmapExporter beatmapExporter;
public ProcessBeatmapDelegate? ProcessBeatmap { private get; set; }
public override bool PauseImports
@ -76,6 +78,11 @@ namespace osu.Game.Beatmaps
beatmapImporter.PostNotification = obj => PostNotification?.Invoke(obj);
workingBeatmapCache = CreateWorkingBeatmapCache(audioManager, gameResources, userResources, defaultBeatmap, host);
beatmapExporter = new LegacyBeatmapExporter(storage)
{
PostNotification = obj => PostNotification?.Invoke(obj)
};
}
protected virtual WorkingBeatmapCache CreateWorkingBeatmapCache(AudioManager audioManager, IResourceStore<byte[]> resources, IResourceStore<byte[]> storage, WorkingBeatmap? defaultBeatmap,
@ -393,6 +400,8 @@ namespace osu.Game.Beatmaps
public Task<Live<BeatmapSetInfo>?> ImportAsUpdate(ProgressNotification notification, ImportTask importTask, BeatmapSetInfo original) =>
beatmapImporter.ImportAsUpdate(notification, importTask, original);
public Task Export(BeatmapSetInfo beatmap) => beatmapExporter.ExportAsync(beatmap.ToLive(Realm));
private void updateHashAndMarkDirty(BeatmapSetInfo setInfo)
{
setInfo.Hash = beatmapImporter.ComputeHash(setInfo);

View File

@ -0,0 +1,69 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.IO;
using System.Linq;
using System.Threading;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Extensions;
using osu.Game.Overlays.Notifications;
using Realms;
using SharpCompress.Common;
using SharpCompress.Writers;
using SharpCompress.Writers.Zip;
using Logger = osu.Framework.Logging.Logger;
namespace osu.Game.Database
{
/// <summary>
/// Handles the common scenario of exporting a model to a zip-based archive, usually with a custom file extension.
/// </summary>
public abstract class LegacyArchiveExporter<TModel> : LegacyExporter<TModel>
where TModel : RealmObject, IHasNamedFiles, IHasGuidPrimaryKey
{
protected LegacyArchiveExporter(Storage storage)
: base(storage)
{
}
public override void ExportToStream(TModel model, Stream outputStream, ProgressNotification? notification, CancellationToken cancellationToken = default)
{
using (var writer = new ZipWriter(outputStream, new ZipWriterOptions(CompressionType.Deflate)))
{
int i = 0;
int fileCount = model.Files.Count();
bool anyFileMissing = false;
foreach (var file in model.Files)
{
cancellationToken.ThrowIfCancellationRequested();
using (var stream = UserFileStorage.GetStream(file.File.GetStoragePath()))
{
if (stream == null)
{
Logger.Log($"File {file.Filename} is missing in local storage and will not be included in the export", LoggingTarget.Database);
anyFileMissing = true;
continue;
}
writer.Write(file.Filename, stream);
}
i++;
if (notification != null)
{
notification.Progress = (float)i / fileCount;
}
}
if (anyFileMissing)
{
Logger.Log("Some files are missing in local storage and will not be included in the export", LoggingTarget.Database, LogLevel.Error);
}
}
}
}
}

View File

@ -1,20 +1,18 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using osu.Framework.Platform;
using osu.Game.Beatmaps;
namespace osu.Game.Database
{
public class LegacyBeatmapExporter : LegacyExporter<BeatmapSetInfo>
public class LegacyBeatmapExporter : LegacyArchiveExporter<BeatmapSetInfo>
{
protected override string FileExtension => ".osz";
public LegacyBeatmapExporter(Storage storage)
: base(storage)
{
}
protected override string FileExtension => @".osz";
}
}

View File

@ -1,24 +1,25 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Platform;
using osu.Game.Extensions;
using osu.Game.Overlays.Notifications;
using osu.Game.Utils;
using SharpCompress.Archives.Zip;
using Realms;
namespace osu.Game.Database
{
/// <summary>
/// A class which handles exporting legacy user data of a single type from osu-stable.
/// Handles exporting models to files for sharing / consumption outside the game.
/// </summary>
public abstract class LegacyExporter<TModel>
where TModel : class, IHasNamedFiles
where TModel : RealmObject, IHasNamedFiles, IHasGuidPrimaryKey
{
/// <summary>
/// Max length of filename (including extension).
@ -39,55 +40,93 @@ namespace osu.Game.Database
protected abstract string FileExtension { get; }
protected readonly Storage UserFileStorage;
private readonly Storage exportStorage;
public Action<Notification>? PostNotification { get; set; }
protected LegacyExporter(Storage storage)
{
exportStorage = storage.GetStorageForDirectory(@"exports");
UserFileStorage = storage.GetStorageForDirectory(@"files");
}
/// <summary>
/// Returns the baseline name of the file to which the <paramref name="item"/> will be exported.
/// </summary>
/// <remarks>
/// The name of the file will be run through <see cref="ModelExtensions.GetValidFilename"/> to eliminate characters
/// which are not permitted by various filesystems.
/// </remarks>
/// <param name="item">The item being exported.</param>
protected virtual string GetFilename(TModel item) => item.GetDisplayString();
/// <summary>
/// Exports an item to a legacy (.zip based) package.
/// Exports a model to the default export location.
/// This will create a notification tracking the progress of the export, visible to the user.
/// </summary>
/// <param name="item">The item to export.</param>
public void Export(TModel item)
/// <param name="model">The model to export.</param>
/// <param name="cancellationToken">A cancellation token.</param>
public async Task ExportAsync(Live<TModel> model, CancellationToken cancellationToken = default)
{
string itemFilename = GetFilename(item).GetValidFilename();
string itemFilename = model.PerformRead(s => GetFilename(s).GetValidFilename());
if (itemFilename.Length > MAX_FILENAME_LENGTH - FileExtension.Length)
itemFilename = itemFilename.Remove(MAX_FILENAME_LENGTH - FileExtension.Length);
IEnumerable<string> existingExports =
exportStorage
.GetFiles(string.Empty, $"{itemFilename}*{FileExtension}")
.Concat(exportStorage.GetDirectories(string.Empty));
IEnumerable<string> existingExports = exportStorage
.GetFiles(string.Empty, $"{itemFilename}*{FileExtension}")
.Concat(exportStorage.GetDirectories(string.Empty));
string filename = NamingUtils.GetNextBestFilename(existingExports, $"{itemFilename}{FileExtension}");
using (var stream = exportStorage.CreateFileSafely(filename))
ExportModelTo(item, stream);
ProgressNotification notification = new ProgressNotification
{
State = ProgressNotificationState.Active,
Text = $"Exporting {itemFilename}...",
};
exportStorage.PresentFileExternally(filename);
PostNotification?.Invoke(notification);
using var linkedSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, notification.CancellationToken);
try
{
using (var stream = exportStorage.CreateFileSafely(filename))
{
await ExportToStreamAsync(model, stream, notification, linkedSource.Token).ConfigureAwait(false);
}
}
catch
{
notification.State = ProgressNotificationState.Cancelled;
// cleanup if export is failed or canceled.
exportStorage.Delete(filename);
throw;
}
notification.CompletionText = $"Exported {itemFilename}! Click to view.";
notification.CompletionClickAction = () => exportStorage.PresentFileExternally(filename);
notification.State = ProgressNotificationState.Completed;
}
/// <summary>
/// Exports an item to the given output stream.
/// Exports a model to a provided stream.
/// </summary>
/// <param name="model">The item to export.</param>
/// <param name="model">The model to export.</param>
/// <param name="outputStream">The output stream to export to.</param>
public virtual void ExportModelTo(TModel model, Stream outputStream)
{
using (var archive = ZipArchive.Create())
{
foreach (var file in model.Files)
archive.AddEntry(file.Filename, UserFileStorage.GetStream(file.File.GetStoragePath()));
/// <param name="notification">An optional notification to be updated with export progress.</param>
/// <param name="cancellationToken">A cancellation token.</param>
public Task ExportToStreamAsync(Live<TModel> model, Stream outputStream, ProgressNotification? notification = null, CancellationToken cancellationToken = default) =>
Task.Run(() => { model.PerformRead(s => ExportToStream(s, outputStream, notification, cancellationToken)); }, cancellationToken);
archive.SaveTo(outputStream);
}
}
/// <summary>
/// Exports a model to a provided stream.
/// </summary>
/// <param name="model">The model to export.</param>
/// <param name="outputStream">The output stream to export to.</param>
/// <param name="notification">An optional notification to be updated with export progress.</param>
/// <param name="cancellationToken">A cancellation token.</param>
public abstract void ExportToStream(TModel model, Stream outputStream, ProgressNotification? notification, CancellationToken cancellationToken = default);
}
}

View File

@ -1,20 +1,18 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System.IO;
using System.Linq;
using System.Threading;
using osu.Framework.Platform;
using osu.Game.Extensions;
using osu.Game.Overlays.Notifications;
using osu.Game.Scoring;
namespace osu.Game.Database
{
public class LegacyScoreExporter : LegacyExporter<ScoreInfo>
{
protected override string FileExtension => ".osr";
public LegacyScoreExporter(Storage storage)
: base(storage)
{
@ -28,7 +26,9 @@ namespace osu.Game.Database
return filename;
}
public override void ExportModelTo(ScoreInfo model, Stream outputStream)
protected override string FileExtension => @".osr";
public override void ExportToStream(ScoreInfo model, Stream outputStream, ProgressNotification? notification, CancellationToken cancellationToken = default)
{
var file = model.Files.SingleOrDefault();
if (file == null)

View File

@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System;
using System.Collections.Generic;
using System.IO;
@ -22,7 +20,7 @@ namespace osu.Game.Database
return Enumerable.Empty<string>();
return storage.GetFiles(ImportFromStablePath)
.Where(p => Importer.HandledExtensions.Any(ext => Path.GetExtension(p)?.Equals(ext, StringComparison.OrdinalIgnoreCase) ?? false))
.Where(p => Importer.HandledExtensions.Any(ext => Path.GetExtension(p).Equals(ext, StringComparison.OrdinalIgnoreCase)))
.Select(path => storage.GetFullPath(path));
}

View File

@ -1,20 +1,18 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using osu.Framework.Platform;
using osu.Game.Skinning;
namespace osu.Game.Database
{
public class LegacySkinExporter : LegacyExporter<SkinInfo>
public class LegacySkinExporter : LegacyArchiveExporter<SkinInfo>
{
protected override string FileExtension => ".osk";
public LegacySkinExporter(Storage storage)
: base(storage)
{
}
protected override string FileExtension => @".osk";
}
}

View File

@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using osu.Game.Skinning;
namespace osu.Game.Database

View File

@ -49,11 +49,10 @@ namespace osu.Game.Graphics.UserInterface
private const float transition_length = 500;
private Sample sampleChecked;
private Sample sampleUnchecked;
private readonly SpriteIcon icon;
public OsuTabControlCheckbox()
{
SpriteIcon icon;
AutoSizeAxes = Axes.Both;
Children = new Drawable[]
@ -85,14 +84,6 @@ namespace osu.Game.Graphics.UserInterface
Anchor = Anchor.BottomLeft,
}
};
Current.ValueChanged += selected =>
{
icon.Icon = selected.NewValue ? FontAwesome.Regular.CheckCircle : FontAwesome.Regular.Circle;
text.Font = text.Font.With(weight: selected.NewValue ? FontWeight.Bold : FontWeight.Medium);
updateFade();
};
}
[BackgroundDependencyLoader]
@ -105,6 +96,19 @@ namespace osu.Game.Graphics.UserInterface
sampleUnchecked = audio.Samples.Get(@"UI/check-off");
}
protected override void LoadComplete()
{
base.LoadComplete();
Current.BindValueChanged(selected =>
{
icon.Icon = selected.NewValue ? FontAwesome.Regular.CheckCircle : FontAwesome.Regular.Circle;
text.Font = text.Font.With(weight: selected.NewValue ? FontWeight.Bold : FontWeight.Medium);
updateFade();
}, true);
}
protected override bool OnHover(HoverEvent e)
{
updateFade();

View File

@ -0,0 +1,133 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
{
/// <summary>
/// A generic popover for sending an online report about something.
/// </summary>
/// <typeparam name="TReportReason">An enumeration type with all valid reasons for the report.</typeparam>
public abstract partial class ReportPopover<TReportReason> : OsuPopover
where TReportReason : struct, Enum
{
/// <summary>
/// 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.
/// </summary>
public Action<TReportReason, string>? Action;
private OsuEnumDropdown<TReportReason> reasonDropdown = null!;
private OsuTextBox commentsTextBox = null!;
private RoundedButton submitButton = null!;
private readonly LocalisableString header;
/// <summary>
/// Creates a new <see cref="ReportPopover{TReportReason}"/>.
/// </summary>
/// <param name="headerString">The text to display in the header of the popover.</param>
protected ReportPopover(LocalisableString headerString)
{
header = headerString;
}
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{
Child = new ReverseChildIDFillFlowContainer<Drawable>
{
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<TReportReason>
{
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);
}
/// <summary>
/// Determines whether an additional comment is required for submitting the report with the supplied <paramref name="reason"/>.
/// </summary>
protected virtual bool IsCommentRequired(TReportReason reason) => true;
}
}

View File

@ -34,6 +34,11 @@ namespace osu.Game.Localisation
/// </summary>
public static LocalisableString AddPreset => new TranslatableString(getKey(@"add_preset"), @"Add preset");
/// <summary>
/// "Use current mods"
/// </summary>
public static LocalisableString UseCurrentMods => new TranslatableString(getKey(@"use_current_mods"), @"Use current mods");
private static string getKey(string key) => $@"{prefix}:{key}";
}
}

View File

@ -0,0 +1,38 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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";
}
}

View File

@ -17,8 +17,6 @@ using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Framework.Platform;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
@ -66,18 +64,18 @@ namespace osu.Game.Online.Leaderboards
private List<ScoreComponentLabel> statisticsLabels;
[Resolved(CanBeNull = true)]
[Resolved(canBeNull: true)]
private IDialogOverlay dialogOverlay { get; set; }
[Resolved(CanBeNull = true)]
[Resolved(canBeNull: true)]
private SongSelect songSelect { get; set; }
[Resolved]
private Storage storage { get; set; }
public ITooltip<ScoreInfo> GetCustomTooltip() => new LeaderboardScoreTooltip();
public virtual ScoreInfo TooltipContent => Score;
[Resolved]
private ScoreManager scoreManager { get; set; } = null!;
public LeaderboardScore(ScoreInfo score, int? rank, bool isOnlineScope = true)
{
Score = score;
@ -90,7 +88,7 @@ namespace osu.Game.Online.Leaderboards
}
[BackgroundDependencyLoader]
private void load(IAPIProvider api, OsuColour colour, ScoreManager scoreManager)
private void load(IAPIProvider api, OsuColour colour)
{
var user = Score.User;
@ -427,7 +425,7 @@ namespace osu.Game.Online.Leaderboards
if (Score.Files.Count > 0)
{
items.Add(new OsuMenuItem("Export", MenuItemType.Standard, () => new LegacyScoreExporter(storage).Export(Score)));
items.Add(new OsuMenuItem("Export", MenuItemType.Standard, () => scoreManager.Export(Score)));
items.Add(new OsuMenuItem(CommonStrings.ButtonsDelete, MenuItemType.Destructive, () => dialogOverlay?.Push(new LocalScoreDeleteDialog(Score))));
}

View File

@ -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<Mod>();
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();
}

View File

@ -1,25 +1,28 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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);
/// <summary>
/// Performs a highlight animation on this <see cref="ChatLine"/>.
/// </summary>

View File

@ -0,0 +1,37 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
{
/// <remarks>
/// 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
/// </remarks>
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
}
}

View File

@ -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();
}
}

View File

@ -0,0 +1,41 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<ChatReportReason>
{
[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);
}
}
}

View File

@ -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<DrawableChannel>
new PopoverContainer
{
RelativeSizeAxes = Axes.Both,
Child = currentChannelContainer = new Container<DrawableChannel>
{
RelativeSizeAxes = Axes.Both,
}
},
loading = new LoadingLayer(true),
channelListing = new ChannelListing

View File

@ -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
};
}
}

View File

@ -1,111 +1,17 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<CommentReportReason>
{
public Action<CommentReportReason, string>? Action;
private readonly Comment? comment;
private OsuEnumDropdown<CommentReportReason> 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<Drawable>
{
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<CommentReportReason>
{
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);
}
}
}

View File

@ -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,

View File

@ -0,0 +1,172 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<ModPreset> preset;
private HashSet<Mod> saveableMods;
[Resolved]
private Bindable<IReadOnlyList<Mod>> selectedMods { get; set; } = null!;
[Resolved]
private OsuColour colours { get; set; } = null!;
[Resolved]
private OverlayColourProvider colourProvider { get; set; } = null!;
public EditPresetPopover(Live<ModPreset> 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();
}
}
}

View File

@ -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<ModPreset>, IHasContextMenu
public partial class ModPresetPanel : ModSelectPanel, IHasCustomTooltip<ModPreset>, IHasContextMenu, IHasPopover
{
public readonly Live<ModPreset> 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);
}
}

View File

@ -0,0 +1,64 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
});
}
}
}
}

View File

@ -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
});
}
}
}
}
}

View File

@ -185,6 +185,12 @@ namespace osu.Game.Overlays
content.ScaleTo(1, 1000, Easing.OutElastic);
base.OnMouseUp(e);
}
protected override bool OnHover(HoverEvent e)
{
base.OnHover(e);
return true;
}
}
}
}

View File

@ -13,7 +13,6 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Database;
using osu.Game.Graphics.UserInterface;
using osu.Game.Localisation;
@ -139,9 +138,6 @@ namespace osu.Game.Overlays.Settings.Sections
[Resolved]
private SkinManager skins { get; set; }
[Resolved]
private Storage storage { get; set; }
private Bindable<Skin> currentSkin;
[BackgroundDependencyLoader]
@ -163,7 +159,7 @@ namespace osu.Game.Overlays.Settings.Sections
{
try
{
currentSkin.Value.SkinInfo.PerformRead(s => new LegacySkinExporter(storage).Export(s));
skins.ExportCurrentSkin();
}
catch (Exception e)
{

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<Drawable>().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<Type, Queue<Drawable>>();
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<Drawable>? componentsOfSameType))
componentsPerTypeLookup.Add(lookup, componentsOfSameType = new Queue<Drawable>());
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<Drawable>? 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<Drawable> typeComponents) in componentsPerTypeLookup)
{
foreach (var component in typeComponents)
component.Dispose();
}
}
}

View File

@ -1,146 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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;
});
}
}
}

View File

@ -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<Type>();
private IReadOnlyList<IBindable>? settingsBacking;
private IReadOnlyDictionary<string, IBindable>? settingsBacking;
/// <summary>
/// A list of the all <see cref="IBindable"/> settings within this mod.
/// All <see cref="IBindable"/> settings within this mod.
/// </summary>
internal IReadOnlyList<IBindable> Settings =>
/// <remarks>
/// The settings are returned in ascending key order as per <see cref="SettingsMap"/>.
/// The ordering is intentionally enforced manually, as ordering of <see cref="Dictionary{TKey,TValue}.Values"/> is unspecified.
/// </remarks>
internal IEnumerable<IBindable> SettingsBindables => SettingsMap.OrderBy(pair => pair.Key).Select(pair => pair.Value);
/// <summary>
/// Provides mapping of names to <see cref="IBindable"/>s of all settings within this mod.
/// </summary>
internal IReadOnlyDictionary<string, IBindable> SettingsMap =>
settingsBacking ??= this.GetSettingsSourceProperties()
.Select(p => p.Item2.GetValue(this))
.Cast<IBindable>()
.ToList();
.Select(p => p.Item2)
.ToDictionary(property => property.Name.ToSnakeCase(), property => (IBindable)property.GetValue(this)!);
/// <summary>
/// Whether all settings in this mod are set to their default state.
/// </summary>
protected virtual bool UsesDefaultConfiguration => Settings.All(s => s.IsDefault);
protected virtual bool UsesDefaultConfiguration => SettingsBindables.All(s => s.IsDefault);
/// <summary>
/// Creates a copy of this <see cref="Mod"/> 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);
}
}
/// <summary>
/// This method copies the values of all settings from <paramref name="source"/> 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.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="source">The mod to extract settings from.</param>
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<int>` and `BindableInt`.
if (!targetBindableType.IsAssignableFrom(sourceBindableType) && !sourceBindableType.IsAssignableFrom(targetBindableType))
continue;
// TODO: special case for handling number types
PropertyInfo property = targetSetting.GetType().GetProperty(nameof(Bindable<bool>.Value))!;
property.SetValue(targetSetting, property.GetValue(sourceSetting));
}
}
/// <summary>
/// When creating copies or clones of a Mod, this method will be called
/// to copy explicitly adjusted user settings from <paramref name="target"/>.
@ -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();

View File

@ -3,17 +3,18 @@
using osu.Framework.Bindables;
namespace osu.Game.Rulesets.Objects.Types;
/// <summary>
/// A HitObject that has a slider velocity multiplier.
/// </summary>
public interface IHasSliderVelocity
namespace osu.Game.Rulesets.Objects.Types
{
/// <summary>
/// The slider velocity multiplier.
/// A HitObject that has a slider velocity multiplier.
/// </summary>
double SliderVelocity { get; set; }
public interface IHasSliderVelocity
{
/// <summary>
/// The slider velocity multiplier.
/// </summary>
double SliderVelocity { get; set; }
BindableNumber<double> SliderVelocityBindable { get; }
BindableNumber<double> SliderVelocityBindable { get; }
}
}

View File

@ -27,6 +27,7 @@ namespace osu.Game.Scoring
{
private readonly OsuConfigManager configManager;
private readonly ScoreImporter scoreImporter;
private readonly LegacyScoreExporter scoreExporter;
public override bool PauseImports
{
@ -48,6 +49,11 @@ namespace osu.Game.Scoring
{
PostNotification = obj => PostNotification?.Invoke(obj)
};
scoreExporter = new LegacyScoreExporter(storage)
{
PostNotification = obj => PostNotification?.Invoke(obj)
};
}
public Score GetScore(ScoreInfo score) => scoreImporter.GetScore(score);
@ -187,6 +193,8 @@ namespace osu.Game.Scoring
public Task<IEnumerable<Live<ScoreInfo>>> Import(ProgressNotification notification, ImportTask[] tasks, ImportParameters parameters = default) => scoreImporter.Import(notification, tasks);
public Task Export(ScoreInfo score) => scoreExporter.ExportAsync(score.ToLive(Realm));
public Task<Live<ScoreInfo>> ImportAsUpdate(ProgressNotification notification, ImportTask task, ScoreInfo original) => scoreImporter.ImportAsUpdate(notification, task, original);
public Live<ScoreInfo> Import(ScoreInfo item, ArchiveReader archive = null, ImportParameters parameters = default, CancellationToken cancellationToken = default) =>

View File

@ -0,0 +1,49 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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;
});
}
}

View File

@ -0,0 +1,111 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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;
}
}
}
}

View File

@ -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<IHasSliderVelocity>().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;
}
}
}

View File

@ -20,7 +20,6 @@ using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Localisation;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Framework.Threading;
@ -29,7 +28,6 @@ using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Graphics.Cursor;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
@ -88,9 +86,6 @@ namespace osu.Game.Screens.Edit
[Resolved]
private RulesetStore rulesets { get; set; }
[Resolved]
private Storage storage { get; set; }
[Resolved(canBeNull: true)]
private IDialogOverlay dialogOverlay { get; set; }
@ -983,7 +978,7 @@ namespace osu.Game.Screens.Edit
private void exportBeatmap()
{
Save();
new LegacyBeatmapExporter(storage).Export(Beatmap.Value.BeatmapSetInfo);
beatmapManager.Export(Beatmap.Value.BeatmapSetInfo);
}
/// <summary>

View File

@ -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
{

View File

@ -35,6 +35,12 @@ namespace osu.Game.Screens.Menu
private const double transition_length = 300;
/// <summary>
/// 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.
/// </summary>
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);
}

View File

@ -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;

View File

@ -58,7 +58,7 @@ namespace osu.Game.Screens.Select.Carousel
item = value;
if (IsLoaded)
if (IsLoaded && !IsDisposed)
UpdateItem();
}
}
@ -165,5 +165,13 @@ namespace osu.Game.Screens.Select.Carousel
Item.State.Value = CarouselItemState.Selected;
return true;
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
// This is important to clean up event subscriptions.
Item = null;
}
}
}

View File

@ -58,6 +58,8 @@ namespace osu.Game.Skinning
private readonly SkinImporter skinImporter;
private readonly LegacySkinExporter skinExporter;
private readonly IResourceStore<byte[]> userFiles;
private Skin argonSkin { get; }
@ -120,6 +122,11 @@ namespace osu.Game.Skinning
SourceChanged?.Invoke();
};
skinExporter = new LegacySkinExporter(storage)
{
PostNotification = obj => PostNotification?.Invoke(obj)
};
}
public void SelectRandomSkin()
@ -298,6 +305,10 @@ namespace osu.Game.Skinning
public Task<Live<SkinInfo>> Import(ImportTask task, ImportParameters parameters = default, CancellationToken cancellationToken = default) =>
skinImporter.Import(task, parameters, cancellationToken);
public Task ExportCurrentSkin() => ExportSkin(CurrentSkinInfo.Value);
public Task ExportSkin(Live<SkinInfo> skin) => skinExporter.ExportAsync(skin);
#endregion
public void Delete([CanBeNull] Expression<Func<SkinInfo, bool>> filter = null, bool silent = false)

View File

@ -36,7 +36,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Realm" Version="10.20.0" />
<PackageReference Include="ppy.osu.Framework" Version="2023.418.0" />
<PackageReference Include="ppy.osu.Framework" Version="2023.506.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2023.417.0" />
<PackageReference Include="Sentry" Version="3.28.1" />
<PackageReference Include="SharpCompress" Version="0.32.2" />

View File

@ -16,6 +16,6 @@
<RuntimeIdentifier>iossimulator-x64</RuntimeIdentifier>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.iOS" Version="2023.418.0" />
<PackageReference Include="ppy.osu.Framework.iOS" Version="2023.506.0" />
</ItemGroup>
</Project>

View File

@ -277,6 +277,7 @@
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpCodeStyle/DEFAULT_INTERNAL_MODIFIER/@EntryValue">Explicit</s:String>
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpCodeStyle/LOCAL_FUNCTION_BODY/@EntryValue">ExpressionBody</s:String>
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpCodeStyle/METHOD_OR_OPERATOR_BODY/@EntryValue">BlockBody</s:String>
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpCodeStyle/NAMESPACE_BODY/@EntryValue">BlockScoped</s:String>
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpCodeStyle/OBJECT_CREATION_WHEN_TYPE_EVIDENT/@EntryValue">ExplicitlyTyped</s:String>
<s:Boolean x:Key="/Default/CodeStyle/CodeFormatting/CSharpCodeStyle/USE_HEURISTICS_FOR_BODY_STYLE/@EntryValue">True</s:Boolean>
<s:String x:Key="/Default/CodeStyle/CodeFormatting/CSharpFormat/ACCESSOR_DECLARATION_BRACES/@EntryValue">NEXT_LINE</s:String>