diff --git a/README.md b/README.md index 7ace47a74f..f64240f67a 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Please make sure you have the following prerequisites: - A desktop platform with the [.NET 6.0 SDK](https://dotnet.microsoft.com/download) installed. - When developing with mobile, [Xamarin](https://docs.microsoft.com/en-us/xamarin/) is required, which is shipped together with Visual Studio or [Visual Studio for Mac](https://visualstudio.microsoft.com/vs/mac/). -- When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as [Visual Studio 2019+](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio Code](https://code.visualstudio.com/). +- When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio Code](https://code.visualstudio.com/). - When running on Linux, please have a system-wide FFmpeg installation available to support video decoding. ### Downloading the source code @@ -72,7 +72,7 @@ git pull Build configurations for the recommended IDEs (listed above) are included. You should use the provided Build/Run functionality of your IDE to get things going. When testing or building new components, it's highly encouraged you use the `VisualTests` project/configuration. More information on this is provided [below](#contributing). -- Visual Studio / Rider users should load the project via one of the platform-specific `.slnf` files, rather than the main `.sln.` This will allow access to template run configurations. +- Visual Studio / Rider users should load the project via one of the platform-specific `.slnf` files, rather than the main `.sln`. This will allow access to template run configurations. You can also build and run *osu!* from the command-line with a single command: diff --git a/osu.Desktop/DiscordRichPresence.cs b/osu.Desktop/DiscordRichPresence.cs index 3642f70a56..d87b25a4c7 100644 --- a/osu.Desktop/DiscordRichPresence.cs +++ b/osu.Desktop/DiscordRichPresence.cs @@ -10,6 +10,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; @@ -108,10 +109,7 @@ namespace osu.Desktop presence.Assets.LargeImageText = $"{user.Value.Username}" + (user.Value.Statistics?.GlobalRank > 0 ? $" (rank #{user.Value.Statistics.GlobalRank:N0})" : string.Empty); // update ruleset - int onlineID = ruleset.Value.OnlineID; - bool isLegacyRuleset = onlineID >= 0 && onlineID <= ILegacyRuleset.MAX_LEGACY_RULESET_ID; - - presence.Assets.SmallImageKey = isLegacyRuleset ? $"mode_{onlineID}" : "mode_custom"; + presence.Assets.SmallImageKey = ruleset.Value.IsLegacyRuleset() ? $"mode_{ruleset.Value.OnlineID}" : "mode_custom"; presence.Assets.SmallImageText = ruleset.Value.Name; client.SetPresence(presence); diff --git a/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs b/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs index 9540e35780..99a064d35f 100644 --- a/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs +++ b/osu.Game.Rulesets.Taiko/Mods/TaikoModDifficultyAdjust.cs @@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Taiko.Mods { get { - string scrollSpeed = ScrollSpeed.IsDefault ? string.Empty : $"Scroll x{ScrollSpeed.Value:N1}"; + string scrollSpeed = ScrollSpeed.IsDefault ? string.Empty : $"Scroll x{ScrollSpeed.Value:N2}"; return string.Join(", ", new[] { diff --git a/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs b/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs index d62ce3b585..02d617d0e0 100644 --- a/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs +++ b/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs @@ -4,8 +4,10 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Extensions; using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Tests.Resources; @@ -18,6 +20,33 @@ namespace osu.Game.Tests.Database [TestFixture] public class RealmSubscriptionRegistrationTests : RealmTest { + [Test] + public void TestSubscriptionWithAsyncWrite() + { + ChangeSet? lastChanges = null; + + RunTestWithRealm((realm, _) => + { + var registration = realm.RegisterForNotifications(r => r.All(), onChanged); + + realm.Run(r => r.Refresh()); + + // Without forcing the write onto its own thread, realm will internally run the operation synchronously, which can cause a deadlock with `WaitSafely`. + Task.Run(async () => + { + await realm.WriteAsync(r => r.Add(TestResources.CreateTestBeatmapSetInfo())); + }).WaitSafely(); + + realm.Run(r => r.Refresh()); + + Assert.That(lastChanges?.InsertedIndices, Has.One.Items); + + registration.Dispose(); + }); + + void onChanged(IRealmCollection sender, ChangeSet? changes, Exception error) => lastChanges = changes; + } + [Test] public void TestSubscriptionWithContextLoss() { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorScreenModes.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorScreenModes.cs index 98d8a41674..2efd125f81 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorScreenModes.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorScreenModes.cs @@ -4,11 +4,9 @@ using System; using System.Linq; using NUnit.Framework; -using osu.Framework.Testing; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Edit; -using osu.Game.Screens.Edit.Components.Menus; namespace osu.Game.Tests.Visual.Editing { @@ -22,7 +20,7 @@ namespace osu.Game.Tests.Visual.Editing AddStep("switch between all screens at once", () => { foreach (var screen in Enum.GetValues(typeof(EditorScreenMode)).Cast()) - Editor.ChildrenOfType().Single().Mode.Value = screen; + Editor.Mode.Value = screen; }); } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs new file mode 100644 index 0000000000..67f5db548b --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs @@ -0,0 +1,74 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Overlays.Settings; +using osu.Game.Scoring; +using osu.Game.Screens.Play.PlayerSettings; +using osu.Game.Tests.Visual.Ranking; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneBeatmapOffsetControl : OsuTestScene + { + private BeatmapOffsetControl offsetControl; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("Create control", () => + { + Child = new PlayerSettingsGroup("Some settings") + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + offsetControl = new BeatmapOffsetControl() + } + }; + }); + } + + [Test] + public void TestTooShortToDisplay() + { + AddStep("Set short reference score", () => + { + offsetControl.ReferenceScore.Value = new ScoreInfo + { + HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(0, 2) + }; + }); + + AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); + } + + [Test] + public void TestDisplay() + { + const double average_error = -4.5; + + AddAssert("Offset is neutral", () => offsetControl.Current.Value == 0); + AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); + AddStep("Set reference score", () => + { + offsetControl.ReferenceScore.Value = new ScoreInfo + { + HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(average_error) + }; + }); + + AddAssert("Has calibration button", () => offsetControl.ChildrenOfType().Any()); + AddStep("Press button", () => offsetControl.ChildrenOfType().Single().TriggerClick()); + AddAssert("Offset is adjusted", () => offsetControl.Current.Value == -average_error); + + AddAssert("Button is disabled", () => !offsetControl.ChildrenOfType().Single().Enabled.Value); + AddStep("Remove reference score", () => offsetControl.ReferenceScore.Value = null); + AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs index e03c8d7561..b195d2aa74 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs @@ -131,9 +131,9 @@ namespace osu.Game.Tests.Visual.Gameplay public double GameplayClockTime => GameplayClockContainer.GameplayClock.CurrentTime; - protected override void Update() + protected override void UpdateAfterChildren() { - base.Update(); + base.UpdateAfterChildren(); if (!FirstFrameClockTime.HasValue) { diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs index 4bc843096f..221001e40b 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs @@ -71,16 +71,16 @@ namespace osu.Game.Tests.Visual.Ranking }; }); - public static List CreateDistributedHitEvents() + public static List CreateDistributedHitEvents(double centre = 0, double range = 25) { var hitEvents = new List(); - for (int i = 0; i < 50; i++) + for (int i = 0; i < range * 2; i++) { - int count = (int)(Math.Pow(25 - Math.Abs(i - 25), 2)); + int count = (int)(Math.Pow(range - Math.Abs(i - range), 2)); for (int j = 0; j < count; j++) - hitEvents.Add(new HitEvent(i - 25, HitResult.Perfect, new HitCircle(), new HitCircle(), null)); + hitEvents.Add(new HitEvent(centre + i - range, HitResult.Perfect, new HitCircle(), new HitCircle(), null)); } return hitEvents; diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs index 167acc94c4..cc380df183 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs @@ -17,10 +17,13 @@ using osu.Game.Database; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Rulesets; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Osu; using osu.Game.Scoring; using osu.Game.Screens; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; +using osu.Game.Screens.Ranking.Expanded.Statistics; using osu.Game.Screens.Ranking.Statistics; using osu.Game.Tests.Resources; using osuTK; @@ -256,6 +259,23 @@ namespace osu.Game.Tests.Visual.Ranking AddAssert("download button is enabled", () => screen.ChildrenOfType().Last().Enabled.Value); } + [Test] + public void TestRulesetWithNoPerformanceCalculator() + { + var ruleset = new RulesetWithNoPerformanceCalculator(); + var score = TestResources.CreateTestScoreInfo(ruleset.RulesetInfo); + + AddStep("load results", () => Child = new TestResultsContainer(createResultsScreen(score))); + AddUntilStep("wait for load", () => this.ChildrenOfType().Single().AllPanelsVisible); + + AddAssert("PP displayed as 0", () => + { + var performance = this.ChildrenOfType().Single(); + var counter = performance.ChildrenOfType().Single(); + return counter.Current.Value == 0; + }); + } + private TestResultsScreen createResultsScreen(ScoreInfo score = null) => new TestResultsScreen(score ?? TestResources.CreateTestScoreInfo()); private UnrankedSoloResultsScreen createUnrankedSoloResultsScreen() => new UnrankedSoloResultsScreen(TestResources.CreateTestScoreInfo()); @@ -367,5 +387,10 @@ namespace osu.Game.Tests.Visual.Ranking RetryOverlay = InternalChildren.OfType().SingleOrDefault(); } } + + private class RulesetWithNoPerformanceCalculator : OsuRuleset + { + public override PerformanceCalculator CreatePerformanceCalculator(DifficultyAttributes attributes, ScoreInfo score) => null; + } } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs index 31bd3a203c..1ed6648131 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs @@ -119,7 +119,8 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep(@"Network failure", () => leaderboard.SetErrorState(LeaderboardState.NetworkFailure)); AddStep(@"No supporter", () => leaderboard.SetErrorState(LeaderboardState.NotSupporter)); AddStep(@"Not logged in", () => leaderboard.SetErrorState(LeaderboardState.NotLoggedIn)); - AddStep(@"Unavailable", () => leaderboard.SetErrorState(LeaderboardState.Unavailable)); + AddStep(@"Ruleset unavailable", () => leaderboard.SetErrorState(LeaderboardState.RulesetUnavailable)); + AddStep(@"Beatmap unavailable", () => leaderboard.SetErrorState(LeaderboardState.BeatmapUnavailable)); AddStep(@"None selected", () => leaderboard.SetErrorState(LeaderboardState.NoneSelected)); } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModColumn.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModColumn.cs new file mode 100644 index 0000000000..e47ae860c6 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModColumn.cs @@ -0,0 +1,187 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Taiko; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.UserInterface +{ + [TestFixture] + public class TestSceneModColumn : OsuManualInputManagerTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); + + [TestCase(ModType.DifficultyReduction)] + [TestCase(ModType.DifficultyIncrease)] + [TestCase(ModType.Conversion)] + [TestCase(ModType.Automation)] + [TestCase(ModType.Fun)] + public void TestBasic(ModType modType) + { + AddStep("create content", () => Child = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(30), + Child = new ModColumn(modType, false) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + } + }); + + AddStep("change ruleset to osu!", () => Ruleset.Value = new OsuRuleset().RulesetInfo); + AddStep("change ruleset to taiko", () => Ruleset.Value = new TaikoRuleset().RulesetInfo); + AddStep("change ruleset to catch", () => Ruleset.Value = new CatchRuleset().RulesetInfo); + AddStep("change ruleset to mania", () => Ruleset.Value = new ManiaRuleset().RulesetInfo); + } + + [Test] + public void TestMultiSelection() + { + ModColumn column = null; + AddStep("create content", () => Child = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(30), + Child = column = new ModColumn(ModType.DifficultyIncrease, true) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + } + }); + + AddUntilStep("wait for panel load", () => column.IsLoaded && column.ItemsLoaded); + + clickToggle(); + AddUntilStep("all panels selected", () => this.ChildrenOfType().All(panel => panel.Active.Value)); + + clickToggle(); + AddUntilStep("all panels deselected", () => this.ChildrenOfType().All(panel => !panel.Active.Value)); + + AddStep("manually activate all panels", () => this.ChildrenOfType().ForEach(panel => panel.Active.Value = true)); + AddUntilStep("checkbox selected", () => this.ChildrenOfType().Single().Current.Value); + + AddStep("deselect first panel", () => this.ChildrenOfType().First().Active.Value = false); + AddUntilStep("checkbox not selected", () => !this.ChildrenOfType().Single().Current.Value); + + void clickToggle() => AddStep("click toggle", () => + { + var checkbox = this.ChildrenOfType().Single(); + InputManager.MoveMouseTo(checkbox); + InputManager.Click(MouseButton.Left); + }); + } + + [Test] + public void TestFiltering() + { + TestModColumn column = null; + + AddStep("create content", () => Child = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(30), + Child = column = new TestModColumn(ModType.Fun, true) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + } + }); + + AddStep("set filter", () => column.Filter = mod => mod.Name.Contains("Wind", StringComparison.CurrentCultureIgnoreCase)); + AddUntilStep("two panels visible", () => column.ChildrenOfType().Count(panel => !panel.Filtered.Value) == 2); + + clickToggle(); + AddUntilStep("wait for animation", () => !column.SelectionAnimationRunning); + AddAssert("only visible items selected", () => column.ChildrenOfType().Where(panel => panel.Active.Value).All(panel => !panel.Filtered.Value)); + + AddStep("unset filter", () => column.Filter = null); + AddUntilStep("all panels visible", () => column.ChildrenOfType().All(panel => !panel.Filtered.Value)); + AddAssert("checkbox not selected", () => !column.ChildrenOfType().Single().Current.Value); + + AddStep("set filter", () => column.Filter = mod => mod.Name.Contains("Wind", StringComparison.CurrentCultureIgnoreCase)); + AddUntilStep("two panels visible", () => column.ChildrenOfType().Count(panel => !panel.Filtered.Value) == 2); + AddAssert("checkbox selected", () => column.ChildrenOfType().Single().Current.Value); + + AddStep("filter out everything", () => column.Filter = _ => false); + AddUntilStep("no panels visible", () => column.ChildrenOfType().All(panel => panel.Filtered.Value)); + AddUntilStep("checkbox hidden", () => !column.ChildrenOfType().Single().IsPresent); + + AddStep("inset filter", () => column.Filter = null); + AddUntilStep("all panels visible", () => column.ChildrenOfType().All(panel => !panel.Filtered.Value)); + AddUntilStep("checkbox visible", () => column.ChildrenOfType().Single().IsPresent); + + void clickToggle() => AddStep("click toggle", () => + { + var checkbox = this.ChildrenOfType().Single(); + InputManager.MoveMouseTo(checkbox); + InputManager.Click(MouseButton.Left); + }); + } + + [Test] + public void TestKeyboardSelection() + { + ModColumn column = null; + AddStep("create content", () => Child = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(30), + Child = column = new ModColumn(ModType.DifficultyReduction, true, new[] { Key.Q, Key.W, Key.E, Key.R, Key.T, Key.Y, Key.U, Key.I, Key.O, Key.P }) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + } + }); + + AddUntilStep("wait for panel load", () => column.IsLoaded && column.ItemsLoaded); + + AddStep("press W", () => InputManager.Key(Key.W)); + AddAssert("NF panel selected", () => this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "NF").Active.Value); + + AddStep("press W again", () => InputManager.Key(Key.W)); + AddAssert("NF panel deselected", () => !this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "NF").Active.Value); + + AddStep("set filter to NF", () => column.Filter = mod => mod.Acronym == "NF"); + + AddStep("press W", () => InputManager.Key(Key.W)); + AddAssert("NF panel selected", () => this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "NF").Active.Value); + + AddStep("press W again", () => InputManager.Key(Key.W)); + AddAssert("NF panel deselected", () => !this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "NF").Active.Value); + + AddStep("filter out everything", () => column.Filter = _ => false); + + AddStep("press W", () => InputManager.Key(Key.W)); + AddAssert("NF panel not selected", () => !this.ChildrenOfType().Single(panel => panel.Mod.Acronym == "NF").Active.Value); + + AddStep("clear filter", () => column.Filter = null); + } + + private class TestModColumn : ModColumn + { + public new bool SelectionAnimationRunning => base.SelectionAnimationRunning; + + public TestModColumn(ModType modType, bool allowBulkSelection) + : base(modType, allowBulkSelection) + { + } + } + } +} diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index 305b3979a0..c6f69286cd 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -40,6 +40,8 @@ namespace osu.Game.Beatmaps [Backlink(nameof(ScoreInfo.BeatmapInfo))] public IQueryable Scores { get; } = null!; + public BeatmapUserSettings UserSettings { get; set; } = null!; + public BeatmapInfo(RulesetInfo? ruleset = null, BeatmapDifficulty? difficulty = null, BeatmapMetadata? metadata = null) { ID = Guid.NewGuid(); @@ -51,6 +53,7 @@ namespace osu.Game.Beatmaps }; Difficulty = difficulty ?? new BeatmapDifficulty(); Metadata = metadata ?? new BeatmapMetadata(); + UserSettings = new BeatmapUserSettings(); } [UsedImplicitly] diff --git a/osu.Game/Beatmaps/BeatmapUserSettings.cs b/osu.Game/Beatmaps/BeatmapUserSettings.cs new file mode 100644 index 0000000000..5c71bf34b1 --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapUserSettings.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable +using Realms; + +namespace osu.Game.Beatmaps +{ + /// + /// User settings overrides that are attached to a beatmap. + /// + public class BeatmapUserSettings : EmbeddedObject + { + /// + /// An audio offset that can be used for timing adjustments. + /// + public double Offset { get; set; } + } +} diff --git a/osu.Game/Beatmaps/DifficultyRecommender.cs b/osu.Game/Beatmaps/DifficultyRecommender.cs index 3949e84f4a..93c2fccbc7 100644 --- a/osu.Game/Beatmaps/DifficultyRecommender.cs +++ b/osu.Game/Beatmaps/DifficultyRecommender.cs @@ -9,6 +9,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; +using osu.Game.Extensions; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Rulesets; @@ -83,7 +84,7 @@ namespace osu.Game.Beatmaps requestedUserId = api.LocalUser.Value.Id; // only query API for built-in rulesets - rulesets.AvailableRulesets.Where(ruleset => ruleset.OnlineID >= 0 && ruleset.OnlineID <= ILegacyRuleset.MAX_LEGACY_RULESET_ID).ForEach(rulesetInfo => + rulesets.AvailableRulesets.Where(ruleset => ruleset.IsLegacyRuleset()).ForEach(rulesetInfo => { var req = new GetUserRequest(api.LocalUser.Value.Id, rulesetInfo); diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 07d2026c65..1358b41ad2 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -240,9 +240,9 @@ namespace osu.Game.Configuration }; } - public Func LookupSkinName { private get; set; } + public Func LookupSkinName { private get; set; } = _ => @"unknown"; - public Func LookupKeyBindings { get; set; } + public Func LookupKeyBindings { get; set; } = _ => @"unknown"; } // IMPORTANT: These are used in user configuration files. diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index 9bdbebfe89..fb3052d850 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -8,20 +8,21 @@ using System.IO; using System.Linq; using System.Reflection; using System.Threading; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Development; using osu.Framework.Input.Bindings; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Statistics; -using osu.Game.Configuration; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Input.Bindings; using osu.Game.Models; -using osu.Game.Skinning; -using osu.Game.Stores; using osu.Game.Rulesets; using osu.Game.Scoring; +using osu.Game.Skinning; +using osu.Game.Stores; using Realms; using Realms.Exceptions; @@ -53,8 +54,9 @@ namespace osu.Game.Database /// 11 2021-11-22 Use ShortName instead of RulesetID for ruleset key bindings. /// 12 2021-11-24 Add Status to RealmBeatmapSet. /// 13 2022-01-13 Final migration of beatmaps and scores to realm (multiple new storage fields). + /// 14 2022-03-01 Added BeatmapUserSettings to BeatmapInfo. /// - private const int schema_version = 13; + private const int schema_version = 14; /// /// Lock object which is held during sections, blocking realm retrieval during blocking periods. @@ -85,6 +87,14 @@ namespace osu.Game.Database private static readonly GlobalStatistic total_subscriptions = GlobalStatistics.Get(@"Realm", @"Subscriptions"); + private static readonly GlobalStatistic total_reads_update = GlobalStatistics.Get(@"Realm", @"Reads (Update)"); + + private static readonly GlobalStatistic total_reads_async = GlobalStatistics.Get(@"Realm", @"Reads (Async)"); + + private static readonly GlobalStatistic total_writes_update = GlobalStatistics.Get(@"Realm", @"Writes (Update)"); + + private static readonly GlobalStatistic total_writes_async = GlobalStatistics.Get(@"Realm", @"Writes (Async)"); + private readonly object realmLock = new object(); private Realm? updateRealm; @@ -213,8 +223,12 @@ namespace osu.Game.Database public T Run(Func action) { if (ThreadSafety.IsUpdateThread) + { + total_reads_update.Value++; return action(Realm); + } + total_reads_async.Value++; using (var realm = getRealmInstance()) return action(realm); } @@ -226,9 +240,13 @@ namespace osu.Game.Database public void Run(Action action) { if (ThreadSafety.IsUpdateThread) + { + total_reads_update.Value++; action(Realm); + } else { + total_reads_async.Value++; using (var realm = getRealmInstance()) action(realm); } @@ -241,14 +259,30 @@ namespace osu.Game.Database public void Write(Action action) { if (ThreadSafety.IsUpdateThread) + { + total_writes_update.Value++; Realm.Write(action); + } else { + total_writes_async.Value++; + using (var realm = getRealmInstance()) realm.Write(action); } } + /// + /// Write changes to realm asynchronously, guaranteeing order of execution. + /// + /// The work to run. + public async Task WriteAsync(Action action) + { + total_writes_async.Value++; + using (var realm = getRealmInstance()) + await realm.WriteAsync(action); + } + /// /// Subscribe to a realm collection and begin watching for asynchronous changes. /// @@ -531,6 +565,11 @@ namespace osu.Game.Database } break; + + case 14: + foreach (var beatmap in migration.NewRealm.All()) + beatmap.UserSettings = new BeatmapUserSettings(); + break; } } diff --git a/osu.Game/Database/RealmObjectExtensions.cs b/osu.Game/Database/RealmObjectExtensions.cs index f89bbbe19d..6dc18df9e0 100644 --- a/osu.Game/Database/RealmObjectExtensions.cs +++ b/osu.Game/Database/RealmObjectExtensions.cs @@ -38,6 +38,7 @@ namespace osu.Game.Database c.CreateMap() .ForMember(s => s.Ruleset, cc => cc.Ignore()) .ForMember(s => s.Metadata, cc => cc.Ignore()) + .ForMember(s => s.UserSettings, cc => cc.Ignore()) .ForMember(s => s.Difficulty, cc => cc.Ignore()) .ForMember(s => s.BeatmapSet, cc => cc.Ignore()) .AfterMap((s, d) => @@ -154,6 +155,7 @@ namespace osu.Game.Database c.CreateMap(); c.CreateMap(); + c.CreateMap(); c.CreateMap(); c.CreateMap(); c.CreateMap(); diff --git a/osu.Game/Extensions/ModelExtensions.cs b/osu.Game/Extensions/ModelExtensions.cs index f178a5c97b..13c25e45c8 100644 --- a/osu.Game/Extensions/ModelExtensions.cs +++ b/osu.Game/Extensions/ModelExtensions.cs @@ -72,6 +72,11 @@ namespace osu.Game.Extensions return result; } + /// + /// Check whether this 's online ID is within the range that defines it as a legacy ruleset (ie. either osu!, osu!taiko, osu!catch or osu!mania). + /// + public static bool IsLegacyRuleset(this IRulesetInfo ruleset) => ruleset.OnlineID >= 0 && ruleset.OnlineID <= ILegacyRuleset.MAX_LEGACY_RULESET_ID; + /// /// Check whether the online ID of two s match. /// diff --git a/osu.Game/Graphics/Containers/ScalingContainer.cs b/osu.Game/Graphics/Containers/ScalingContainer.cs index 0d543bdbc8..d331b818a1 100644 --- a/osu.Game/Graphics/Containers/ScalingContainer.cs +++ b/osu.Game/Graphics/Containers/ScalingContainer.cs @@ -5,6 +5,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; using osu.Framework.Screens; using osu.Game.Configuration; using osu.Game.Screens; @@ -38,24 +39,24 @@ namespace osu.Game.Graphics.Containers private BackgroundScreenStack backgroundStack; - private bool allowScaling = true; + private RectangleF? customRect; + private bool customRectIsRelativePosition; /// - /// Whether user scaling preferences should be applied. Enabled by default. + /// Set a custom position and scale which overrides any user specification. /// - public bool AllowScaling + /// A rectangle with positional and sizing information for this container to conform to. null will clear the custom rect and revert to user settings. + /// Whether the position portion of the provided rect is in relative coordinate space or not. + public void SetCustomRect(RectangleF? rect, bool relativePosition = false) { - get => allowScaling; - set - { - if (value == allowScaling) - return; + customRect = rect; + customRectIsRelativePosition = relativePosition; - allowScaling = value; - if (IsLoaded) Scheduler.AddOnce(updateSize); - } + if (IsLoaded) Scheduler.AddOnce(updateSize); } + private const float corner_radius = 10; + /// /// Create a new instance. /// @@ -69,7 +70,7 @@ namespace osu.Game.Graphics.Containers { RelativeSizeAxes = Axes.Both, RelativePositionAxes = Axes.Both, - CornerRadius = 10, + CornerRadius = corner_radius, Child = content = new ScalingDrawSizePreservingFillContainer(targetMode != ScalingMode.Gameplay) }; } @@ -137,7 +138,7 @@ namespace osu.Game.Graphics.Containers private void updateSize() { - const float fade_time = 500; + const float duration = 500; if (targetMode == ScalingMode.Everything) { @@ -156,17 +157,31 @@ namespace osu.Game.Graphics.Containers backgroundStack.Push(new ScalingBackgroundScreen()); } - backgroundStack.FadeIn(fade_time); + backgroundStack.FadeIn(duration); } else - backgroundStack?.FadeOut(fade_time); + backgroundStack?.FadeOut(duration); } - bool scaling = AllowScaling && (targetMode == null || scalingMode.Value == targetMode); + RectangleF targetRect = new RectangleF(Vector2.Zero, Vector2.One); - var targetSize = scaling ? new Vector2(sizeX.Value, sizeY.Value) : Vector2.One; - var targetPosition = scaling ? new Vector2(posX.Value, posY.Value) * (Vector2.One - targetSize) : Vector2.Zero; - bool requiresMasking = (scaling && targetSize != Vector2.One) + if (customRect != null) + { + sizableContainer.RelativePositionAxes = customRectIsRelativePosition ? Axes.Both : Axes.None; + + targetRect = customRect.Value; + } + else if (targetMode == null || scalingMode.Value == targetMode) + { + sizableContainer.RelativePositionAxes = Axes.Both; + + Vector2 scale = new Vector2(sizeX.Value, sizeY.Value); + Vector2 pos = new Vector2(posX.Value, posY.Value) * (Vector2.One - scale); + + targetRect = new RectangleF(pos, scale); + } + + bool requiresMasking = targetRect.Size != Vector2.One // For the top level scaling container, for now we apply masking if safe areas are in use. // In the future this can likely be removed as more of the actual UI supports overflowing into the safe areas. || (targetMode == ScalingMode.Everything && safeAreaPadding.Value.Total != Vector2.Zero); @@ -174,8 +189,14 @@ namespace osu.Game.Graphics.Containers if (requiresMasking) sizableContainer.Masking = true; - sizableContainer.MoveTo(targetPosition, 500, Easing.OutQuart); - sizableContainer.ResizeTo(targetSize, 500, Easing.OutQuart).OnComplete(_ => { sizableContainer.Masking = requiresMasking; }); + sizableContainer.MoveTo(targetRect.Location, duration, Easing.OutQuart); + sizableContainer.ResizeTo(targetRect.Size, duration, Easing.OutQuart); + + // Of note, this will not work great in the case of nested ScalingContainers where multiple are applying corner radius. + // Masking and corner radius should likely only be applied at one point in the full game stack to fix this. + // An example of how this can occur is when the skin editor is visible and the game screen scaling is set to "Everything". + sizableContainer.TransformTo(nameof(CornerRadius), requiresMasking ? corner_radius : 0, duration, requiresMasking ? Easing.OutQuart : Easing.None) + .OnComplete(_ => { sizableContainer.Masking = requiresMasking; }); } private class ScalingBackgroundScreen : BackgroundScreenDefault diff --git a/osu.Game/Localisation/BeatmapOffsetControlStrings.cs b/osu.Game/Localisation/BeatmapOffsetControlStrings.cs new file mode 100644 index 0000000000..7b2a9e50b2 --- /dev/null +++ b/osu.Game/Localisation/BeatmapOffsetControlStrings.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class BeatmapOffsetControlStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.BeatmapOffsetControl"; + + /// + /// "Beatmap offset" + /// + public static LocalisableString BeatmapOffset => new TranslatableString(getKey(@"beatmap_offset"), @"Beatmap offset"); + + /// + /// "Previous play:" + /// + public static LocalisableString PreviousPlay => new TranslatableString(getKey(@"previous_play"), @"Previous play:"); + + /// + /// "Previous play too short to use for calibration" + /// + public static LocalisableString PreviousPlayTooShortToUseForCalibration => new TranslatableString(getKey(@"previous_play_too_short_to_use_for_calibration"), @"Previous play too short to use for calibration"); + + /// + /// "Calibrate using last play" + /// + public static LocalisableString CalibrateUsingLastPlay => new TranslatableString(getKey(@"calibrate_using_last_play"), @"Calibrate using last play"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} \ No newline at end of file diff --git a/osu.Game/Localisation/LeaderboardStrings.cs b/osu.Game/Localisation/LeaderboardStrings.cs new file mode 100644 index 0000000000..8e53f8e88c --- /dev/null +++ b/osu.Game/Localisation/LeaderboardStrings.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class LeaderboardStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.Leaderboard"; + + /// + /// "Couldn't fetch scores!" + /// + public static LocalisableString CouldntFetchScores => new TranslatableString(getKey(@"couldnt_fetch_scores"), @"Couldn't fetch scores!"); + + /// + /// "Please select a beatmap!" + /// + public static LocalisableString PleaseSelectABeatmap => new TranslatableString(getKey(@"please_select_a_beatmap"), @"Please select a beatmap!"); + + /// + /// "Leaderboards are not available for this ruleset!" + /// + public static LocalisableString LeaderboardsAreNotAvailableForThisRuleset => new TranslatableString(getKey(@"leaderboards_are_not_available_for_this_ruleset"), @"Leaderboards are not available for this ruleset!"); + + /// + /// "Leaderboards are not available for this beatmap!" + /// + public static LocalisableString LeaderboardsAreNotAvailableForThisBeatmap => new TranslatableString(getKey(@"leaderboards_are_not_available_for_this_beatmap"), @"Leaderboards are not available for this beatmap!"); + + /// + /// "No records yet!" + /// + public static LocalisableString NoRecordsYet => new TranslatableString(getKey(@"no_records_yet"), @"No records yet!"); + + /// + /// "Please sign in to view online leaderboards!" + /// + public static LocalisableString PleaseSignInToViewOnlineLeaderboards => new TranslatableString(getKey(@"please_sign_in_to_view_online_leaderboards"), @"Please sign in to view online leaderboards!"); + + /// + /// "Please invest in an osu!supporter tag to view this leaderboard!" + /// + public static LocalisableString PleaseInvestInAnOsuSupporterTagToViewThisLeaderboard => new TranslatableString(getKey(@"please_invest_in_an_osu_supporter_tag_to_view_this_leaderboard"), @"Please invest in an osu!supporter tag to view this leaderboard!"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Online/Leaderboards/Leaderboard.cs b/osu.Game/Online/Leaderboards/Leaderboard.cs index 5dd3e46b4a..c94a6d3361 100644 --- a/osu.Game/Online/Leaderboards/Leaderboard.cs +++ b/osu.Game/Online/Leaderboards/Leaderboard.cs @@ -22,6 +22,7 @@ using osu.Game.Online.API; using osu.Game.Online.Placeholders; using osuTK; using osuTK.Graphics; +using osu.Game.Localisation; namespace osu.Game.Online.Leaderboards { @@ -311,25 +312,28 @@ namespace osu.Game.Online.Leaderboards switch (state) { case LeaderboardState.NetworkFailure: - return new ClickablePlaceholder(@"Couldn't fetch scores!", FontAwesome.Solid.Sync) + return new ClickablePlaceholder(LeaderboardStrings.CouldntFetchScores, FontAwesome.Solid.Sync) { Action = RefetchScores }; case LeaderboardState.NoneSelected: - return new MessagePlaceholder(@"Please select a beatmap!"); + return new MessagePlaceholder(LeaderboardStrings.PleaseSelectABeatmap); - case LeaderboardState.Unavailable: - return new MessagePlaceholder(@"Leaderboards are not available for this beatmap!"); + case LeaderboardState.RulesetUnavailable: + return new MessagePlaceholder(LeaderboardStrings.LeaderboardsAreNotAvailableForThisRuleset); + + case LeaderboardState.BeatmapUnavailable: + return new MessagePlaceholder(LeaderboardStrings.LeaderboardsAreNotAvailableForThisBeatmap); case LeaderboardState.NoScores: - return new MessagePlaceholder(@"No records yet!"); + return new MessagePlaceholder(LeaderboardStrings.NoRecordsYet); case LeaderboardState.NotLoggedIn: - return new LoginPlaceholder(@"Please sign in to view online leaderboards!"); + return new LoginPlaceholder(LeaderboardStrings.PleaseSignInToViewOnlineLeaderboards); case LeaderboardState.NotSupporter: - return new MessagePlaceholder(@"Please invest in an osu!supporter tag to view this leaderboard!"); + return new MessagePlaceholder(LeaderboardStrings.PleaseInvestInAnOsuSupporterTagToViewThisLeaderboard); case LeaderboardState.Retrieving: return null; diff --git a/osu.Game/Online/Leaderboards/LeaderboardState.cs b/osu.Game/Online/Leaderboards/LeaderboardState.cs index 75e2c6e6db..6b07500a98 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardState.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardState.cs @@ -8,7 +8,8 @@ namespace osu.Game.Online.Leaderboards Success, Retrieving, NetworkFailure, - Unavailable, + BeatmapUnavailable, + RulesetUnavailable, NoneSelected, NoScores, NotLoggedIn, diff --git a/osu.Game/Online/Placeholders/LoginPlaceholder.cs b/osu.Game/Online/Placeholders/LoginPlaceholder.cs index f8a326a52e..d03b3d8ffc 100644 --- a/osu.Game/Online/Placeholders/LoginPlaceholder.cs +++ b/osu.Game/Online/Placeholders/LoginPlaceholder.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; using osu.Game.Overlays; namespace osu.Game.Online.Placeholders @@ -12,7 +13,7 @@ namespace osu.Game.Online.Placeholders [Resolved(CanBeNull = true)] private LoginOverlay login { get; set; } - public LoginPlaceholder(string actionMessage) + public LoginPlaceholder(LocalisableString actionMessage) : base(actionMessage, FontAwesome.Solid.UserLock) { Action = () => login?.Show(); diff --git a/osu.Game/Overlays/Mods/ModColumn.cs b/osu.Game/Overlays/Mods/ModColumn.cs new file mode 100644 index 0000000000..736a0205e2 --- /dev/null +++ b/osu.Game/Overlays/Mods/ModColumn.cs @@ -0,0 +1,426 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Humanizer; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Colour; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Mods; +using osu.Game.Utils; +using osuTK; +using osuTK.Graphics; +using osuTK.Input; + +#nullable enable + +namespace osu.Game.Overlays.Mods +{ + public class ModColumn : CompositeDrawable + { + private Func? filter; + + /// + /// Function determining whether each mod in the column should be displayed. + /// A return value of means that the mod is not filtered and therefore its corresponding panel should be displayed. + /// A return value of means that the mod is filtered out and therefore its corresponding panel should be hidden. + /// + public Func? Filter + { + get => filter; + set + { + filter = value; + updateFilter(); + } + } + + private readonly ModType modType; + private readonly Key[]? toggleKeys; + + private readonly Bindable>> availableMods = new Bindable>>(); + + private readonly TextFlowContainer headerText; + private readonly Box headerBackground; + private readonly Container contentContainer; + private readonly Box contentBackground; + private readonly FillFlowContainer panelFlow; + private readonly ToggleAllCheckbox? toggleAllCheckbox; + + private Colour4 accentColour; + + private Task? latestLoadTask; + internal bool ItemsLoaded => latestLoadTask == null; + + private const float header_height = 42; + + public ModColumn(ModType modType, bool allowBulkSelection, Key[]? toggleKeys = null) + { + this.modType = modType; + this.toggleKeys = toggleKeys; + + Width = 320; + RelativeSizeAxes = Axes.Y; + Shear = new Vector2(ModPanel.SHEAR_X, 0); + CornerRadius = ModPanel.CORNER_RADIUS; + Masking = true; + + Container controlContainer; + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + Height = header_height + ModPanel.CORNER_RADIUS, + Children = new Drawable[] + { + headerBackground = new Box + { + RelativeSizeAxes = Axes.X, + Height = header_height + ModPanel.CORNER_RADIUS + }, + headerText = new OsuTextFlowContainer(t => + { + t.Font = OsuFont.TorusAlternate.With(size: 17); + t.Shadow = false; + t.Colour = Colour4.Black; + }) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Shear = new Vector2(-ModPanel.SHEAR_X, 0), + Padding = new MarginPadding + { + Horizontal = 17, + Bottom = ModPanel.CORNER_RADIUS + } + } + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = header_height }, + Child = contentContainer = new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = ModPanel.CORNER_RADIUS, + BorderThickness = 3, + Children = new Drawable[] + { + contentBackground = new Box + { + RelativeSizeAxes = Axes.Both + }, + new GridContainer + { + RelativeSizeAxes = Axes.Both, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension() + }, + Content = new[] + { + new Drawable[] + { + controlContainer = new Container + { + RelativeSizeAxes = Axes.X, + Padding = new MarginPadding { Horizontal = 14 } + } + }, + new Drawable[] + { + new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + ScrollbarOverlapsContent = false, + Child = panelFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0, 7), + Padding = new MarginPadding(7) + } + } + } + } + } + } + } + } + }; + + createHeaderText(); + + if (allowBulkSelection) + { + controlContainer.Height = 35; + controlContainer.Add(toggleAllCheckbox = new ToggleAllCheckbox(this) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Scale = new Vector2(0.8f), + RelativeSizeAxes = Axes.X, + LabelText = "Enable All", + Shear = new Vector2(-ModPanel.SHEAR_X, 0) + }); + panelFlow.Padding = new MarginPadding + { + Top = 0, + Bottom = 7, + Horizontal = 7 + }; + } + } + + private void createHeaderText() + { + IEnumerable headerTextWords = modType.Humanize(LetterCasing.Title).Split(' '); + + if (headerTextWords.Count() > 1) + { + headerText.AddText($"{headerTextWords.First()} ", t => t.Font = t.Font.With(weight: FontWeight.SemiBold)); + headerTextWords = headerTextWords.Skip(1); + } + + headerText.AddText(string.Join(' ', headerTextWords)); + } + + [BackgroundDependencyLoader] + private void load(OsuGameBase game, OverlayColourProvider colourProvider, OsuColour colours) + { + availableMods.BindTo(game.AvailableMods); + + headerBackground.Colour = accentColour = colours.ForModType(modType); + + if (toggleAllCheckbox != null) + { + toggleAllCheckbox.AccentColour = accentColour; + toggleAllCheckbox.AccentHoverColour = accentColour.Lighten(0.3f); + } + + contentContainer.BorderColour = ColourInfo.GradientVertical(colourProvider.Background4, colourProvider.Background3); + contentBackground.Colour = colourProvider.Background4; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + availableMods.BindValueChanged(_ => Scheduler.AddOnce(updateMods)); + updateMods(); + } + + private CancellationTokenSource? cancellationTokenSource; + + private void updateMods() + { + var newMods = ModUtils.FlattenMods(availableMods.Value.GetValueOrDefault(modType) ?? Array.Empty()).ToList(); + + if (newMods.SequenceEqual(panelFlow.Children.Select(p => p.Mod))) + return; + + cancellationTokenSource?.Cancel(); + + var panels = newMods.Select(mod => new ModPanel(mod) + { + Shear = new Vector2(-ModPanel.SHEAR_X, 0) + }); + + Task? loadTask; + + latestLoadTask = loadTask = LoadComponentsAsync(panels, loaded => + { + panelFlow.ChildrenEnumerable = loaded; + + foreach (var panel in panelFlow) + panel.Active.BindValueChanged(_ => updateToggleState()); + updateToggleState(); + + updateFilter(); + }, (cancellationTokenSource = new CancellationTokenSource()).Token); + loadTask.ContinueWith(_ => + { + if (loadTask == latestLoadTask) + latestLoadTask = null; + }); + } + + #region Bulk select / deselect + + private const double initial_multiple_selection_delay = 120; + + private double selectionDelay = initial_multiple_selection_delay; + private double lastSelection; + + private readonly Queue pendingSelectionOperations = new Queue(); + + protected bool SelectionAnimationRunning => pendingSelectionOperations.Count > 0; + + protected override void Update() + { + base.Update(); + + if (selectionDelay == initial_multiple_selection_delay || Time.Current - lastSelection >= selectionDelay) + { + if (pendingSelectionOperations.TryDequeue(out var dequeuedAction)) + { + dequeuedAction(); + + // each time we play an animation, we decrease the time until the next animation (to ramp the visual and audible elements). + selectionDelay = Math.Max(30, selectionDelay * 0.8f); + lastSelection = Time.Current; + } + else + { + // reset the selection delay after all animations have been completed. + // this will cause the next action to be immediately performed. + selectionDelay = initial_multiple_selection_delay; + } + } + } + + private void updateToggleState() + { + if (toggleAllCheckbox != null && !SelectionAnimationRunning) + { + toggleAllCheckbox.Alpha = panelFlow.Any(panel => !panel.Filtered.Value) ? 1 : 0; + toggleAllCheckbox.Current.Value = panelFlow.Where(panel => !panel.Filtered.Value).All(panel => panel.Active.Value); + } + } + + /// + /// Selects all mods. + /// + public void SelectAll() + { + pendingSelectionOperations.Clear(); + + foreach (var button in panelFlow.Where(b => !b.Active.Value && !b.Filtered.Value)) + pendingSelectionOperations.Enqueue(() => button.Active.Value = true); + } + + /// + /// Deselects all mods. + /// + public void DeselectAll() + { + pendingSelectionOperations.Clear(); + + foreach (var button in panelFlow.Where(b => b.Active.Value && !b.Filtered.Value)) + pendingSelectionOperations.Enqueue(() => button.Active.Value = false); + } + + private class ToggleAllCheckbox : OsuCheckbox + { + private Color4 accentColour; + + public Color4 AccentColour + { + get => accentColour; + set + { + accentColour = value; + updateState(); + } + } + + private Color4 accentHoverColour; + + public Color4 AccentHoverColour + { + get => accentHoverColour; + set + { + accentHoverColour = value; + updateState(); + } + } + + private readonly ModColumn column; + + public ToggleAllCheckbox(ModColumn column) + : base(false) + { + this.column = column; + } + + protected override void ApplyLabelParameters(SpriteText text) + { + base.ApplyLabelParameters(text); + text.Font = text.Font.With(weight: FontWeight.SemiBold); + } + + [BackgroundDependencyLoader] + private void load() + { + updateState(); + } + + private void updateState() + { + Nub.AccentColour = AccentColour; + Nub.GlowingAccentColour = AccentHoverColour; + Nub.GlowColour = AccentHoverColour.Opacity(0.2f); + } + + protected override void OnUserChange(bool value) + { + if (value) + column.SelectAll(); + else + column.DeselectAll(); + } + } + + #endregion + + #region Filtering support + + private void updateFilter() + { + foreach (var modPanel in panelFlow) + modPanel.ApplyFilter(Filter); + + updateToggleState(); + } + + #endregion + + #region Keyboard selection support + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.ControlPressed || e.AltPressed) return false; + if (toggleKeys == null) return false; + + int index = Array.IndexOf(toggleKeys, e.Key); + if (index < 0) return false; + + var panel = panelFlow.ElementAtOrDefault(index); + if (panel == null || panel.Filtered.Value) return false; + + panel.Active.Toggle(); + return true; + } + + #endregion + } +} diff --git a/osu.Game/Overlays/Mods/ModPanel.cs b/osu.Game/Overlays/Mods/ModPanel.cs index af8ad3eb18..312171cf74 100644 --- a/osu.Game/Overlays/Mods/ModPanel.cs +++ b/osu.Game/Overlays/Mods/ModPanel.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -28,6 +29,7 @@ namespace osu.Game.Overlays.Mods { public Mod Mod { get; } public BindableBool Active { get; } = new BindableBool(); + public BindableBool Filtered { get; } = new BindableBool(); protected readonly Box Background; protected readonly Container SwitchContainer; @@ -40,10 +42,10 @@ namespace osu.Game.Overlays.Mods protected const double TRANSITION_DURATION = 150; - protected const float SHEAR_X = 0.2f; + public const float SHEAR_X = 0.2f; + public const float CORNER_RADIUS = 7; protected const float HEIGHT = 42; - protected const float CORNER_RADIUS = 7; protected const float IDLE_SWITCH_WIDTH = 54; protected const float EXPANDED_SWITCH_WIDTH = 70; @@ -157,6 +159,7 @@ namespace osu.Game.Overlays.Mods playStateChangeSamples(); UpdateState(); }); + Filtered.BindValueChanged(_ => updateFilterState()); UpdateState(); FinishTransforms(true); @@ -190,7 +193,7 @@ namespace osu.Game.Overlays.Mods mouseDown = true; UpdateState(); - return true; + return false; } protected override void OnMouseUp(MouseUpEvent e) @@ -235,5 +238,19 @@ namespace osu.Game.Overlays.Mods TextBackground.FadeColour(textBackgroundColour, transitionDuration, Easing.OutQuint); TextFlow.FadeColour(textColour, transitionDuration, Easing.OutQuint); } + + #region Filtering support + + public void ApplyFilter(Func? filter) + { + Filtered.Value = filter != null && !filter.Invoke(Mod); + } + + private void updateFilterState() + { + this.FadeTo(Filtered.Value ? 0 : 1); + } + + #endregion } } diff --git a/osu.Game/Overlays/SettingsToolboxGroup.cs b/osu.Game/Overlays/SettingsToolboxGroup.cs index 08321f68fe..b4178359a4 100644 --- a/osu.Game/Overlays/SettingsToolboxGroup.cs +++ b/osu.Game/Overlays/SettingsToolboxGroup.cs @@ -22,8 +22,9 @@ namespace osu.Game.Overlays { public class SettingsToolboxGroup : Container, IExpandable { + public const int CONTAINER_WIDTH = 270; + private const float transition_duration = 250; - private const int container_width = 270; private const int border_thickness = 2; private const int header_height = 30; private const int corner_radius = 5; @@ -49,7 +50,7 @@ namespace osu.Game.Overlays public SettingsToolboxGroup(string title) { AutoSizeAxes = Axes.Y; - Width = container_width; + Width = CONTAINER_WIDTH; Masking = true; CornerRadius = corner_radius; BorderColour = Color4.Black; @@ -201,7 +202,5 @@ namespace osu.Game.Overlays } protected override Container Content => content; - - protected override bool OnMouseDown(MouseDownEvent e) => true; } } diff --git a/osu.Game/Rulesets/Mods/IUpdatableByPlayfield.cs b/osu.Game/Rulesets/Mods/IUpdatableByPlayfield.cs index 9baa252caf..7cf480a11b 100644 --- a/osu.Game/Rulesets/Mods/IUpdatableByPlayfield.cs +++ b/osu.Game/Rulesets/Mods/IUpdatableByPlayfield.cs @@ -5,8 +5,19 @@ using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Mods { + /// + /// An interface for s that are updated every frame by a . + /// public interface IUpdatableByPlayfield : IApplicableMod { + /// + /// Update this . + /// + /// The main + /// + /// This method is called once per frame during gameplay by the main only. + /// To access nested s, use . + /// void Update(Playfield playfield); } } diff --git a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs index 637d0a872a..fea13cf4b6 100644 --- a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs +++ b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs @@ -29,8 +29,15 @@ namespace osu.Game.Rulesets.Scoring /// A non-null value if unstable rate could be calculated, /// and if unstable rate cannot be calculated due to being empty. /// - public static double? CalculateAverageHitError(this IEnumerable hitEvents) => - hitEvents.Where(affectsUnstableRate).Select(ev => ev.TimeOffset).Average(); + public static double? CalculateAverageHitError(this IEnumerable hitEvents) + { + double[] timeOffsets = hitEvents.Where(affectsUnstableRate).Select(ev => ev.TimeOffset).ToArray(); + + if (timeOffsets.Length == 0) + return null; + + return timeOffsets.Average(); + } private static bool affectsUnstableRate(HitEvent e) => !(e.HitObject.HitWindows is HitWindows.EmptyHitWindows) && e.Result.IsHit(); diff --git a/osu.Game/Rulesets/UI/Playfield.cs b/osu.Game/Rulesets/UI/Playfield.cs index d0bbf859af..30e71dde1c 100644 --- a/osu.Game/Rulesets/UI/Playfield.cs +++ b/osu.Game/Rulesets/UI/Playfield.cs @@ -79,6 +79,11 @@ namespace osu.Game.Rulesets.UI private readonly List nestedPlayfields = new List(); + /// + /// Whether this is nested in another . + /// + public bool IsNested { get; private set; } + /// /// Whether judgements should be displayed by this and and all nested s. /// @@ -206,6 +211,8 @@ namespace osu.Game.Rulesets.UI /// The to add. protected void AddNested(Playfield otherPlayfield) { + otherPlayfield.IsNested = true; + otherPlayfield.DisplayJudgements.BindTo(DisplayJudgements); otherPlayfield.NewResult += (d, r) => NewResult?.Invoke(d, r); @@ -229,7 +236,7 @@ namespace osu.Game.Rulesets.UI { base.Update(); - if (mods != null) + if (!IsNested && mods != null) { foreach (var mod in mods) { diff --git a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs index 9460ec680c..f0ead05280 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs @@ -7,10 +7,15 @@ using System.Linq; using System.Text; using osu.Framework.Extensions; using osu.Game.Beatmaps; +using osu.Game.Extensions; using osu.Game.IO.Legacy; +using osu.Game.Replays.Legacy; +using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays.Types; using SharpCompress.Compressors.LZMA; +#nullable enable + namespace osu.Game.Scoring.Legacy { public class LegacyScoreEncoder @@ -27,15 +32,24 @@ namespace osu.Game.Scoring.Legacy public const int FIRST_LAZER_VERSION = 30000000; private readonly Score score; - private readonly IBeatmap beatmap; + private readonly IBeatmap? beatmap; - public LegacyScoreEncoder(Score score, IBeatmap beatmap) + /// + /// Create a new score encoder for a specific score. + /// + /// The score to be encoded. + /// The beatmap used to convert frames for the score. May be null if the frames are already s. + /// + public LegacyScoreEncoder(Score score, IBeatmap? beatmap) { this.score = score; this.beatmap = beatmap; - if (score.ScoreInfo.BeatmapInfo.Ruleset.OnlineID < 0 || score.ScoreInfo.BeatmapInfo.Ruleset.OnlineID > 3) - throw new ArgumentException("Only scores in the osu, taiko, catch, or mania rulesets can be encoded to the legacy score format.", nameof(score)); + if (beatmap == null && !score.Replay.Frames.All(f => f is LegacyReplayFrame)) + throw new ArgumentException(@"Beatmap must be provided if frames are not already legacy frames.", nameof(beatmap)); + + if (!score.ScoreInfo.Ruleset.IsLegacyRuleset()) + throw new ArgumentException(@"Only scores in the osu, taiko, catch, or mania rulesets can be encoded to the legacy score format.", nameof(score)); } public void Encode(Stream stream) @@ -101,11 +115,13 @@ namespace osu.Game.Scoring.Legacy { int lastTime = 0; - foreach (var f in score.Replay.Frames.OfType().Select(f => f.ToLegacy(beatmap))) + foreach (var f in score.Replay.Frames) { + var legacyFrame = getLegacyFrame(f); + // Rounding because stable could only parse integral values - int time = (int)Math.Round(f.Time); - replayData.Append(FormattableString.Invariant($"{time - lastTime}|{f.MouseX ?? 0}|{f.MouseY ?? 0}|{(int)f.ButtonState},")); + int time = (int)Math.Round(legacyFrame.Time); + replayData.Append(FormattableString.Invariant($"{time - lastTime}|{legacyFrame.MouseX ?? 0}|{legacyFrame.MouseY ?? 0}|{(int)legacyFrame.ButtonState},")); lastTime = time; } } @@ -117,6 +133,21 @@ namespace osu.Game.Scoring.Legacy } } + private LegacyReplayFrame getLegacyFrame(ReplayFrame replayFrame) + { + switch (replayFrame) + { + case LegacyReplayFrame legacyFrame: + return legacyFrame; + + case IConvertibleReplayFrame convertibleFrame: + return convertibleFrame.ToLegacy(beatmap); + + default: + throw new ArgumentException(@"Frame could not be converted to legacy frames", nameof(replayFrame)); + } + } + private string getHpGraphFormatted() { // todo: implement, maybe? diff --git a/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs b/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs index c6787a1fb1..2a8435ff47 100644 --- a/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs +++ b/osu.Game/Screens/Edit/Components/Menus/EditorMenuBar.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -18,8 +17,6 @@ namespace osu.Game.Screens.Edit.Components.Menus { public class EditorMenuBar : OsuMenu { - public readonly Bindable Mode = new Bindable(); - public EditorMenuBar() : base(Direction.Horizontal, true) { @@ -28,25 +25,6 @@ namespace osu.Game.Screens.Edit.Components.Menus MaskingContainer.CornerRadius = 0; ItemsContainer.Padding = new MarginPadding { Left = 100 }; BackgroundColour = Color4Extensions.FromHex("111"); - - ScreenSelectionTabControl tabControl; - AddRangeInternal(new Drawable[] - { - tabControl = new ScreenSelectionTabControl - { - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - X = -15 - } - }); - - Mode.BindTo(tabControl.Current); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - Mode.TriggerChange(); } protected override Framework.Graphics.UserInterface.Menu CreateSubMenu() => new SubMenu(); diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index c2775ae101..dcb7e3a282 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -89,6 +89,8 @@ namespace osu.Game.Screens.Edit [Resolved(canBeNull: true)] private NotificationOverlay notifications { get; set; } + public readonly Bindable Mode = new Bindable(); + public IBindable SamplePlaybackDisabled => samplePlaybackDisabled; private readonly Bindable samplePlaybackDisabled = new Bindable(); @@ -115,8 +117,6 @@ namespace osu.Game.Screens.Edit [CanBeNull] // Should be non-null once it can support custom rulesets. private EditorChangeHandler changeHandler; - private EditorMenuBar menuBar; - private DependencyContainer dependencies; private TestGameplayButton testGameplayButton; @@ -239,40 +239,49 @@ namespace osu.Game.Screens.Edit Name = "Top bar", RelativeSizeAxes = Axes.X, Height = 40, - Child = menuBar = new EditorMenuBar + Children = new Drawable[] { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - RelativeSizeAxes = Axes.Both, - Mode = { Value = isNewBeatmap ? EditorScreenMode.SongSetup : EditorScreenMode.Compose }, - Items = new[] + new EditorMenuBar { - new MenuItem("File") + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Both, + Items = new[] { - Items = createFileMenuItems() - }, - new MenuItem("Edit") - { - Items = new[] + new MenuItem("File") { - undoMenuItem = new EditorMenuItem("Undo", MenuItemType.Standard, Undo), - redoMenuItem = new EditorMenuItem("Redo", MenuItemType.Standard, Redo), - new EditorMenuItemSpacer(), - cutMenuItem = new EditorMenuItem("Cut", MenuItemType.Standard, Cut), - copyMenuItem = new EditorMenuItem("Copy", MenuItemType.Standard, Copy), - pasteMenuItem = new EditorMenuItem("Paste", MenuItemType.Standard, Paste), - } - }, - new MenuItem("View") - { - Items = new MenuItem[] + Items = createFileMenuItems() + }, + new MenuItem("Edit") { - new WaveformOpacityMenuItem(config.GetBindable(OsuSetting.EditorWaveformOpacity)), - new HitAnimationsMenuItem(config.GetBindable(OsuSetting.EditorHitAnimations)) + Items = new[] + { + undoMenuItem = new EditorMenuItem("Undo", MenuItemType.Standard, Undo), + redoMenuItem = new EditorMenuItem("Redo", MenuItemType.Standard, Redo), + new EditorMenuItemSpacer(), + cutMenuItem = new EditorMenuItem("Cut", MenuItemType.Standard, Cut), + copyMenuItem = new EditorMenuItem("Copy", MenuItemType.Standard, Copy), + pasteMenuItem = new EditorMenuItem("Paste", MenuItemType.Standard, Paste), + } + }, + new MenuItem("View") + { + Items = new MenuItem[] + { + new WaveformOpacityMenuItem(config.GetBindable(OsuSetting.EditorWaveformOpacity)), + new HitAnimationsMenuItem(config.GetBindable(OsuSetting.EditorHitAnimations)) + } } } - } - } + }, + new ScreenSelectionTabControl + { + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + X = -15, + Current = Mode, + }, + }, }, new Container { @@ -340,14 +349,15 @@ namespace osu.Game.Screens.Edit changeHandler?.CanUndo.BindValueChanged(v => undoMenuItem.Action.Disabled = !v.NewValue, true); changeHandler?.CanRedo.BindValueChanged(v => redoMenuItem.Action.Disabled = !v.NewValue, true); - - menuBar.Mode.ValueChanged += onModeChanged; } protected override void LoadComplete() { base.LoadComplete(); setUpClipboardActionAvailability(); + + Mode.Value = isNewBeatmap ? EditorScreenMode.SongSetup : EditorScreenMode.Compose; + Mode.BindValueChanged(onModeChanged, true); } /// @@ -517,23 +527,23 @@ namespace osu.Game.Screens.Edit return true; case GlobalAction.EditorComposeMode: - menuBar.Mode.Value = EditorScreenMode.Compose; + Mode.Value = EditorScreenMode.Compose; return true; case GlobalAction.EditorDesignMode: - menuBar.Mode.Value = EditorScreenMode.Design; + Mode.Value = EditorScreenMode.Design; return true; case GlobalAction.EditorTimingMode: - menuBar.Mode.Value = EditorScreenMode.Timing; + Mode.Value = EditorScreenMode.Timing; return true; case GlobalAction.EditorSetupMode: - menuBar.Mode.Value = EditorScreenMode.SongSetup; + Mode.Value = EditorScreenMode.SongSetup; return true; case GlobalAction.EditorVerifyMode: - menuBar.Mode.Value = EditorScreenMode.Verify; + Mode.Value = EditorScreenMode.Verify; return true; case GlobalAction.EditorTestGameplay: diff --git a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs index 200921680e..2b6db5f59e 100644 --- a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs +++ b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using osu.Framework; using osu.Framework.Allocation; @@ -13,6 +14,7 @@ using osu.Framework.Graphics; using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Database; namespace osu.Game.Screens.Play { @@ -43,7 +45,7 @@ namespace osu.Game.Screens.Play Precision = 0.1, }; - private double totalAppliedOffset => userOffsetClock.RateAdjustedOffset + platformOffsetClock.RateAdjustedOffset; + private double totalAppliedOffset => userBeatmapOffsetClock.RateAdjustedOffset + userGlobalOffsetClock.RateAdjustedOffset + platformOffsetClock.RateAdjustedOffset; private readonly BindableDouble pauseFreqAdjust = new BindableDouble(1); @@ -52,12 +54,21 @@ namespace osu.Game.Screens.Play private readonly bool startAtGameplayStart; private readonly double firstHitObjectTime; - private HardwareCorrectionOffsetClock userOffsetClock; + private HardwareCorrectionOffsetClock userGlobalOffsetClock; + private HardwareCorrectionOffsetClock userBeatmapOffsetClock; private HardwareCorrectionOffsetClock platformOffsetClock; private MasterGameplayClock masterGameplayClock; private Bindable userAudioOffset; private double startOffset; + private IDisposable beatmapOffsetSubscription; + + [Resolved] + private RealmAccess realm { get; set; } + + [Resolved] + private OsuConfigManager config { get; set; } + public MasterGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStartTime, bool startAtGameplayStart = false) : base(beatmap.Track) { @@ -68,11 +79,33 @@ namespace osu.Game.Screens.Play firstHitObjectTime = beatmap.Beatmap.HitObjects.First().StartTime; } - [BackgroundDependencyLoader] - private void load(OsuConfigManager config) + protected override void LoadComplete() { + base.LoadComplete(); + userAudioOffset = config.GetBindable(OsuSetting.AudioOffset); - userAudioOffset.BindValueChanged(offset => userOffsetClock.Offset = offset.NewValue, true); + userAudioOffset.BindValueChanged(offset => userGlobalOffsetClock.Offset = offset.NewValue, true); + + beatmapOffsetSubscription = realm.RegisterCustomSubscription(r => + { + var userSettings = r.Find(beatmap.BeatmapInfo.ID)?.UserSettings; + + if (userSettings == null) // only the case for tests. + return null; + + void onUserSettingsOnPropertyChanged(object sender, PropertyChangedEventArgs args) + { + if (args.PropertyName == nameof(BeatmapUserSettings.Offset)) + updateOffset(); + } + + updateOffset(); + userSettings.PropertyChanged += onUserSettingsOnPropertyChanged; + + return new InvokeOnDisposal(() => userSettings.PropertyChanged -= onUserSettingsOnPropertyChanged); + + void updateOffset() => userBeatmapOffsetClock.Offset = userSettings.Offset; + }); // sane default provided by ruleset. startOffset = gameplayStartTime; @@ -161,9 +194,10 @@ namespace osu.Game.Screens.Play platformOffsetClock = new HardwareCorrectionOffsetClock(source, pauseFreqAdjust) { Offset = RuntimeInfo.OS == RuntimeInfo.Platform.Windows ? 15 : 0 }; // the final usable gameplay clock with user-set offsets applied. - userOffsetClock = new HardwareCorrectionOffsetClock(platformOffsetClock, pauseFreqAdjust); + userGlobalOffsetClock = new HardwareCorrectionOffsetClock(platformOffsetClock, pauseFreqAdjust); + userBeatmapOffsetClock = new HardwareCorrectionOffsetClock(userGlobalOffsetClock, pauseFreqAdjust); - return masterGameplayClock = new MasterGameplayClock(userOffsetClock); + return masterGameplayClock = new MasterGameplayClock(userBeatmapOffsetClock); } /// @@ -209,6 +243,7 @@ namespace osu.Game.Screens.Play protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); + beatmapOffsetSubscription?.Dispose(); removeSourceClockAdjustments(); } diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index d4b02622d3..86ea412488 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -136,7 +136,11 @@ namespace osu.Game.Screens.Play public readonly PlayerConfiguration Configuration; - protected Score Score { get; private set; } + /// + /// The score for the current play session. + /// Available only after the player is loaded. + /// + public Score Score { get; private set; } /// /// Create a new player instance. diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 6009c85583..41eb822e39 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -61,6 +61,8 @@ namespace osu.Game.Screens.Play protected VisualSettings VisualSettings { get; private set; } + protected AudioSettings AudioSettings { get; private set; } + protected Task LoadTask { get; private set; } protected Task DisposalTask { get; private set; } @@ -141,6 +143,8 @@ namespace osu.Game.Screens.Play muteWarningShownOnce = sessionStatics.GetBindable(Static.MutedAudioNotificationShownOnce); batteryWarningShownOnce = sessionStatics.GetBindable(Static.LowBatteryNotificationShownOnce); + const float padding = 25; + InternalChildren = new Drawable[] { (content = new LogoTrackingContainer @@ -156,19 +160,27 @@ namespace osu.Game.Screens.Play Anchor = Anchor.Centre, Origin = Anchor.Centre, }, - PlayerSettings = new FillFlowContainer + new OsuScrollContainer { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 20), - Margin = new MarginPadding(25), - Children = new PlayerSettingsGroup[] + RelativeSizeAxes = Axes.Y, + Width = SettingsToolboxGroup.CONTAINER_WIDTH + padding * 2, + Padding = new MarginPadding { Vertical = padding }, + Masking = false, + Child = PlayerSettings = new FillFlowContainer { - VisualSettings = new VisualSettings(), - new InputSettings() - } + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 20), + Padding = new MarginPadding { Horizontal = padding }, + Children = new PlayerSettingsGroup[] + { + VisualSettings = new VisualSettings(), + AudioSettings = new AudioSettings(), + new InputSettings() + } + }, }, idleTracker = new IdleTracker(750), }), @@ -225,6 +237,10 @@ namespace osu.Game.Screens.Play { base.OnResuming(last); + var lastScore = player.Score; + + AudioSettings.ReferenceScore.Value = lastScore?.ScoreInfo; + // prepare for a retry. player = null; playerConsumed = false; diff --git a/osu.Game/Screens/Play/PlayerSettings/AudioSettings.cs b/osu.Game/Screens/Play/PlayerSettings/AudioSettings.cs new file mode 100644 index 0000000000..32de5333e1 --- /dev/null +++ b/osu.Game/Screens/Play/PlayerSettings/AudioSettings.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Configuration; +using osu.Game.Scoring; + +namespace osu.Game.Screens.Play.PlayerSettings +{ + public class AudioSettings : PlayerSettingsGroup + { + public Bindable ReferenceScore { get; } = new Bindable(); + + private readonly PlayerCheckbox beatmapHitsoundsToggle; + + public AudioSettings() + : base("Audio Settings") + { + Children = new Drawable[] + { + beatmapHitsoundsToggle = new PlayerCheckbox { LabelText = "Beatmap hitsounds" }, + new BeatmapOffsetControl + { + ReferenceScore = { BindTarget = ReferenceScore }, + }, + }; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + beatmapHitsoundsToggle.Current = config.GetBindable(OsuSetting.BeatmapHitsounds); + } + } +} diff --git a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs new file mode 100644 index 0000000000..dc3e80d695 --- /dev/null +++ b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs @@ -0,0 +1,213 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Localisation; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; +using osu.Game.Screens.Ranking.Statistics; +using osuTK; + +#nullable enable + +namespace osu.Game.Screens.Play.PlayerSettings +{ + public class BeatmapOffsetControl : CompositeDrawable + { + public Bindable ReferenceScore { get; } = new Bindable(); + + public BindableDouble Current { get; } = new BindableDouble + { + Default = 0, + Value = 0, + MinValue = -50, + MaxValue = 50, + Precision = 0.1, + }; + + private readonly FillFlowContainer referenceScoreContainer; + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + [Resolved] + private IBindable beatmap { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + private double lastPlayAverage; + + private SettingsButton? useAverageButton; + + private IDisposable? beatmapOffsetSubscription; + + private Task? realmWriteTask; + + public BeatmapOffsetControl() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(10), + Children = new Drawable[] + { + new PlayerSliderBar + { + KeyboardStep = 5, + LabelText = BeatmapOffsetControlStrings.BeatmapOffset, + Current = Current, + }, + referenceScoreContainer = new FillFlowContainer + { + Spacing = new Vector2(10), + Direction = FillDirection.Vertical, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ReferenceScore.BindValueChanged(scoreChanged, true); + + beatmapOffsetSubscription = realm.RegisterCustomSubscription(r => + { + var userSettings = r.Find(beatmap.Value.BeatmapInfo.ID)?.UserSettings; + + if (userSettings == null) // only the case for tests. + return null; + + Current.Value = userSettings.Offset; + userSettings.PropertyChanged += onUserSettingsOnPropertyChanged; + + return new InvokeOnDisposal(() => userSettings.PropertyChanged -= onUserSettingsOnPropertyChanged); + + void onUserSettingsOnPropertyChanged(object sender, PropertyChangedEventArgs args) + { + if (args.PropertyName == nameof(BeatmapUserSettings.Offset)) + Current.Value = userSettings.Offset; + } + }); + + Current.BindValueChanged(currentChanged); + } + + private void currentChanged(ValueChangedEvent offset) + { + Scheduler.AddOnce(updateOffset); + + void updateOffset() + { + // ensure the previous write has completed. ignoring performance concerns, if we don't do this, the async writes could be out of sequence. + if (realmWriteTask?.IsCompleted == false) + { + Scheduler.AddOnce(updateOffset); + return; + } + + if (useAverageButton != null) + useAverageButton.Enabled.Value = !Precision.AlmostEquals(lastPlayAverage, -Current.Value, Current.Precision / 2); + + realmWriteTask = realm.WriteAsync(r => + { + var settings = r.Find(beatmap.Value.BeatmapInfo.ID)?.UserSettings; + + if (settings == null) // only the case for tests. + return; + + if (settings.Offset == Current.Value) + return; + + settings.Offset = Current.Value; + }); + } + } + + private void scoreChanged(ValueChangedEvent score) + { + referenceScoreContainer.Clear(); + + if (score.NewValue == null) + return; + + if (score.NewValue.Mods.Any(m => !m.UserPlayable)) + return; + + var hitEvents = score.NewValue.HitEvents; + + if (!(hitEvents.CalculateAverageHitError() is double average)) + return; + + referenceScoreContainer.Children = new Drawable[] + { + new OsuSpriteText + { + Text = BeatmapOffsetControlStrings.PreviousPlay + }, + }; + + if (hitEvents.Count < 10) + { + referenceScoreContainer.AddRange(new Drawable[] + { + new OsuTextFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Colour = colours.Red1, + Text = BeatmapOffsetControlStrings.PreviousPlayTooShortToUseForCalibration + }, + }); + + return; + } + + lastPlayAverage = average; + + referenceScoreContainer.AddRange(new Drawable[] + { + new HitEventTimingDistributionGraph(hitEvents) + { + RelativeSizeAxes = Axes.X, + Height = 50, + }, + new AverageHitError(hitEvents), + useAverageButton = new SettingsButton + { + Text = BeatmapOffsetControlStrings.CalibrateUsingLastPlay, + Action = () => Current.Value = -lastPlayAverage + }, + }); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + beatmapOffsetSubscription?.Dispose(); + } + } +} diff --git a/osu.Game/Screens/Play/PlayerSettings/VisualSettings.cs b/osu.Game/Screens/Play/PlayerSettings/VisualSettings.cs index a97078c461..81950efa9e 100644 --- a/osu.Game/Screens/Play/PlayerSettings/VisualSettings.cs +++ b/osu.Game/Screens/Play/PlayerSettings/VisualSettings.cs @@ -15,7 +15,6 @@ namespace osu.Game.Screens.Play.PlayerSettings private readonly PlayerCheckbox showStoryboardToggle; private readonly PlayerCheckbox beatmapSkinsToggle; private readonly PlayerCheckbox beatmapColorsToggle; - private readonly PlayerCheckbox beatmapHitsoundsToggle; public VisualSettings() : base("Visual Settings") @@ -45,7 +44,6 @@ namespace osu.Game.Screens.Play.PlayerSettings showStoryboardToggle = new PlayerCheckbox { LabelText = "Storyboard / Video" }, beatmapSkinsToggle = new PlayerCheckbox { LabelText = "Beatmap skins" }, beatmapColorsToggle = new PlayerCheckbox { LabelText = "Beatmap colours" }, - beatmapHitsoundsToggle = new PlayerCheckbox { LabelText = "Beatmap hitsounds" } }; } @@ -57,7 +55,6 @@ namespace osu.Game.Screens.Play.PlayerSettings showStoryboardToggle.Current = config.GetBindable(OsuSetting.ShowStoryboard); beatmapSkinsToggle.Current = config.GetBindable(OsuSetting.BeatmapSkins); beatmapColorsToggle.Current = config.GetBindable(OsuSetting.BeatmapColours); - beatmapHitsoundsToggle.Current = config.GetBindable(OsuSetting.BeatmapHitsounds); } } } diff --git a/osu.Game/Screens/Play/SoloPlayer.cs b/osu.Game/Screens/Play/SoloPlayer.cs index 824c0072e3..a935ce49eb 100644 --- a/osu.Game/Screens/Play/SoloPlayer.cs +++ b/osu.Game/Screens/Play/SoloPlayer.cs @@ -4,10 +4,10 @@ using System; using System.Diagnostics; using osu.Game.Beatmaps; +using osu.Game.Extensions; using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Online.Solo; -using osu.Game.Rulesets; using osu.Game.Scoring; namespace osu.Game.Screens.Play @@ -32,7 +32,7 @@ namespace osu.Game.Screens.Play if (beatmapId <= 0) return null; - if (rulesetId < 0 || rulesetId > ILegacyRuleset.MAX_LEGACY_RULESET_ID) + if (!Ruleset.Value.IsLegacyRuleset()) return null; return new CreateSoloScoreRequest(beatmapId, rulesetId, Game.VersionHash); diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs index 859b42d66d..95f017d625 100644 --- a/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/PerformanceStatistic.cs @@ -38,7 +38,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics else { performanceCache.CalculatePerformanceAsync(score, cancellationTokenSource.Token) - .ContinueWith(t => Schedule(() => setPerformanceValue(t.GetResultSafely().Total)), cancellationTokenSource.Token); + .ContinueWith(t => Schedule(() => setPerformanceValue(t.GetResultSafely()?.Total)), cancellationTokenSource.Token); } } diff --git a/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs b/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs index 93885b6e02..372c0d4849 100644 --- a/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs +++ b/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs @@ -160,8 +160,6 @@ namespace osu.Game.Screens.Ranking.Statistics RelativeSizeAxes = Axes.Both; - Padding = new MarginPadding { Horizontal = 1 }; - InternalChild = new Circle { RelativeSizeAxes = Axes.Both, diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs index 907a2c9bda..eb0addd377 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs @@ -11,6 +11,7 @@ using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Game.Beatmaps; using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.Leaderboards; @@ -98,6 +99,7 @@ namespace osu.Game.Screens.Select.Leaderboards protected override APIRequest FetchScores(CancellationToken cancellationToken) { var fetchBeatmapInfo = BeatmapInfo; + var fetchRuleset = ruleset.Value ?? fetchBeatmapInfo.Ruleset; if (fetchBeatmapInfo == null) { @@ -117,9 +119,15 @@ namespace osu.Game.Screens.Select.Leaderboards return null; } + if (!fetchRuleset.IsLegacyRuleset()) + { + SetErrorState(LeaderboardState.RulesetUnavailable); + return null; + } + if (fetchBeatmapInfo.OnlineID <= 0 || fetchBeatmapInfo.Status <= BeatmapOnlineStatus.Pending) { - SetErrorState(LeaderboardState.Unavailable); + SetErrorState(LeaderboardState.BeatmapUnavailable); return null; } @@ -137,7 +145,7 @@ namespace osu.Game.Screens.Select.Leaderboards else if (filterMods) requestMods = mods.Value; - var req = new GetScoresRequest(fetchBeatmapInfo, ruleset.Value ?? fetchBeatmapInfo.Ruleset, Scope, requestMods); + var req = new GetScoresRequest(fetchBeatmapInfo, fetchRuleset, Scope, requestMods); req.Success += r => { diff --git a/osu.Game/Skinning/Editor/SkinComponentToolbox.cs b/osu.Game/Skinning/Editor/SkinComponentToolbox.cs index 935d2756fb..ce9afd650a 100644 --- a/osu.Game/Skinning/Editor/SkinComponentToolbox.cs +++ b/osu.Game/Skinning/Editor/SkinComponentToolbox.cs @@ -23,6 +23,8 @@ namespace osu.Game.Skinning.Editor { public class SkinComponentToolbox : ScrollingToolboxGroup { + public const float WIDTH = 200; + public Action RequestPlacement; private const float component_display_scale = 0.8f; @@ -41,7 +43,7 @@ namespace osu.Game.Skinning.Editor : base("Components", height) { RelativeSizeAxes = Axes.None; - Width = 200; + Width = WIDTH; } [BackgroundDependencyLoader] diff --git a/osu.Game/Skinning/Editor/SkinEditor.cs b/osu.Game/Skinning/Editor/SkinEditor.cs index 8052f82c93..ae5cbc95f0 100644 --- a/osu.Game/Skinning/Editor/SkinEditor.cs +++ b/osu.Game/Skinning/Editor/SkinEditor.cs @@ -8,14 +8,14 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Testing; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; -using osu.Game.Resources.Localisation.Web; -using osuTK; +using osu.Game.Screens.Edit.Components.Menus; namespace osu.Game.Skinning.Editor { @@ -57,13 +57,43 @@ namespace osu.Game.Skinning.Editor RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - headerText = new OsuTextFlowContainer + new Container { - TextAnchor = Anchor.TopCentre, - Padding = new MarginPadding(20), - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.X + Name = "Top bar", + RelativeSizeAxes = Axes.X, + Depth = float.MinValue, + Height = 40, + Children = new Drawable[] + { + new EditorMenuBar + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + RelativeSizeAxes = Axes.Both, + Items = new[] + { + new MenuItem("File") + { + Items = new[] + { + new EditorMenuItem("Save", MenuItemType.Standard, Save), + new EditorMenuItem("Revert to default", MenuItemType.Destructive, revert), + new EditorMenuItemSpacer(), + new EditorMenuItem("Exit", MenuItemType.Standard, Hide), + }, + }, + } + }, + headerText = new OsuTextFlowContainer + { + TextAnchor = Anchor.TopRight, + Padding = new MarginPadding(5), + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + }, + }, }, new GridContainer { @@ -89,46 +119,6 @@ namespace osu.Game.Skinning.Editor Children = new Drawable[] { new SkinBlueprintContainer(targetScreen), - new TriangleButton - { - Margin = new MarginPadding(10), - Text = CommonStrings.ButtonsClose, - Width = 100, - Action = Hide, - }, - new FillFlowContainer - { - Direction = FillDirection.Horizontal, - AutoSizeAxes = Axes.Both, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Spacing = new Vector2(5), - Padding = new MarginPadding - { - Top = 10, - Left = 10, - }, - Margin = new MarginPadding - { - Right = 10, - Bottom = 10, - }, - Children = new Drawable[] - { - new TriangleButton - { - Text = "Save Changes", - Width = 140, - Action = Save, - }, - new DangerousTriangleButton - { - Text = "Revert to default", - Width = 140, - Action = revert, - }, - } - }, } }, } @@ -161,7 +151,7 @@ namespace osu.Game.Skinning.Editor { headerText.Clear(); - headerText.AddParagraph("Skin editor", cp => cp.Font = OsuFont.Default.With(size: 24)); + headerText.AddParagraph("Skin editor", cp => cp.Font = OsuFont.Default.With(size: 16)); headerText.NewParagraph(); headerText.AddText("Currently editing ", cp => { diff --git a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs index 86854ab6ff..61c363b019 100644 --- a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs +++ b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs @@ -5,6 +5,7 @@ using JetBrains.Annotations; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Primitives; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; @@ -100,30 +101,14 @@ namespace osu.Game.Skinning.Editor { if (visibility.NewValue == Visibility.Visible) { - updateMasking(); - target.AllowScaling = false; - target.RelativePositionAxes = Axes.Both; - - target.ScaleTo(VISIBLE_TARGET_SCALE, SkinEditor.TRANSITION_DURATION, Easing.OutQuint); - target.MoveToX(0.095f, SkinEditor.TRANSITION_DURATION, Easing.OutQuint); + target.SetCustomRect(new RectangleF(0.18f, 0.1f, VISIBLE_TARGET_SCALE, VISIBLE_TARGET_SCALE), true); } else { - target.AllowScaling = true; - - target.ScaleTo(1, SkinEditor.TRANSITION_DURATION, Easing.OutQuint).OnComplete(_ => updateMasking()); - target.MoveToX(0f, SkinEditor.TRANSITION_DURATION, Easing.OutQuint); + target.SetCustomRect(null); } } - private void updateMasking() - { - if (skinEditor == null) - return; - - target.Masking = skinEditor.State.Value == Visibility.Visible; - } - public void OnReleased(KeyBindingReleaseEvent e) { }