diff --git a/osu.Android.props b/osu.Android.props index 752eb160ed..f7b7b6fb23 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,7 +51,7 @@ - + diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs index db42667033..160af47a6d 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs @@ -10,9 +10,13 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components.Timelines.Summary; +using osu.Game.Screens.Edit.GameplayTest; +using osu.Game.Screens.Play; using osu.Game.Tests.Beatmaps.IO; +using osuTK.Graphics; using osuTK.Input; namespace osu.Game.Tests.Visual.Editing @@ -58,6 +62,42 @@ namespace osu.Game.Tests.Visual.Editing AddUntilStep("player pushed", () => (editorPlayer = Stack.CurrentScreen as EditorPlayer) != null); AddStep("exit player", () => editorPlayer.Exit()); AddUntilStep("current screen is editor", () => Stack.CurrentScreen is Editor); + AddUntilStep("background has correct params", () => + { + var background = this.ChildrenOfType().Single(); + return background.Colour == Color4.DarkGray && background.BlurAmount.Value == 0; + }); + } + + [Test] + public void TestGameplayTestWhenTrackRunning() + { + AddStep("start track", () => EditorClock.Start()); + AddAssert("sample playback enabled", () => !Editor.SamplePlaybackDisabled.Value); + + AddStep("click test gameplay button", () => + { + var button = Editor.ChildrenOfType().Single(); + + InputManager.MoveMouseTo(button); + InputManager.Click(MouseButton.Left); + }); + + EditorPlayer editorPlayer = null; + AddUntilStep("player pushed", () => (editorPlayer = Stack.CurrentScreen as EditorPlayer) != null); + AddAssert("editor track stopped", () => !EditorClock.IsRunning); + AddAssert("sample playback disabled", () => Editor.SamplePlaybackDisabled.Value); + + AddStep("exit player", () => editorPlayer.Exit()); + AddUntilStep("current screen is editor", () => Stack.CurrentScreen is Editor); + AddUntilStep("background has correct params", () => + { + var background = this.ChildrenOfType().Single(); + return background.Colour == Color4.DarkGray && background.BlurAmount.Value == 0; + }); + + AddStep("start track", () => EditorClock.Start()); + AddAssert("sample playback re-enabled", () => !Editor.SamplePlaybackDisabled.Value); } [Test] @@ -111,6 +151,35 @@ namespace osu.Game.Tests.Visual.Editing AddAssert("track stopped", () => !Beatmap.Value.Track.IsRunning); } + [Test] + public void TestSharedClockState() + { + AddStep("seek to 00:01:00", () => EditorClock.Seek(60_000)); + AddStep("click test gameplay button", () => + { + var button = Editor.ChildrenOfType().Single(); + + InputManager.MoveMouseTo(button); + InputManager.Click(MouseButton.Left); + }); + + EditorPlayer editorPlayer = null; + AddUntilStep("player pushed", () => (editorPlayer = Stack.CurrentScreen as EditorPlayer) != null); + + GameplayClockContainer gameplayClockContainer = null; + AddStep("fetch gameplay clock", () => gameplayClockContainer = editorPlayer.ChildrenOfType().First()); + AddUntilStep("gameplay clock running", () => gameplayClockContainer.IsRunning); + AddAssert("gameplay time past 00:01:00", () => gameplayClockContainer.CurrentTime >= 60_000); + + double timeAtPlayerExit = 0; + AddWaitStep("wait some", 5); + AddStep("store time before exit", () => timeAtPlayerExit = gameplayClockContainer.CurrentTime); + + AddStep("exit player", () => editorPlayer.Exit()); + AddUntilStep("current screen is editor", () => Stack.CurrentScreen is Editor); + AddAssert("time is past player exit", () => EditorClock.CurrentTime >= timeAtPlayerExit); + } + public override void TearDownSteps() { base.TearDownSteps(); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs new file mode 100644 index 0000000000..4012a672ed --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs @@ -0,0 +1,171 @@ +// 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 Humanizer; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Screens.Edit.Compose.Components.Timeline; +using osu.Game.Screens.Edit.Timing; +using osu.Game.Tests.Beatmaps; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneHitObjectDifficultyPointAdjustments : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("add test objects", () => + { + EditorBeatmap.Add(new Slider + { + StartTime = 0, + Position = (OsuPlayfield.BASE_SIZE - new Vector2(0, 100)) / 2, + Path = new SliderPath + { + ControlPoints = + { + new PathControlPoint(new Vector2(0, 0)), + new PathControlPoint(new Vector2(0, 100)) + } + } + }); + + EditorBeatmap.Add(new Slider + { + StartTime = 500, + Position = (OsuPlayfield.BASE_SIZE - new Vector2(100, 0)) / 2, + Path = new SliderPath + { + ControlPoints = + { + new PathControlPoint(new Vector2(0, 0)), + new PathControlPoint(new Vector2(100, 0)) + } + }, + DifficultyControlPoint = new DifficultyControlPoint + { + SliderVelocity = 2 + } + }); + }); + } + + [Test] + public void TestSingleSelection() + { + clickDifficultyPiece(0); + velocityPopoverHasSingleValue(1); + + dismissPopover(); + + // select first object to ensure that difficulty pieces for unselected objects + // work independently from selection state. + AddStep("select first object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.First())); + + clickDifficultyPiece(1); + velocityPopoverHasSingleValue(2); + + setVelocityViaPopover(5); + hitObjectHasVelocity(1, 5); + } + + [Test] + public void TestMultipleSelectionWithSameSliderVelocity() + { + AddStep("unify slider velocity", () => + { + foreach (var h in EditorBeatmap.HitObjects) + h.DifficultyControlPoint.SliderVelocity = 1.5; + }); + + AddStep("select both objects", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); + clickDifficultyPiece(0); + velocityPopoverHasSingleValue(1.5); + + dismissPopover(); + + clickDifficultyPiece(1); + velocityPopoverHasSingleValue(1.5); + + setVelocityViaPopover(5); + hitObjectHasVelocity(0, 5); + hitObjectHasVelocity(1, 5); + } + + [Test] + public void TestMultipleSelectionWithDifferentSliderVelocity() + { + AddStep("select both objects", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); + clickDifficultyPiece(0); + velocityPopoverHasIndeterminateValue(); + + dismissPopover(); + + clickDifficultyPiece(1); + velocityPopoverHasIndeterminateValue(); + + setVelocityViaPopover(3); + hitObjectHasVelocity(0, 3); + hitObjectHasVelocity(1, 3); + } + + private void clickDifficultyPiece(int objectIndex) => AddStep($"click {objectIndex.ToOrdinalWords()} difficulty piece", () => + { + var difficultyPiece = this.ChildrenOfType().Single(piece => piece.HitObject == EditorBeatmap.HitObjects.ElementAt(objectIndex)); + + InputManager.MoveMouseTo(difficultyPiece); + InputManager.Click(MouseButton.Left); + }); + + private void velocityPopoverHasSingleValue(double velocity) => AddUntilStep($"velocity popover has {velocity}", () => + { + var popover = this.ChildrenOfType().SingleOrDefault(); + var slider = popover?.ChildrenOfType>().Single(); + + return slider?.Current.Value == velocity; + }); + + private void velocityPopoverHasIndeterminateValue() => AddUntilStep("velocity popover has indeterminate value", () => + { + var popover = this.ChildrenOfType().SingleOrDefault(); + var slider = popover?.ChildrenOfType>().Single(); + + return slider != null && slider.Current.Value == null; + }); + + private void dismissPopover() + { + AddStep("dismiss popover", () => InputManager.Key(Key.Escape)); + AddUntilStep("wait for dismiss", () => !this.ChildrenOfType().Any(popover => popover.IsPresent)); + } + + private void setVelocityViaPopover(double velocity) => AddStep($"set {velocity} via popover", () => + { + var popover = this.ChildrenOfType().Single(); + var slider = popover.ChildrenOfType>().Single(); + slider.Current.Value = velocity; + }); + + private void hitObjectHasVelocity(int objectIndex, double velocity) => AddAssert($"{objectIndex.ToOrdinalWords()} has velocity {velocity}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex); + return h.DifficultyControlPoint.SliderVelocity == velocity; + }); + } +} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSamplePointAdjustments.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSamplePointAdjustments.cs new file mode 100644 index 0000000000..dca30a6fc0 --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSamplePointAdjustments.cs @@ -0,0 +1,252 @@ +// 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 Humanizer; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Screens.Edit.Compose.Components.Timeline; +using osu.Game.Screens.Edit.Timing; +using osu.Game.Tests.Beatmaps; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Editing +{ + public class TestSceneHitObjectSamplePointAdjustments : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("add test objects", () => + { + EditorBeatmap.Add(new HitCircle + { + StartTime = 0, + Position = (OsuPlayfield.BASE_SIZE - new Vector2(100, 0)) / 2, + SampleControlPoint = new SampleControlPoint + { + SampleBank = "normal", + SampleVolume = 80 + } + }); + + EditorBeatmap.Add(new HitCircle + { + StartTime = 500, + Position = (OsuPlayfield.BASE_SIZE + new Vector2(100, 0)) / 2, + SampleControlPoint = new SampleControlPoint + { + SampleBank = "soft", + SampleVolume = 60 + } + }); + }); + } + + [Test] + public void TestSingleSelection() + { + clickSamplePiece(0); + samplePopoverHasSingleBank("normal"); + samplePopoverHasSingleVolume(80); + + dismissPopover(); + + // select first object to ensure that sample pieces for unselected objects + // work independently from selection state. + AddStep("select first object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.First())); + + clickSamplePiece(1); + samplePopoverHasSingleBank("soft"); + samplePopoverHasSingleVolume(60); + + setVolumeViaPopover(90); + hitObjectHasSampleVolume(1, 90); + + setBankViaPopover("drum"); + hitObjectHasSampleBank(1, "drum"); + } + + [Test] + public void TestMultipleSelectionWithSameSampleVolume() + { + AddStep("unify sample volume", () => + { + foreach (var h in EditorBeatmap.HitObjects) + h.SampleControlPoint.SampleVolume = 50; + }); + + AddStep("select both objects", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); + clickSamplePiece(0); + samplePopoverHasSingleVolume(50); + + dismissPopover(); + + clickSamplePiece(1); + samplePopoverHasSingleVolume(50); + + setVolumeViaPopover(75); + hitObjectHasSampleVolume(0, 75); + hitObjectHasSampleVolume(1, 75); + } + + [Test] + public void TestMultipleSelectionWithDifferentSampleVolume() + { + AddStep("select both objects", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); + clickSamplePiece(0); + samplePopoverHasIndeterminateVolume(); + + dismissPopover(); + + clickSamplePiece(1); + samplePopoverHasIndeterminateVolume(); + + setVolumeViaPopover(30); + hitObjectHasSampleVolume(0, 30); + hitObjectHasSampleVolume(1, 30); + } + + [Test] + public void TestMultipleSelectionWithSameSampleBank() + { + AddStep("unify sample bank", () => + { + foreach (var h in EditorBeatmap.HitObjects) + h.SampleControlPoint.SampleBank = "soft"; + }); + + AddStep("select both objects", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); + clickSamplePiece(0); + samplePopoverHasSingleBank("soft"); + + dismissPopover(); + + clickSamplePiece(1); + samplePopoverHasSingleBank("soft"); + + setBankViaPopover(string.Empty); + hitObjectHasSampleBank(0, "soft"); + hitObjectHasSampleBank(1, "soft"); + samplePopoverHasSingleBank("soft"); + + setBankViaPopover("drum"); + hitObjectHasSampleBank(0, "drum"); + hitObjectHasSampleBank(1, "drum"); + samplePopoverHasSingleBank("drum"); + } + + [Test] + public void TestMultipleSelectionWithDifferentSampleBank() + { + AddStep("select both objects", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); + clickSamplePiece(0); + samplePopoverHasIndeterminateBank(); + + dismissPopover(); + + clickSamplePiece(1); + samplePopoverHasIndeterminateBank(); + + setBankViaPopover(string.Empty); + hitObjectHasSampleBank(0, "normal"); + hitObjectHasSampleBank(1, "soft"); + samplePopoverHasIndeterminateBank(); + + setBankViaPopover("normal"); + hitObjectHasSampleBank(0, "normal"); + hitObjectHasSampleBank(1, "normal"); + samplePopoverHasSingleBank("normal"); + } + + private void clickSamplePiece(int objectIndex) => AddStep($"click {objectIndex.ToOrdinalWords()} difficulty piece", () => + { + var difficultyPiece = this.ChildrenOfType().Single(piece => piece.HitObject == EditorBeatmap.HitObjects.ElementAt(objectIndex)); + + InputManager.MoveMouseTo(difficultyPiece); + InputManager.Click(MouseButton.Left); + }); + + private void samplePopoverHasSingleVolume(int volume) => AddUntilStep($"sample popover has volume {volume}", () => + { + var popover = this.ChildrenOfType().SingleOrDefault(); + var slider = popover?.ChildrenOfType>().Single(); + + return slider?.Current.Value == volume; + }); + + private void samplePopoverHasIndeterminateVolume() => AddUntilStep("sample popover has indeterminate volume", () => + { + var popover = this.ChildrenOfType().SingleOrDefault(); + var slider = popover?.ChildrenOfType>().Single(); + + return slider != null && slider.Current.Value == null; + }); + + private void samplePopoverHasSingleBank(string bank) => AddUntilStep($"sample popover has bank {bank}", () => + { + var popover = this.ChildrenOfType().SingleOrDefault(); + var textBox = popover?.ChildrenOfType().First(); + + return textBox?.Current.Value == bank && string.IsNullOrEmpty(textBox?.PlaceholderText.ToString()); + }); + + private void samplePopoverHasIndeterminateBank() => AddUntilStep("sample popover has indeterminate bank", () => + { + var popover = this.ChildrenOfType().SingleOrDefault(); + var textBox = popover?.ChildrenOfType().First(); + + return textBox != null && string.IsNullOrEmpty(textBox.Current.Value) && !string.IsNullOrEmpty(textBox.PlaceholderText.ToString()); + }); + + private void dismissPopover() + { + AddStep("dismiss popover", () => InputManager.Key(Key.Escape)); + AddUntilStep("wait for dismiss", () => !this.ChildrenOfType().Any(popover => popover.IsPresent)); + } + + private void setVolumeViaPopover(int volume) => AddStep($"set volume {volume} via popover", () => + { + var popover = this.ChildrenOfType().Single(); + var slider = popover.ChildrenOfType>().Single(); + slider.Current.Value = volume; + }); + + private void hitObjectHasSampleVolume(int objectIndex, int volume) => AddAssert($"{objectIndex.ToOrdinalWords()} has volume {volume}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex); + return h.SampleControlPoint.SampleVolume == volume; + }); + + private void setBankViaPopover(string bank) => AddStep($"set bank {bank} via popover", () => + { + var popover = this.ChildrenOfType().Single(); + var textBox = popover.ChildrenOfType().First(); + textBox.Current.Value = bank; + // force a commit via keyboard. + // this is needed when testing attempting to set empty bank - which should revert to the previous value, but only on commit. + InputManager.ChangeFocus(textBox); + InputManager.Key(Key.Enter); + }); + + private void hitObjectHasSampleBank(int objectIndex, string bank) => AddAssert($"{objectIndex.ToOrdinalWords()} has bank {bank}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex); + return h.SampleControlPoint.SampleBank == bank; + }); + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs index 36fc6812bd..7167d3120a 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs @@ -31,12 +31,18 @@ namespace osu.Game.Tests.Visual.Gameplay { AddUntilStep("wait for fail", () => Player.HasFailed); AddUntilStep("wait for fail overlay", () => ((FailPlayer)Player).FailOverlay.State.Value == Visibility.Visible); + + // The pause screen and fail animation both ramp frequency. + // This tests to ensure that it doesn't reset during that handoff. + AddAssert("frequency only ever decreased", () => !((FailPlayer)Player).FrequencyIncreased); } private class FailPlayer : TestPlayer { public new FailOverlay FailOverlay => base.FailOverlay; + public bool FrequencyIncreased { get; private set; } + public FailPlayer() : base(false, false) { @@ -47,6 +53,19 @@ namespace osu.Game.Tests.Visual.Gameplay base.LoadComplete(); HealthProcessor.FailConditions += (_, __) => true; } + + private double lastFrequency = double.MaxValue; + + protected override void Update() + { + base.Update(); + + double freq = Beatmap.Value.Track.AggregateFrequency.Value; + + FrequencyIncreased |= freq > lastFrequency; + + lastFrequency = freq; + } } } } diff --git a/osu.Game/Beatmaps/IWorkingBeatmap.cs b/osu.Game/Beatmaps/IWorkingBeatmap.cs index a916b37b85..ba887edf62 100644 --- a/osu.Game/Beatmaps/IWorkingBeatmap.cs +++ b/osu.Game/Beatmaps/IWorkingBeatmap.cs @@ -17,33 +17,69 @@ namespace osu.Game.Beatmaps { public interface IWorkingBeatmap { + IBeatmapInfo BeatmapInfo { get; } + + IBeatmapSetInfo BeatmapSetInfo { get; } + + IBeatmapMetadataInfo Metadata { get; } + /// - /// Retrieves the which this represents. + /// Whether the Beatmap has finished loading. + /// + public bool BeatmapLoaded { get; } + + /// + /// Whether the Background has finished loading. + /// + public bool BackgroundLoaded { get; } + + /// + /// Whether the Waveform has finished loading. + /// + public bool WaveformLoaded { get; } + + /// + /// Whether the Storyboard has finished loading. + /// + public bool StoryboardLoaded { get; } + + /// + /// Whether the Skin has finished loading. + /// + public bool SkinLoaded { get; } + + /// + /// Whether the Track has finished loading. + /// + public bool TrackLoaded { get; } + + /// + /// Retrieves the which this represents. /// IBeatmap Beatmap { get; } /// - /// Retrieves the background for this . + /// Retrieves the background for this . /// Texture Background { get; } /// - /// Retrieves the for the of this . + /// Retrieves the for the of this . /// Waveform Waveform { get; } /// - /// Retrieves the which this provides. + /// Retrieves the which this provides. /// Storyboard Storyboard { get; } /// - /// Retrieves the which this provides. + /// Retrieves the which this provides. /// ISkin Skin { get; } /// - /// Retrieves the which this has loaded. + /// Retrieves the which this has loaded. /// Track Track { get; } @@ -67,7 +103,7 @@ namespace osu.Game.Beatmaps /// /// /// In a standard game context, the loading of the track is managed solely by MusicController, which will - /// automatically load the track of the current global IBindable WorkingBeatmap. + /// automatically load the track of the current global IBindable IWorkingBeatmap. /// As such, this method should only be called in very special scenarios, such as external tests or apps which are /// outside of the game context. /// @@ -79,5 +115,20 @@ namespace osu.Game.Beatmaps /// /// The storage path to the file. Stream GetStream(string storagePath); + + /// + /// Beings loading the contents of this asynchronously. + /// + public void BeginAsyncLoad(); + + /// + /// Cancels the asynchronous loading of the contents of this . + /// + public void CancelAsyncLoad(); + + /// + /// Reads the correct track restart point from beatmap metadata and sets looping to enabled. + /// + void PrepareTrackForPreviewLooping(); } } diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index d2c0f7de0f..f68f34f673 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -27,9 +27,7 @@ namespace osu.Game.Beatmaps public abstract class WorkingBeatmap : IWorkingBeatmap { public readonly BeatmapInfo BeatmapInfo; - public readonly BeatmapSetInfo BeatmapSetInfo; - public readonly BeatmapMetadata Metadata; protected AudioManager AudioManager { get; } @@ -89,6 +87,9 @@ namespace osu.Game.Beatmaps var rulesetInstance = ruleset.CreateInstance(); + if (rulesetInstance == null) + throw new RulesetLoadException("Creating ruleset instance failed when attempting to create playable beatmap."); + IBeatmapConverter converter = CreateBeatmapConverter(Beatmap, rulesetInstance); // Check if the beatmap can be converted @@ -176,17 +177,8 @@ namespace osu.Game.Beatmaps private CancellationTokenSource loadCancellation = new CancellationTokenSource(); - /// - /// Beings loading the contents of this asynchronously. - /// - public void BeginAsyncLoad() - { - loadBeatmapAsync(); - } + public void BeginAsyncLoad() => loadBeatmapAsync(); - /// - /// Cancels the asynchronous loading of the contents of this . - /// public void CancelAsyncLoad() { lock (beatmapFetchLock) @@ -234,6 +226,10 @@ namespace osu.Game.Beatmaps public virtual bool BeatmapLoaded => beatmapLoadTask?.IsCompleted ?? false; + IBeatmapInfo IWorkingBeatmap.BeatmapInfo => BeatmapInfo; + IBeatmapMetadataInfo IWorkingBeatmap.Metadata => Metadata; + IBeatmapSetInfo IWorkingBeatmap.BeatmapSetInfo => BeatmapSetInfo; + public IBeatmap Beatmap { get @@ -273,9 +269,6 @@ namespace osu.Game.Beatmaps [NotNull] public Track LoadTrack() => loadedTrack = GetBeatmapTrack() ?? GetVirtualTrack(1000); - /// - /// Reads the correct track restart point from beatmap metadata and sets looping to enabled. - /// public void PrepareTrackForPreviewLooping() { Track.Looping = true; diff --git a/osu.Game/Database/ImportTask.cs b/osu.Game/Database/ImportTask.cs index 1433a567a9..1fb5a42630 100644 --- a/osu.Game/Database/ImportTask.cs +++ b/osu.Game/Database/ImportTask.cs @@ -47,10 +47,30 @@ namespace osu.Game.Database /// public ArchiveReader GetReader() { - if (Stream != null) - return new ZipArchiveReader(Stream, Path); + return Stream != null + ? getReaderFrom(Stream) + : getReaderFrom(Path); + } - return getReaderFrom(Path); + /// + /// Creates an from a stream. + /// + /// A seekable stream containing the archive content. + /// A reader giving access to the archive's content. + private ArchiveReader getReaderFrom(Stream stream) + { + if (!(stream is MemoryStream memoryStream)) + { + // This isn't used in any current path. May need to reconsider for performance reasons (ie. if we don't expect the incoming stream to be copied out). + byte[] buffer = new byte[stream.Length]; + stream.Read(buffer, 0, (int)stream.Length); + memoryStream = new MemoryStream(buffer); + } + + if (ZipUtils.IsZipArchive(memoryStream)) + return new ZipArchiveReader(memoryStream, Path); + + return new LegacyByteArrayReader(memoryStream.ToArray(), Path); } /// diff --git a/osu.Game/Graphics/OsuColour.cs b/osu.Game/Graphics/OsuColour.cs index 3aa4dbf1d8..d3afb21933 100644 --- a/osu.Game/Graphics/OsuColour.cs +++ b/osu.Game/Graphics/OsuColour.cs @@ -52,15 +52,18 @@ namespace osu.Game.Graphics public Color4 ForStarDifficulty(double starDifficulty) => ColourUtils.SampleFromLinearGradient(new[] { - (1.5f, Color4Extensions.FromHex("4fc0ff")), + (0.1f, Color4Extensions.FromHex("aaaaaa")), + (0.1f, Color4Extensions.FromHex("4290fb")), + (1.25f, Color4Extensions.FromHex("4fc0ff")), (2.0f, Color4Extensions.FromHex("4fffd5")), (2.5f, Color4Extensions.FromHex("7cff4f")), - (3.25f, Color4Extensions.FromHex("f6f05c")), - (4.5f, Color4Extensions.FromHex("ff8068")), - (6.0f, Color4Extensions.FromHex("ff3c71")), - (7.0f, Color4Extensions.FromHex("6563de")), - (8.0f, Color4Extensions.FromHex("18158e")), - (8.0f, Color4.Black), + (3.3f, Color4Extensions.FromHex("f6f05c")), + (4.2f, Color4Extensions.FromHex("ff8068")), + (4.9f, Color4Extensions.FromHex("ff4e6f")), + (5.8f, Color4Extensions.FromHex("c645b8")), + (6.7f, Color4Extensions.FromHex("6563de")), + (7.7f, Color4Extensions.FromHex("18158e")), + (9.0f, Color4.Black), }, (float)Math.Round(starDifficulty, 2, MidpointRounding.AwayFromZero)); /// diff --git a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs index d4310dc901..333ae4f832 100644 --- a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs @@ -19,6 +19,7 @@ using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Overlays; +using osu.Game.Utils; namespace osu.Game.Graphics.UserInterface { @@ -219,7 +220,7 @@ namespace osu.Game.Graphics.UserInterface decimal decimalPrecision = normalise(CurrentNumber.Precision.ToDecimal(NumberFormatInfo.InvariantInfo), max_decimal_digits); // Find the number of significant digits (we could have less than 5 after normalize()) - int significantDigits = findPrecision(decimalPrecision); + int significantDigits = FormatUtils.FindPrecision(decimalPrecision); TooltipText = floatValue.ToString($"N{significantDigits}"); } @@ -248,23 +249,5 @@ namespace osu.Game.Graphics.UserInterface /// The normalised decimal. private decimal normalise(decimal d, int sd) => decimal.Parse(Math.Round(d, sd).ToString(string.Concat("0.", new string('#', sd)), CultureInfo.InvariantCulture), CultureInfo.InvariantCulture); - - /// - /// Finds the number of digits after the decimal. - /// - /// The value to find the number of decimal digits for. - /// The number decimal digits. - private int findPrecision(decimal d) - { - int precision = 0; - - while (d != Math.Round(d)) - { - d *= 10; - precision++; - } - - return precision; - } } } diff --git a/osu.Game/IO/Archives/LegacyByteArrayReader.cs b/osu.Game/IO/Archives/LegacyByteArrayReader.cs index 0c3620403f..ea8ff3bbe0 100644 --- a/osu.Game/IO/Archives/LegacyByteArrayReader.cs +++ b/osu.Game/IO/Archives/LegacyByteArrayReader.cs @@ -7,7 +7,7 @@ using System.IO; namespace osu.Game.IO.Archives { /// - /// Allows reading a single file from the provided stream. + /// Allows reading a single file from the provided byte array. /// public class LegacyByteArrayReader : ArchiveReader { diff --git a/osu.Game/Overlays/Settings/SettingsHeader.cs b/osu.Game/Overlays/Settings/SettingsHeader.cs index 69b7b69a29..f9ee8df0bd 100644 --- a/osu.Game/Overlays/Settings/SettingsHeader.cs +++ b/osu.Game/Overlays/Settings/SettingsHeader.cs @@ -6,7 +6,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.Containers; namespace osu.Game.Overlays.Settings { @@ -29,36 +29,26 @@ namespace osu.Game.Overlays.Settings Children = new Drawable[] { - new FillFlowContainer + new OsuTextFlowContainer { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, - Direction = FillDirection.Vertical, - Children = new Drawable[] + Padding = new MarginPadding { - new OsuSpriteText - { - Text = heading, - Font = OsuFont.TorusAlternate.With(size: 40), - Margin = new MarginPadding - { - Left = SettingsPanel.CONTENT_MARGINS, - Top = Toolbar.Toolbar.TOOLTIP_HEIGHT - }, - }, - new OsuSpriteText - { - Colour = colourProvider.Content2, - Text = subheading, - Font = OsuFont.GetFont(size: 18), - Margin = new MarginPadding - { - Left = SettingsPanel.CONTENT_MARGINS, - Bottom = 30 - }, - }, + Horizontal = SettingsPanel.CONTENT_MARGINS, + Top = Toolbar.Toolbar.TOOLTIP_HEIGHT, + Bottom = 30 } - } + }.With(flow => + { + flow.AddText(heading, header => header.Font = OsuFont.TorusAlternate.With(size: 40)); + flow.NewLine(); + flow.AddText(subheading, subheader => + { + subheader.Colour = colourProvider.Content2; + subheader.Font = OsuFont.GetFont(size: 18); + }); + }) }; } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs index 21457ea273..b230bab0c2 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs @@ -1,9 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -14,19 +16,20 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Rulesets.Objects; using osu.Game.Screens.Edit.Timing; +using osuTK; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { public class DifficultyPointPiece : HitObjectPointPiece, IHasPopover { - private readonly HitObject hitObject; + public readonly HitObject HitObject; private readonly BindableNumber speedMultiplier; public DifficultyPointPiece(HitObject hitObject) : base(hitObject.DifficultyControlPoint) { - this.hitObject = hitObject; + HitObject = hitObject; speedMultiplier = hitObject.DifficultyControlPoint.SliderVelocityBindable.GetBoundCopy(); } @@ -44,14 +47,13 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline return true; } - public Popover GetPopover() => new DifficultyEditPopover(hitObject); + public Popover GetPopover() => new DifficultyEditPopover(HitObject); public class DifficultyEditPopover : OsuPopover { private readonly HitObject hitObject; - private readonly DifficultyControlPoint point; - private SliderWithTextBoxInput sliderVelocitySlider; + private IndeterminateSliderWithTextBoxInput sliderVelocitySlider; [Resolved(canBeNull: true)] private EditorBeatmap beatmap { get; set; } @@ -59,7 +61,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline public DifficultyEditPopover(HitObject hitObject) { this.hitObject = hitObject; - point = hitObject.DifficultyControlPoint; } [BackgroundDependencyLoader] @@ -72,11 +73,11 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline Width = 200, Direction = FillDirection.Vertical, AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0, 15), Children = new Drawable[] { - sliderVelocitySlider = new SliderWithTextBoxInput("Velocity") + sliderVelocitySlider = new IndeterminateSliderWithTextBoxInput("Velocity", new DifficultyControlPoint().SliderVelocityBindable) { - Current = new DifficultyControlPoint().SliderVelocityBindable, KeyboardStep = 0.1f }, new OsuTextFlowContainer @@ -89,17 +90,37 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline } }; - var selectedPointBindable = point.SliderVelocityBindable; + // if the piece belongs to a currently selected object, assume that the user wants to change all selected objects. + // if the piece belongs to an unselected object, operate on that object alone, independently of the selection. + var relevantObjects = (beatmap.SelectedHitObjects.Contains(hitObject) ? beatmap.SelectedHitObjects : hitObject.Yield()).ToArray(); + var relevantControlPoints = relevantObjects.Select(h => h.DifficultyControlPoint).ToArray(); - // there may be legacy control points, which contain infinite precision for compatibility reasons (see LegacyDifficultyControlPoint). - // generally that level of precision could only be set by externally editing the .osu file, so at the point - // a user is looking to update this within the editor it should be safe to obliterate this additional precision. - double expectedPrecision = new DifficultyControlPoint().SliderVelocityBindable.Precision; - if (selectedPointBindable.Precision < expectedPrecision) - selectedPointBindable.Precision = expectedPrecision; + // even if there are multiple objects selected, we can still display a value if they all have the same value. + var selectedPointBindable = relevantControlPoints.Select(point => point.SliderVelocity).Distinct().Count() == 1 ? relevantControlPoints.First().SliderVelocityBindable : null; - sliderVelocitySlider.Current = selectedPointBindable; - sliderVelocitySlider.Current.BindValueChanged(_ => beatmap?.Update(hitObject)); + if (selectedPointBindable != null) + { + // there may be legacy control points, which contain infinite precision for compatibility reasons (see LegacyDifficultyControlPoint). + // generally that level of precision could only be set by externally editing the .osu file, so at the point + // a user is looking to update this within the editor it should be safe to obliterate this additional precision. + sliderVelocitySlider.Current.Value = selectedPointBindable.Value; + } + + sliderVelocitySlider.Current.BindValueChanged(val => + { + if (val.NewValue == null) + return; + + beatmap.BeginChange(); + + foreach (var h in relevantObjects) + { + h.DifficultyControlPoint.SliderVelocity = val.NewValue.Value; + beatmap.Update(h); + } + + beatmap.EndChange(); + }); } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs index 6a26f69e41..2cbfe88519 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs @@ -1,9 +1,14 @@ // 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 System.Collections.Generic; +using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -14,12 +19,13 @@ using osu.Game.Graphics; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Rulesets.Objects; using osu.Game.Screens.Edit.Timing; +using osuTK; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { public class SamplePointPiece : HitObjectPointPiece, IHasPopover { - private readonly HitObject hitObject; + public readonly HitObject HitObject; private readonly Bindable bank; private readonly BindableNumber volume; @@ -27,7 +33,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline public SamplePointPiece(HitObject hitObject) : base(hitObject.SampleControlPoint) { - this.hitObject = hitObject; + HitObject = hitObject; volume = hitObject.SampleControlPoint.SampleVolumeBindable.GetBoundCopy(); bank = hitObject.SampleControlPoint.SampleBankBindable.GetBoundCopy(); } @@ -50,23 +56,21 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline Label.Text = $"{bank.Value} {volume.Value}"; } - public Popover GetPopover() => new SampleEditPopover(hitObject); + public Popover GetPopover() => new SampleEditPopover(HitObject); public class SampleEditPopover : OsuPopover { private readonly HitObject hitObject; - private readonly SampleControlPoint point; - private LabelledTextBox bank; - private SliderWithTextBoxInput volume; + private LabelledTextBox bank = null!; + private IndeterminateSliderWithTextBoxInput volume = null!; [Resolved(canBeNull: true)] - private EditorBeatmap beatmap { get; set; } + private EditorBeatmap beatmap { get; set; } = null!; public SampleEditPopover(HitObject hitObject) { this.hitObject = hitObject; - point = hitObject.SampleControlPoint; } [BackgroundDependencyLoader] @@ -79,25 +83,84 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline Width = 200, Direction = FillDirection.Vertical, AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0, 10), Children = new Drawable[] { bank = new LabelledTextBox { Label = "Bank Name", }, - volume = new SliderWithTextBoxInput("Volume") - { - Current = new SampleControlPoint().SampleVolumeBindable, - } + volume = new IndeterminateSliderWithTextBoxInput("Volume", new SampleControlPoint().SampleVolumeBindable) } } }; - bank.Current = point.SampleBankBindable; - bank.Current.BindValueChanged(_ => beatmap.Update(hitObject)); + // if the piece belongs to a currently selected object, assume that the user wants to change all selected objects. + // if the piece belongs to an unselected object, operate on that object alone, independently of the selection. + var relevantObjects = (beatmap.SelectedHitObjects.Contains(hitObject) ? beatmap.SelectedHitObjects : hitObject.Yield()).ToArray(); + var relevantControlPoints = relevantObjects.Select(h => h.SampleControlPoint).ToArray(); - volume.Current = point.SampleVolumeBindable; - volume.Current.BindValueChanged(_ => beatmap.Update(hitObject)); + // even if there are multiple objects selected, we can still display sample volume or bank if they all have the same value. + string? commonBank = getCommonBank(relevantControlPoints); + if (!string.IsNullOrEmpty(commonBank)) + bank.Current.Value = commonBank; + + int? commonVolume = getCommonVolume(relevantControlPoints); + if (commonVolume != null) + volume.Current.Value = commonVolume.Value; + + updateBankPlaceholderText(relevantObjects); + bank.Current.BindValueChanged(val => + { + updateBankFor(relevantObjects, val.NewValue); + updateBankPlaceholderText(relevantObjects); + }); + // on commit, ensure that the value is correct by sourcing it from the objects' control points again. + // this ensures that committing empty text causes a revert to the previous value. + bank.OnCommit += (_, __) => bank.Current.Value = getCommonBank(relevantControlPoints); + + volume.Current.BindValueChanged(val => updateVolumeFor(relevantObjects, val.NewValue)); + } + + private static string? getCommonBank(SampleControlPoint[] relevantControlPoints) => relevantControlPoints.Select(point => point.SampleBank).Distinct().Count() == 1 ? relevantControlPoints.First().SampleBank : null; + private static int? getCommonVolume(SampleControlPoint[] relevantControlPoints) => relevantControlPoints.Select(point => point.SampleVolume).Distinct().Count() == 1 ? (int?)relevantControlPoints.First().SampleVolume : null; + + private void updateBankFor(IEnumerable objects, string? newBank) + { + if (string.IsNullOrEmpty(newBank)) + return; + + beatmap.BeginChange(); + + foreach (var h in objects) + { + h.SampleControlPoint.SampleBank = newBank; + beatmap.Update(h); + } + + beatmap.EndChange(); + } + + private void updateBankPlaceholderText(IEnumerable objects) + { + string? commonBank = getCommonBank(objects.Select(h => h.SampleControlPoint).ToArray()); + bank.PlaceholderText = string.IsNullOrEmpty(commonBank) ? "(multiple)" : null; + } + + private void updateVolumeFor(IEnumerable objects, int? newVolume) + { + if (newVolume == null) + return; + + beatmap.BeginChange(); + + foreach (var h in objects) + { + h.SampleControlPoint.SampleVolume = newVolume.Value; + beatmap.Update(h); + } + + beatmap.EndChange(); } } } diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 738f607cc8..2a7e2c9cef 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -31,6 +31,7 @@ using osu.Game.Screens.Edit.Components.Menus; using osu.Game.Screens.Edit.Components.Timelines.Summary; using osu.Game.Screens.Edit.Compose; using osu.Game.Screens.Edit.Design; +using osu.Game.Screens.Edit.GameplayTest; using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Edit.Timing; using osu.Game.Screens.Edit.Verify; @@ -324,6 +325,19 @@ namespace osu.Game.Screens.Edit /// public void UpdateClockSource() => clock.ChangeSource(Beatmap.Value.Track); + /// + /// Creates an instance representing the current state of the editor. + /// + /// + /// The next beatmap to be shown, in the case of difficulty switch. + /// indicates that the beatmap will not be changing. + /// + public EditorState GetState([CanBeNull] BeatmapInfo nextBeatmap = null) => new EditorState + { + Time = clock.CurrentTimeAccurate, + ClipboardContent = nextBeatmap == null || editorBeatmap.BeatmapInfo.RulesetID == nextBeatmap.RulesetID ? Clipboard.Content.Value : string.Empty + }; + /// /// Restore the editor to a provided state. /// @@ -486,7 +500,18 @@ namespace osu.Game.Screens.Edit public override void OnEntering(IScreen last) { base.OnEntering(last); + dimBackground(); + resetTrack(true); + } + public override void OnResuming(IScreen last) + { + base.OnResuming(last); + dimBackground(); + } + + private void dimBackground() + { ApplyToBackground(b => { // todo: temporary. we want to be applying dim using the UserDimContainer eventually. @@ -495,8 +520,6 @@ namespace osu.Game.Screens.Edit b.IgnoreUserSettings.Value = true; b.BlurAmount.Value = 0; }); - - resetTrack(true); } public override bool OnExiting(IScreen next) @@ -535,9 +558,9 @@ namespace osu.Game.Screens.Edit public override void OnSuspending(IScreen next) { - refetchBeatmap(); - base.OnSuspending(next); + clock.Stop(); + refetchBeatmap(); } private void refetchBeatmap() @@ -770,11 +793,7 @@ namespace osu.Game.Screens.Edit return new DifficultyMenuItem(beatmapInfo, isCurrentDifficulty, SwitchToDifficulty); } - protected void SwitchToDifficulty(BeatmapInfo nextBeatmap) => loader?.ScheduleDifficultySwitch(nextBeatmap, new EditorState - { - Time = clock.CurrentTimeAccurate, - ClipboardContent = editorBeatmap.BeatmapInfo.RulesetID == nextBeatmap.RulesetID ? Clipboard.Content.Value : string.Empty - }); + protected void SwitchToDifficulty(BeatmapInfo nextBeatmap) => loader?.ScheduleDifficultySwitch(nextBeatmap, GetState(nextBeatmap)); private void cancelExit() { @@ -797,7 +816,7 @@ namespace osu.Game.Screens.Edit pushEditorPlayer(); } - void pushEditorPlayer() => this.Push(new PlayerLoader(() => new EditorPlayer())); + void pushEditorPlayer() => this.Push(new EditorPlayerLoader(this)); } public double SnapTime(double time, double? referenceTime) => editorBeatmap.SnapTime(time, referenceTime); diff --git a/osu.Game/Screens/Edit/EditorPlayer.cs b/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs similarity index 52% rename from osu.Game/Screens/Edit/EditorPlayer.cs rename to osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs index b2fab3fefc..f49603c754 100644 --- a/osu.Game/Screens/Edit/EditorPlayer.cs +++ b/osu.Game/Screens/Edit/GameplayTest/EditorPlayer.cs @@ -3,21 +3,30 @@ using osu.Framework.Allocation; using osu.Framework.Screens; +using osu.Game.Beatmaps; using osu.Game.Overlays; using osu.Game.Screens.Play; -namespace osu.Game.Screens.Edit +namespace osu.Game.Screens.Edit.GameplayTest { public class EditorPlayer : Player { - public EditorPlayer() - : base(new PlayerConfiguration { ShowResults = false }) - { - } + private readonly Editor editor; + private readonly EditorState editorState; [Resolved] private MusicController musicController { get; set; } + public EditorPlayer(Editor editor) + : base(new PlayerConfiguration { ShowResults = false }) + { + this.editor = editor; + editorState = editor.GetState(); + } + + protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) + => new MasterGameplayClockContainer(beatmap, editorState.Time, true); + protected override void LoadComplete() { base.LoadComplete(); @@ -35,9 +44,22 @@ namespace osu.Game.Screens.Edit protected override bool CheckModsAllowFailure() => false; // never fail. + public override void OnEntering(IScreen last) + { + base.OnEntering(last); + + // finish alpha transforms on entering to avoid gameplay starting in a half-hidden state. + // the finish calls are purposefully not propagated to children to avoid messing up their state. + FinishTransforms(); + GameplayClockContainer.FinishTransforms(false, nameof(Alpha)); + } + public override bool OnExiting(IScreen next) { musicController.Stop(); + + editorState.Time = GameplayClockContainer.CurrentTime; + editor.RestoreState(editorState); return base.OnExiting(next); } } diff --git a/osu.Game/Screens/Edit/GameplayTest/EditorPlayerLoader.cs b/osu.Game/Screens/Edit/GameplayTest/EditorPlayerLoader.cs new file mode 100644 index 0000000000..addc79ba61 --- /dev/null +++ b/osu.Game/Screens/Edit/GameplayTest/EditorPlayerLoader.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Screens; +using osu.Game.Screens.Menu; +using osu.Game.Screens.Play; + +namespace osu.Game.Screens.Edit.GameplayTest +{ + public class EditorPlayerLoader : PlayerLoader + { + [Resolved] + private OsuLogo osuLogo { get; set; } + + public EditorPlayerLoader(Editor editor) + : base(() => new EditorPlayer(editor)) + { + } + + public override void OnEntering(IScreen last) + { + base.OnEntering(last); + + MetadataInfo.FinishTransforms(true); + } + + protected override void LogoArriving(OsuLogo logo, bool resuming) + { + // call base with resuming forcefully set to true to reduce logo movements. + base.LogoArriving(logo, true); + logo.FinishTransforms(true, nameof(Scale)); + } + + protected override void ContentOut() + { + base.ContentOut(); + osuLogo.FadeOut(CONTENT_OUT_DURATION, Easing.OutQuint); + } + + protected override double PlayerPushDelay => 0; + } +} diff --git a/osu.Game/Screens/Edit/SaveBeforeGameplayTestDialog.cs b/osu.Game/Screens/Edit/GameplayTest/SaveBeforeGameplayTestDialog.cs similarity index 95% rename from osu.Game/Screens/Edit/SaveBeforeGameplayTestDialog.cs rename to osu.Game/Screens/Edit/GameplayTest/SaveBeforeGameplayTestDialog.cs index 7c03664c66..9334c74706 100644 --- a/osu.Game/Screens/Edit/SaveBeforeGameplayTestDialog.cs +++ b/osu.Game/Screens/Edit/GameplayTest/SaveBeforeGameplayTestDialog.cs @@ -5,7 +5,7 @@ using System; using osu.Framework.Graphics.Sprites; using osu.Game.Overlays.Dialog; -namespace osu.Game.Screens.Edit +namespace osu.Game.Screens.Edit.GameplayTest { public class SaveBeforeGameplayTestDialog : PopupDialog { diff --git a/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs b/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs new file mode 100644 index 0000000000..14b8c4c9de --- /dev/null +++ b/osu.Game/Screens/Edit/Timing/IndeterminateSliderWithTextBoxInput.cs @@ -0,0 +1,123 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Globalization; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays.Settings; +using osu.Game.Utils; +using osuTK; + +namespace osu.Game.Screens.Edit.Timing +{ + /// + /// Analogous to , but supports scenarios + /// where multiple objects with multiple different property values are selected + /// by providing an "indeterminate state". + /// + public class IndeterminateSliderWithTextBoxInput : CompositeDrawable, IHasCurrentValue + where T : struct, IEquatable, IComparable, IConvertible + { + /// + /// A custom step value for each key press which actuates a change on this control. + /// + public float KeyboardStep + { + get => slider.KeyboardStep; + set => slider.KeyboardStep = value; + } + + private readonly BindableWithCurrent current = new BindableWithCurrent(); + + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly SettingsSlider slider; + private readonly LabelledTextBox textbox; + + /// + /// Creates an . + /// + /// The label text for the slider and text box. + /// + /// Bindable to use for the slider until a non-null value is set for . + /// In particular, it can be used to control min/max bounds and precision in the case of s. + /// + public IndeterminateSliderWithTextBoxInput(LocalisableString labelText, Bindable indeterminateValue) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChildren = new Drawable[] + { + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new Drawable[] + { + textbox = new LabelledTextBox + { + Label = labelText, + }, + slider = new SettingsSlider + { + TransferValueOnCommit = true, + RelativeSizeAxes = Axes.X, + Current = indeterminateValue + } + } + }, + }; + + textbox.OnCommit += (t, isNew) => + { + if (!isNew) return; + + try + { + slider.Current.Parse(t.Text); + } + catch + { + // TriggerChange below will restore the previous text value on failure. + } + + // This is run regardless of parsing success as the parsed number may not actually trigger a change + // due to bindable clamping. Even in such a case we want to update the textbox to a sane visual state. + Current.TriggerChange(); + }; + slider.Current.BindValueChanged(val => Current.Value = val.NewValue); + + Current.BindValueChanged(_ => updateState(), true); + } + + private void updateState() + { + if (Current.Value is T nonNullValue) + { + slider.Current.Value = nonNullValue; + + // use the value from the slider to ensure that any precision/min/max set on it via the initial indeterminate value have been applied correctly. + decimal decimalValue = slider.Current.Value.ToDecimal(NumberFormatInfo.InvariantInfo); + textbox.Text = decimalValue.ToString($@"N{FormatUtils.FindPrecision(decimalValue)}"); + textbox.PlaceholderText = string.Empty; + } + else + { + textbox.Text = null; + textbox.PlaceholderText = "(multiple)"; + } + } + } +} diff --git a/osu.Game/Screens/Play/FailAnimation.cs b/osu.Game/Screens/Play/FailAnimation.cs index f3676baf80..193e1e4129 100644 --- a/osu.Game/Screens/Play/FailAnimation.cs +++ b/osu.Game/Screens/Play/FailAnimation.cs @@ -107,7 +107,8 @@ namespace osu.Game.Screens.Play this.TransformBindableTo(trackFreq, 0, duration).OnComplete(_ => { - RemoveFilters(); + // Don't reset frequency as the pause screen may appear post transform, causing a second frequency sweep. + RemoveFilters(false); OnComplete?.Invoke(); }); @@ -137,15 +138,16 @@ namespace osu.Game.Screens.Play Content.FadeColour(Color4.Gray, duration); } - public void RemoveFilters() + public void RemoveFilters(bool resetTrackFrequency = true) { + if (resetTrackFrequency) + track?.RemoveAdjustment(AdjustableProperty.Frequency, trackFreq); + if (filters.Parent == null) return; RemoveInternal(filters); filters.Dispose(); - - track?.RemoveAdjustment(AdjustableProperty.Frequency, trackFreq); } protected override void Update() diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index d852ac2940..57db411571 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -5,6 +5,7 @@ using System; using System.Diagnostics; using System.Threading.Tasks; using JetBrains.Annotations; +using ManagedBass.Fx; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; @@ -35,7 +36,9 @@ namespace osu.Game.Screens.Play { protected const float BACKGROUND_BLUR = 15; - private const double content_out_duration = 300; + protected const double CONTENT_OUT_DURATION = 300; + + protected virtual double PlayerPushDelay => 1800; public override bool HideOverlaysOnEnter => hideOverlays; @@ -67,6 +70,7 @@ namespace osu.Game.Screens.Play private readonly BindableDouble volumeAdjustment = new BindableDouble(1); private AudioFilter lowPassFilter; + private AudioFilter highPassFilter; protected bool BackgroundBrightnessReduction { @@ -168,7 +172,8 @@ namespace osu.Game.Screens.Play }, idleTracker = new IdleTracker(750), }), - lowPassFilter = new AudioFilter(audio.TrackMixer) + lowPassFilter = new AudioFilter(audio.TrackMixer), + highPassFilter = new AudioFilter(audio.TrackMixer, BQFType.HighPass) }; if (Beatmap.Value.BeatmapInfo.EpilepsyWarning) @@ -210,7 +215,7 @@ namespace osu.Game.Screens.Play // after an initial delay, start the debounced load check. // this will continue to execute even after resuming back on restart. - Scheduler.Add(new ScheduledDelegate(pushWhenLoaded, Clock.CurrentTime + 1800, 0)); + Scheduler.Add(new ScheduledDelegate(pushWhenLoaded, Clock.CurrentTime + PlayerPushDelay, 0)); showMuteWarningIfNeeded(); showBatteryWarningIfNeeded(); @@ -239,18 +244,19 @@ namespace osu.Game.Screens.Play Beatmap.Value.Track.Stop(); Beatmap.Value.Track.RemoveAdjustment(AdjustableProperty.Volume, volumeAdjustment); lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF); + highPassFilter.CutoffTo(0); } public override bool OnExiting(IScreen next) { cancelLoad(); - contentOut(); + ContentOut(); // If the load sequence was interrupted, the epilepsy warning may already be displayed (or in the process of being displayed). epilepsyWarning?.Hide(); // Ensure the screen doesn't expire until all the outwards fade operations have completed. - this.Delay(content_out_duration).FadeOut(); + this.Delay(CONTENT_OUT_DURATION).FadeOut(); ApplyToBackground(b => b.IgnoreUserSettings.Value = true); @@ -266,9 +272,9 @@ namespace osu.Game.Screens.Play const double duration = 300; - if (!resuming) logo.MoveTo(new Vector2(0.5f), duration, Easing.In); + if (!resuming) logo.MoveTo(new Vector2(0.5f), duration, Easing.OutQuint); - logo.ScaleTo(new Vector2(0.15f), duration, Easing.In); + logo.ScaleTo(new Vector2(0.15f), duration, Easing.OutQuint); logo.FadeIn(350); Scheduler.AddDelayed(() => @@ -352,18 +358,20 @@ namespace osu.Game.Screens.Play content.FadeInFromZero(400); content.ScaleTo(1, 650, Easing.OutQuint).Then().Schedule(prepareNewPlayer); lowPassFilter.CutoffTo(1000, 650, Easing.OutQuint); + highPassFilter.CutoffTo(300).Then().CutoffTo(0, 1250); // 1250 is to line up with the appearance of MetadataInfo (750 delay + 500 fade-in) ApplyToBackground(b => b?.FadeColour(Color4.White, 800, Easing.OutQuint)); } - private void contentOut() + protected virtual void ContentOut() { // Ensure the logo is no longer tracking before we scale the content content.StopTracking(); - content.ScaleTo(0.7f, content_out_duration * 2, Easing.OutQuint); - content.FadeOut(content_out_duration, Easing.OutQuint); - lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, content_out_duration); + content.ScaleTo(0.7f, CONTENT_OUT_DURATION * 2, Easing.OutQuint); + content.FadeOut(CONTENT_OUT_DURATION, Easing.OutQuint); + lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, CONTENT_OUT_DURATION); + highPassFilter.CutoffTo(0, CONTENT_OUT_DURATION); } private void pushWhenLoaded() @@ -388,9 +396,9 @@ namespace osu.Game.Screens.Play // ensure that once we have reached this "point of no return", readyForPush will be false for all future checks (until a new player instance is prepared). var consumedPlayer = consumePlayer(); - contentOut(); + ContentOut(); - TransformSequence pushSequence = this.Delay(content_out_duration); + TransformSequence pushSequence = this.Delay(CONTENT_OUT_DURATION); // only show if the warning was created (i.e. the beatmap needs it) // and this is not a restart of the map (the warning expires after first load). @@ -412,7 +420,7 @@ namespace osu.Game.Screens.Play else { // This goes hand-in-hand with the restoration of low pass filter in contentOut(). - this.TransformBindableTo(volumeAdjustment, 0, content_out_duration, Easing.OutCubic); + this.TransformBindableTo(volumeAdjustment, 0, CONTENT_OUT_DURATION, Easing.OutCubic); } pushSequence.Schedule(() => diff --git a/osu.Game/Screens/Play/SkipOverlay.cs b/osu.Game/Screens/Play/SkipOverlay.cs index b04fcba0c6..c35548c6b4 100644 --- a/osu.Game/Screens/Play/SkipOverlay.cs +++ b/osu.Game/Screens/Play/SkipOverlay.cs @@ -270,7 +270,7 @@ namespace osu.Game.Screens.Play colourNormal = colours.Yellow; colourHover = colours.YellowDark; - sampleConfirm = audio.Samples.Get(@"SongSelect/confirm-selection"); + sampleConfirm = audio.Samples.Get(@"UI/submit-select"); Children = new Drawable[] { diff --git a/osu.Game/Screens/Select/Carousel/CarouselHeader.cs b/osu.Game/Screens/Select/Carousel/CarouselHeader.cs index 40ca3e0764..ed3aea3445 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselHeader.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselHeader.cs @@ -113,7 +113,7 @@ namespace osu.Game.Screens.Select.Carousel RelativeSizeAxes = Axes.Both, }; - sampleHover = audio.Samples.Get("SongSelect/song-ping"); + sampleHover = audio.Samples.Get("UI/default-hover"); } public bool InsetForBorder diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index a2dea355ac..4da15ee53c 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -105,6 +105,8 @@ namespace osu.Game.Screens.Select private readonly Bindable decoupledRuleset = new Bindable(); + private double audioFeedbackLastPlaybackTime; + [Resolved] private MusicController music { get; set; } @@ -435,6 +437,7 @@ namespace osu.Game.Screens.Select } // We need to keep track of the last selected beatmap ignoring debounce to play the correct selection sounds. + private BeatmapInfo beatmapInfoPrevious; private BeatmapInfo beatmapInfoNoDebounce; private RulesetInfo rulesetNoDebounce; @@ -477,6 +480,21 @@ namespace osu.Game.Screens.Select else selectionChangedDebounce = Scheduler.AddDelayed(run, 200); + if (beatmap != beatmapInfoPrevious) + { + if (beatmap != null && beatmapInfoPrevious != null && Time.Current - audioFeedbackLastPlaybackTime >= 50) + { + if (beatmap.BeatmapSetInfoID == beatmapInfoPrevious.BeatmapSetInfoID) + sampleChangeDifficulty.Play(); + else + sampleChangeBeatmap.Play(); + + audioFeedbackLastPlaybackTime = Time.Current; + } + + beatmapInfoPrevious = beatmap; + } + void run() { // clear pending task immediately to track any potential nested debounce operation. @@ -508,18 +526,7 @@ namespace osu.Game.Screens.Select if (!EqualityComparer.Default.Equals(beatmap, Beatmap.Value.BeatmapInfo)) { Logger.Log($"beatmap changed from \"{Beatmap.Value.BeatmapInfo}\" to \"{beatmap}\""); - - int? lastSetID = Beatmap.Value?.BeatmapInfo.BeatmapSetInfoID; - Beatmap.Value = beatmaps.GetWorkingBeatmap(beatmap); - - if (beatmap != null) - { - if (beatmap.BeatmapSetInfoID == lastSetID) - sampleChangeDifficulty.Play(); - else - sampleChangeBeatmap.Play(); - } } if (this.IsCurrentScreen()) diff --git a/osu.Game/Utils/FormatUtils.cs b/osu.Game/Utils/FormatUtils.cs index d14dbb49f3..799dc75ca9 100644 --- a/osu.Game/Utils/FormatUtils.cs +++ b/osu.Game/Utils/FormatUtils.cs @@ -31,5 +31,23 @@ namespace osu.Game.Utils /// /// The rank/position to be formatted. public static string FormatRank(this int rank) => rank.ToMetric(decimals: rank < 100_000 ? 1 : 0); + + /// + /// Finds the number of digits after the decimal. + /// + /// The value to find the number of decimal digits for. + /// The number decimal digits. + public static int FindPrecision(decimal d) + { + int precision = 0; + + while (d != Math.Round(d)) + { + d *= 10; + precision++; + } + + return precision; + } } } diff --git a/osu.Game/Utils/ZipUtils.cs b/osu.Game/Utils/ZipUtils.cs index cd4d876451..eb2d2d3b80 100644 --- a/osu.Game/Utils/ZipUtils.cs +++ b/osu.Game/Utils/ZipUtils.cs @@ -9,6 +9,34 @@ namespace osu.Game.Utils { public static class ZipUtils { + public static bool IsZipArchive(MemoryStream stream) + { + try + { + stream.Seek(0, SeekOrigin.Begin); + + using (var arc = ZipArchive.Open(stream)) + { + foreach (var entry in arc.Entries) + { + using (entry.OpenEntryStream()) + { + } + } + } + + return true; + } + catch (Exception) + { + return false; + } + finally + { + stream.Seek(0, SeekOrigin.Begin); + } + } + public static bool IsZipArchive(string path) { if (!File.Exists(path)) diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index df3c9b355a..93fb729f46 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -37,7 +37,7 @@ - + diff --git a/osu.iOS.props b/osu.iOS.props index 1852957e87..3a2a3fd65e 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -71,7 +71,7 @@ - +