diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 985fc09df3..4177c402aa 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -27,7 +27,7 @@ ] }, "ppy.localisationanalyser.tools": { - "version": "2021.1210.0", + "version": "2022.320.0", "commands": [ "localisation" ] diff --git a/.github/ISSUE_TEMPLATE/bug-issue.yml b/.github/ISSUE_TEMPLATE/bug-issue.yml new file mode 100644 index 0000000000..5b19c3732c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-issue.yml @@ -0,0 +1,72 @@ +name: Bug report +description: Report a very clearly broken issue. +body: + - type: markdown + attributes: + value: | + # osu! bug report + + Important to note that your issue may have already been reported before. Please check: + - Pinned issues, at the top of https://github.com/ppy/osu/issues. + - Current open `priority:0` issues, filterable [here](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3Apriority%3A0). + - And most importantly, search for your issue. If you find that it already exists, respond with a reaction or add any further information that may be helpful. + + - type: dropdown + attributes: + label: Type + options: + - Crash to desktop + - Game behaviour + - Performance + - Cosmetic + - Other + validations: + required: true + - type: textarea + attributes: + label: Bug description + description: How did you find the bug? Any additional details that might help? + validations: + required: true + - type: textarea + attributes: + label: Screenshots or videos + description: Add screenshots or videos that show the bug here. + placeholder: Drag and drop the screenshots/videos into this box. + validations: + required: false + - type: input + attributes: + label: Version + description: The version you encountered this bug on. This is shown at the bottom of the main menu and also at the end of the settings screen. + validations: + required: true + - type: markdown + attributes: + value: | + ## Logs + + Attaching log files is required for every reported bug. See instructions below on how to find them. + + If the game has not yet been closed since you found the bug: + 1. Head on to game settings and click on "Open osu! folder" + 2. Then open the `logs` folder located there + + **Logs are reset when you reopen the game.** If the game crashed or has been closed since you found the bug, retrieve the logs using the file explorer instead. + + The default places to find the logs are as follows: + - `%AppData%/osu/logs` *on Windows* + - `~/.local/share/osu/logs` *on Linux & macOS* + - `Android/data/sh.ppy.osulazer/files/logs` *on Android* + - *On iOS*, they can be obtained by connecting your device to your desktop and copying the `logs` directory from the app's own document storage using iTunes. (https://support.apple.com/en-us/HT201301#copy-to-computer) + + If you have selected a custom location for the game files, you can find the `logs` folder there. + + After locating the `logs` folder, select all log files inside and drag them into the "Logs" box below. + + - type: textarea + attributes: + label: Logs + placeholder: Drag and drop the log files into this box. + validations: + required: true diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000000..5b7a98f4ba --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "ms-dotnettools.csharp" + ] +} diff --git a/README.md b/README.md index f64240f67a..dba0b2670d 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ If you are looking to install or test osu! without setting up a development envi **Latest build:** -| [Windows 8.1+ (x64)](https://github.com/ppy/osu/releases/latest/download/install.exe) | [macOS 10.15+](https://github.com/ppy/osu/releases/latest/download/osu.app.zip) | [Linux (x64)](https://github.com/ppy/osu/releases/latest/download/osu.AppImage) | [iOS 10+](https://osu.ppy.sh/home/testflight) | [Android 5+](https://github.com/ppy/osu/releases/latest/download/sh.ppy.osulazer.apk) +| [Windows 8.1+ (x64)](https://github.com/ppy/osu/releases/latest/download/install.exe) | macOS 10.15+ ([Intel](https://github.com/ppy/osu/releases/latest/download/osu.app.Intel.zip), [Apple Silicon](https://github.com/ppy/osu/releases/latest/download/osu.app.Apple.Silicon.zip)) | [Linux (x64)](https://github.com/ppy/osu/releases/latest/download/osu.AppImage) | [iOS 10+](https://osu.ppy.sh/home/testflight) | [Android 5+](https://github.com/ppy/osu/releases/latest/download/sh.ppy.osulazer.apk) | ------------- | ------------- | ------------- | ------------- | ------------- | - The iOS testflight link may fill up (Apple has a hard limit of 10,000 users). We reset it occasionally when this happens. Please do not ask about this. Check back regularly for link resets or follow [peppy](https://twitter.com/ppy) on twitter for announcements of link resets. diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAimAssist.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAimAssist.cs index ed4b139e00..1abbd67d8f 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAimAssist.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAimAssist.cs @@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override ModType Type => ModType.Fun; public override string Description => "No need to chase the circle – the circle chases you!"; public override double ScoreMultiplier => 1; - public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay) }; + public override Type[] IncompatibleMods => new[] { typeof(OsuModAutopilot), typeof(OsuModWiggle), typeof(OsuModTransform), typeof(ModAutoplay), typeof(OsuModRelax) }; private IFrameStableClock gameplayClock; diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs index e04a30d06c..f46573c494 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModClassic.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using osu.Framework.Bindables; using osu.Game.Configuration; @@ -16,6 +17,8 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModClassic : ModClassic, IApplicableToHitObject, IApplicableToDrawableHitObject, IApplicableToDrawableRuleset { + public override Type[] IncompatibleMods => new[] { typeof(OsuModStrictTracking) }; + [SettingSource("No slider head accuracy requirement", "Scores sliders proportionally to the number of ticks hit.")] public Bindable NoSliderHeadAccuracy { get; } = new BindableBool(true); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs index 1bf63ef6d4..9719de441e 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Osu.Mods public class OsuModRelax : ModRelax, IUpdatableByPlayfield, IApplicableToDrawableRuleset, IApplicableToPlayer { public override string Description => @"You don't need to click. Give your clicking/tapping fingers a break from the heat of things."; - public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModAutopilot)).ToArray(); + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModAutopilot), typeof(OsuModAimAssist) }).ToArray(); /// /// How early before a hitobject's start time to trigger a hit. diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs new file mode 100644 index 0000000000..ee325db66a --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs @@ -0,0 +1,148 @@ +// 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 System.Threading; +using osu.Framework.Graphics.Sprites; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Osu.Objects.Drawables; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public class OsuModStrictTracking : Mod, IApplicableAfterBeatmapConversion, IApplicableToDrawableHitObject, IApplicableToDrawableRuleset + { + public override string Name => @"Strict Tracking"; + public override string Acronym => @"ST"; + public override IconUsage? Icon => FontAwesome.Solid.PenFancy; + public override ModType Type => ModType.DifficultyIncrease; + public override string Description => @"Follow circles just got serious..."; + public override double ScoreMultiplier => 1.0; + public override Type[] IncompatibleMods => new[] { typeof(ModClassic) }; + + public void ApplyToDrawableHitObject(DrawableHitObject drawable) + { + if (drawable is DrawableSlider slider) + { + slider.Tracking.ValueChanged += e => + { + if (e.NewValue || slider.Judged) return; + + var tail = slider.NestedHitObjects.OfType().First(); + + if (!tail.Judged) + tail.MissForcefully(); + }; + } + } + + public void ApplyToBeatmap(IBeatmap beatmap) + { + var osuBeatmap = (OsuBeatmap)beatmap; + + if (osuBeatmap.HitObjects.Count == 0) return; + + var hitObjects = osuBeatmap.HitObjects.Select(ho => + { + if (ho is Slider slider) + { + var newSlider = new StrictTrackingSlider(slider); + return newSlider; + } + + return ho; + }).ToList(); + + osuBeatmap.HitObjects = hitObjects; + } + + public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) + { + drawableRuleset.Playfield.RegisterPool(10, 100); + } + + private class StrictTrackingSliderTailCircle : SliderTailCircle + { + public StrictTrackingSliderTailCircle(Slider slider) + : base(slider) + { + } + + public override Judgement CreateJudgement() => new OsuJudgement(); + } + + private class StrictTrackingDrawableSliderTail : DrawableSliderTail + { + public override bool DisplayResult => true; + } + + private class StrictTrackingSlider : Slider + { + public StrictTrackingSlider(Slider original) + { + StartTime = original.StartTime; + Samples = original.Samples; + Path = original.Path; + NodeSamples = original.NodeSamples; + RepeatCount = original.RepeatCount; + Position = original.Position; + NewCombo = original.NewCombo; + ComboOffset = original.ComboOffset; + LegacyLastTickOffset = original.LegacyLastTickOffset; + TickDistanceMultiplier = original.TickDistanceMultiplier; + } + + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) + { + var sliderEvents = SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset, cancellationToken); + + foreach (var e in sliderEvents) + { + switch (e.Type) + { + case SliderEventType.Head: + AddNested(HeadCircle = new SliderHeadCircle + { + StartTime = e.Time, + Position = Position, + StackHeight = StackHeight, + }); + break; + + case SliderEventType.LegacyLastTick: + AddNested(TailCircle = new StrictTrackingSliderTailCircle(this) + { + RepeatIndex = e.SpanIndex, + StartTime = e.Time, + Position = EndPosition, + StackHeight = StackHeight + }); + break; + + case SliderEventType.Repeat: + AddNested(new SliderRepeat(this) + { + RepeatIndex = e.SpanIndex, + StartTime = StartTime + (e.SpanIndex + 1) * SpanDuration, + Position = Position + Path.PositionAt(e.PathProgress), + StackHeight = StackHeight, + Scale = Scale, + }); + break; + } + } + + UpdateNestedSamples(); + } + } + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index 776165cfb4..a698311bf7 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -154,7 +154,7 @@ namespace osu.Game.Rulesets.Osu.Objects public Slider() { - SamplesBindable.CollectionChanged += (_, __) => updateNestedSamples(); + SamplesBindable.CollectionChanged += (_, __) => UpdateNestedSamples(); Path.Version.ValueChanged += _ => updateNestedPositions(); } @@ -227,7 +227,7 @@ namespace osu.Game.Rulesets.Osu.Objects } } - updateNestedSamples(); + UpdateNestedSamples(); } private void updateNestedPositions() @@ -241,7 +241,7 @@ namespace osu.Game.Rulesets.Osu.Objects TailCircle.Position = EndPosition; } - private void updateNestedSamples() + protected void UpdateNestedSamples() { var firstSample = Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL) ?? Samples.FirstOrDefault(); // TODO: remove this when guaranteed sort is present for samples (https://github.com/ppy/osu/issues/1933) diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 2fdf42fca1..47a2618ddd 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -159,6 +159,7 @@ namespace osu.Game.Rulesets.Osu new MultiMod(new OsuModDoubleTime(), new OsuModNightcore()), new OsuModHidden(), new MultiMod(new OsuModFlashlight(), new OsuModBlinds()), + new OsuModStrictTracking() }; case ModType.Conversion: diff --git a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs index ff9f6f0e07..900ad6f6d3 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Legacy/OsuLegacySkinTransformer.cs @@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy switch (osuComponent.Component) { case OsuSkinComponents.FollowPoint: - return this.GetAnimation(component.LookupName, true, false, true, startAtCurrentTime: false); + return this.GetAnimation(component.LookupName, true, true, true, startAtCurrentTime: false); case OsuSkinComponents.SliderFollowCircle: var followCircle = this.GetAnimation("sliderfollowcircle", true, true, true); diff --git a/osu.Game.Tests/Database/BeatmapImporterTests.cs b/osu.Game.Tests/Database/BeatmapImporterTests.cs index 9d67381b5a..9abd78039a 100644 --- a/osu.Game.Tests/Database/BeatmapImporterTests.cs +++ b/osu.Game.Tests/Database/BeatmapImporterTests.cs @@ -590,6 +590,8 @@ namespace osu.Game.Tests.Database Assert.IsTrue(imported.DeletePending); + var originalAddedDate = imported.DateAdded; + var importedSecondTime = await LoadOszIntoStore(importer, realm.Realm); // check the newly "imported" beatmap is actually just the restored previous import. since it matches hash. @@ -597,6 +599,7 @@ namespace osu.Game.Tests.Database Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID); Assert.IsFalse(imported.DeletePending); Assert.IsFalse(importedSecondTime.DeletePending); + Assert.That(importedSecondTime.DateAdded, Is.GreaterThan(originalAddedDate)); }); } @@ -646,6 +649,8 @@ namespace osu.Game.Tests.Database Assert.IsTrue(imported.DeletePending); + var originalAddedDate = imported.DateAdded; + var importedSecondTime = await LoadOszIntoStore(importer, realm.Realm); // check the newly "imported" beatmap is actually just the restored previous import. since it matches hash. @@ -653,6 +658,7 @@ namespace osu.Game.Tests.Database Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID); Assert.IsFalse(imported.DeletePending); Assert.IsFalse(importedSecondTime.DeletePending); + Assert.That(importedSecondTime.DateAdded, Is.GreaterThan(originalAddedDate)); }); } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs index ecd4035edd..b109234fec 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs @@ -13,6 +13,7 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Database; +using osu.Game.Overlays.Dialog; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Osu; @@ -23,6 +24,7 @@ using osu.Game.Screens.Edit.Setup; using osu.Game.Storyboards; using osu.Game.Tests.Resources; using osuTK; +using osuTK.Input; using SharpCompress.Archives; using SharpCompress.Archives.Zip; @@ -63,13 +65,19 @@ namespace osu.Game.Tests.Visual.Editing EditorBeatmap editorBeatmap = null; AddStep("store editor beatmap", () => editorBeatmap = EditorBeatmap); - AddStep("exit without save", () => + + AddStep("exit without save", () => Editor.Exit()); + AddStep("hold to confirm", () => { - Editor.Exit(); - DialogOverlay.CurrentDialog.PerformOkAction(); + var confirmButton = DialogOverlay.CurrentDialog.ChildrenOfType().First(); + + InputManager.MoveMouseTo(confirmButton); + InputManager.PressButton(MouseButton.Left); }); AddUntilStep("wait for exit", () => !Editor.IsCurrentScreen()); + AddStep("release", () => InputManager.ReleaseButton(MouseButton.Left)); + AddAssert("new beatmap not persisted", () => beatmapManager.QueryBeatmapSet(s => s.ID == editorBeatmap.BeatmapInfo.BeatmapSet.ID)?.Value.DeletePending == true); } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs index d1c1558003..e75c7f25a3 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs @@ -17,6 +17,13 @@ namespace osu.Game.Tests.Visual.Editing { public class TestSceneEditorSaving : EditorSavingTestScene { + [Test] + public void TestCantExitWithoutSaving() + { + AddRepeatStep("Exit", () => InputManager.Key(Key.Escape), 10); + AddAssert("Editor is still active screen", () => Game.ScreenStack.CurrentScreen is Editor); + } + [Test] public void TestMetadata() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs index e74345aae9..38d83058c0 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs @@ -1,14 +1,19 @@ // 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.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Testing; using osu.Game.Overlays; +using osu.Game.Overlays.Settings; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Play.HUD.HitErrorMeters; using osu.Game.Skinning.Editor; +using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { @@ -29,7 +34,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("reload skin editor", () => { skinEditor?.Expire(); - Player.ScaleTo(0.8f); + Player.ScaleTo(0.4f); LoadComponentAsync(skinEditor = new SkinEditor(Player), Add); }); } @@ -40,6 +45,36 @@ namespace osu.Game.Tests.Visual.Gameplay AddToggleStep("toggle editor visibility", visible => skinEditor.ToggleVisibility()); } + [Test] + public void TestEditComponent() + { + BarHitErrorMeter hitErrorMeter = null; + + AddStep("select bar hit error blueprint", () => + { + var blueprint = skinEditor.ChildrenOfType().First(b => b.Item is BarHitErrorMeter); + + hitErrorMeter = (BarHitErrorMeter)blueprint.Item; + skinEditor.SelectedComponents.Clear(); + skinEditor.SelectedComponents.Add(blueprint.Item); + }); + + AddAssert("value is default", () => hitErrorMeter.JudgementLineThickness.IsDefault); + + AddStep("hover first slider", () => + { + InputManager.MoveMouseTo( + skinEditor.ChildrenOfType().First() + .ChildrenOfType>().First() + .ChildrenOfType>().First() + ); + }); + + AddStep("adjust slider via keyboard", () => InputManager.Key(Key.Left)); + + AddAssert("value is less than default", () => hitErrorMeter.JudgementLineThickness.Value < hitErrorMeter.JudgementLineThickness.Default); + } + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs index d614815316..8b420cebc8 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectator.cs @@ -70,6 +70,56 @@ namespace osu.Game.Tests.Visual.Gameplay }); } + [Test] + public void TestSeekToGameplayStartFramesArriveAfterPlayerLoad() + { + const double gameplay_start = 10000; + + loadSpectatingScreen(); + + start(); + + waitForPlayer(); + + sendFrames(startTime: gameplay_start); + + AddAssert("time is greater than seek target", () => currentFrameStableTime > gameplay_start); + } + + /// + /// Tests the same as but with the frames arriving just as is transitioning into existence. + /// + [Test] + public void TestSeekToGameplayStartFramesArriveAsPlayerLoaded() + { + const double gameplay_start = 10000; + + loadSpectatingScreen(); + + start(); + + AddUntilStep("wait for player loader", () => (Stack.CurrentScreen as PlayerLoader)?.IsLoaded == true); + + AddUntilStep("queue send frames on player load", () => + { + var loadingPlayer = (Stack.CurrentScreen as PlayerLoader)?.CurrentPlayer; + + if (loadingPlayer == null) + return false; + + loadingPlayer.OnLoadComplete += _ => + { + spectatorClient.SendFramesFromUser(streamingUser.Id, 10, gameplay_start); + }; + return true; + }); + + waitForPlayer(); + + AddUntilStep("state is playing", () => spectatorClient.WatchedUserStates[streamingUser.Id].State == SpectatedUserState.Playing); + AddAssert("time is greater than seek target", () => currentFrameStableTime > gameplay_start); + } + [Test] public void TestFrameStarvationAndResume() { @@ -319,9 +369,9 @@ namespace osu.Game.Tests.Visual.Gameplay private void checkPaused(bool state) => AddUntilStep($"game is {(state ? "paused" : "playing")}", () => player.ChildrenOfType().First().IsPaused.Value == state); - private void sendFrames(int count = 10) + private void sendFrames(int count = 10, double startTime = 0) { - AddStep("send frames", () => spectatorClient.SendFramesFromUser(streamingUser.Id, count)); + AddStep("send frames", () => spectatorClient.SendFramesFromUser(streamingUser.Id, count, startTime)); } private void loadSpectatingScreen() diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs index 7ef9505d8c..cd1eee49ec 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs @@ -286,7 +286,7 @@ namespace osu.Game.Tests.Visual.Multiplayer ClickButtonWhenEnabled(); AddUntilStep("user is idle (match not started)", () => MultiplayerClient.Room?.Users[0].State == MultiplayerUserState.Idle); - AddAssert("ready button enabled", () => control.ChildrenOfType().Single().Enabled.Value); + AddUntilStep("ready button enabled", () => control.ChildrenOfType().Single().Enabled.Value); } [TestCase(true)] diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs index 6b5b45b73e..cbd8b472b8 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs @@ -11,6 +11,8 @@ using osu.Framework.Graphics; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Rulesets; @@ -129,6 +131,25 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("item 1 not in lists", () => !inHistoryList(0) && !inQueueList(0)); } + [Test] + public void TestQueueTabCount() + { + assertQueueTabCount(1); + + addItemStep(); + assertQueueTabCount(2); + + addItemStep(); + assertQueueTabCount(3); + + AddStep("finish current item", () => MultiplayerClient.FinishCurrentItem().WaitSafely()); + assertQueueTabCount(2); + + AddStep("leave room", () => RoomManager.PartRoom()); + AddUntilStep("wait for room part", () => !RoomJoined); + assertQueueTabCount(0); + } + [Ignore("Expired items are initially removed from the room.")] [Test] public void TestJoinRoomWithMixedItemsAddedInCorrectLists() @@ -213,6 +234,17 @@ namespace osu.Game.Tests.Visual.Multiplayer }); } + private void assertQueueTabCount(int count) + { + string queueTabText = count > 0 ? $"Queue ({count})" : "Queue"; + AddUntilStep($"Queue tab shows \"{queueTabText}\"", () => + { + return this.ChildrenOfType.OsuTabItem>() + .Single(t => t.Value == MultiplayerPlaylistDisplayMode.Queue) + .ChildrenOfType().Single().Text == queueTabText; + }); + } + private void changeDisplayModeStep(MultiplayerPlaylistDisplayMode mode) => AddStep($"change list to {mode}", () => list.DisplayMode.Value = mode); private bool inQueueList(int playlistItemId) diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index f2e6aa1e16..394976eb43 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -7,6 +7,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.UserInterface; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; @@ -14,6 +15,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Online.Leaderboards; using osu.Game.Overlays; using osu.Game.Overlays.Mods; +using osu.Game.Overlays.Settings; using osu.Game.Overlays.Toolbar; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; @@ -21,10 +23,12 @@ using osu.Game.Scoring; using osu.Game.Screens.Menu; using osu.Game.Screens.OnlinePlay.Lounge; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD.HitErrorMeters; using osu.Game.Screens.Ranking; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Leaderboards; using osu.Game.Screens.Select.Options; +using osu.Game.Skinning.Editor; using osu.Game.Tests.Beatmaps.IO; using osuTK; using osuTK.Input; @@ -66,6 +70,73 @@ namespace osu.Game.Tests.Visual.Navigation AddAssert("Overlay was shown", () => songSelect.ModSelectOverlay.State.Value == Visibility.Visible); } + [Test] + public void TestEditComponentDuringGameplay() + { + Screens.Select.SongSelect songSelect = null; + PushAndConfirm(() => songSelect = new TestPlaySongSelect()); + AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + + AddStep("import beatmap", () => BeatmapImportHelper.LoadQuickOszIntoOsu(Game).WaitSafely()); + + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + + SkinEditor skinEditor = null; + + AddStep("open skin editor", () => + { + InputManager.PressKey(Key.ControlLeft); + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.S); + InputManager.ReleaseKey(Key.ControlLeft); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + + AddUntilStep("get skin editor", () => (skinEditor = Game.ChildrenOfType().FirstOrDefault()) != null); + + AddStep("Click gameplay scene button", () => + { + skinEditor.ChildrenOfType().First(b => b.Text == "Gameplay").TriggerClick(); + }); + + AddUntilStep("wait for player", () => + { + // dismiss any notifications that may appear (ie. muted notification). + clickMouseInCentre(); + return Game.ScreenStack.CurrentScreen is Player; + }); + + BarHitErrorMeter hitErrorMeter = null; + + AddUntilStep("select bar hit error blueprint", () => + { + var blueprint = skinEditor.ChildrenOfType().FirstOrDefault(b => b.Item is BarHitErrorMeter); + + if (blueprint == null) + return false; + + hitErrorMeter = (BarHitErrorMeter)blueprint.Item; + skinEditor.SelectedComponents.Clear(); + skinEditor.SelectedComponents.Add(blueprint.Item); + return true; + }); + + AddAssert("value is default", () => hitErrorMeter.JudgementLineThickness.IsDefault); + + AddStep("hover first slider", () => + { + InputManager.MoveMouseTo( + skinEditor.ChildrenOfType().First() + .ChildrenOfType>().First() + .ChildrenOfType>().First() + ); + }); + + AddStep("adjust slider via keyboard", () => InputManager.Key(Key.Left)); + + AddAssert("value is less than default", () => hitErrorMeter.JudgementLineThickness.Value < hitErrorMeter.JudgementLineThickness.Default); + } + [Test] public void TestRetryCountIncrements() { @@ -120,7 +191,8 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("press back button", () => Game.ChildrenOfType().First().Action()); - AddStep("show local scores", () => Game.ChildrenOfType().First().Current.Value = new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Local)); + AddStep("show local scores", + () => Game.ChildrenOfType().First().Current.Value = new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Local)); AddUntilStep("wait for score displayed", () => (scorePanel = Game.ChildrenOfType().FirstOrDefault(s => s.Score.Equals(score))) != null); @@ -152,7 +224,8 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("press back button", () => Game.ChildrenOfType().First().Action()); - AddStep("show local scores", () => Game.ChildrenOfType().First().Current.Value = new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Local)); + AddStep("show local scores", + () => Game.ChildrenOfType().First().Current.Value = new BeatmapDetailAreaLeaderboardTabItem(BeatmapLeaderboardScope.Local)); AddUntilStep("wait for score displayed", () => (scorePanel = Game.ChildrenOfType().FirstOrDefault(s => s.Score.Equals(score))) != null); @@ -262,6 +335,20 @@ namespace osu.Game.Tests.Visual.Navigation exitViaBackButtonAndConfirm(); } + [Test] + public void TestModsResetOnEnteringMultiplayer() + { + var osuAutomationMod = new OsuModAutoplay(); + + AddStep("Enable autoplay", () => { Game.SelectedMods.Value = new[] { osuAutomationMod }; }); + + PushAndConfirm(() => new Screens.OnlinePlay.Multiplayer.Multiplayer()); + AddUntilStep("Mods are removed", () => Game.SelectedMods.Value.Count == 0); + + AddStep("Return to menu", () => Game.ScreenStack.CurrentScreen.Exit()); + AddUntilStep("Mods are restored", () => Game.SelectedMods.Value.Contains(osuAutomationMod)); + } + [Test] public void TestExitMultiWithEscape() { diff --git a/osu.Game.Tests/Visual/Online/TestSceneChannelListItem.cs b/osu.Game.Tests/Visual/Online/TestSceneChannelListItem.cs new file mode 100644 index 0000000000..af419c8b91 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneChannelListItem.cs @@ -0,0 +1,163 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Chat; +using osu.Game.Overlays; +using osu.Game.Overlays.Chat.ChannelList; +using osuTK; + +namespace osu.Game.Tests.Visual.Online +{ + [TestFixture] + public class TestSceneChannelListItem : OsuTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); + + [Cached] + private readonly Bindable selected = new Bindable(); + + private static readonly List channels = new List + { + createPublicChannel("#public-channel"), + createPublicChannel("#public-channel-long-name"), + createPrivateChannel("test user", 2), + createPrivateChannel("test user long name", 3), + }; + + private readonly Dictionary channelMap = new Dictionary(); + + private FillFlowContainer flow; + private OsuSpriteText selectedText; + private OsuSpriteText leaveText; + + [SetUp] + public void SetUp() + { + Schedule(() => + { + foreach (var item in channelMap.Values) + item.Expire(); + + channelMap.Clear(); + + Child = new FillFlowContainer + { + Direction = FillDirection.Vertical, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(10), + Children = new Drawable[] + { + selectedText = new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + }, + leaveText = new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Height = 16, + AlwaysPresent = true, + }, + new Container + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Y, + Width = 190, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background6, + }, + flow = new FillFlowContainer + { + Direction = FillDirection.Vertical, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + }, + }, + }, + }, + }; + + selected.BindValueChanged(change => + { + selectedText.Text = $"Selected Channel: {change.NewValue?.Name ?? "[null]"}"; + }, true); + + foreach (var channel in channels) + { + var item = new ChannelListItem(channel); + flow.Add(item); + channelMap.Add(channel, item); + item.OnRequestSelect += c => selected.Value = c; + item.OnRequestLeave += leaveChannel; + } + }); + } + + [Test] + public void TestVisual() + { + AddStep("Select second item", () => selected.Value = channels.Skip(1).First()); + + AddStep("Unread Selected", () => + { + if (selected.Value != null) + channelMap[selected.Value].Unread.Value = true; + }); + + AddStep("Read Selected", () => + { + if (selected.Value != null) + channelMap[selected.Value].Unread.Value = false; + }); + + AddStep("Add Mention Selected", () => + { + if (selected.Value != null) + channelMap[selected.Value].Mentions.Value++; + }); + + AddStep("Add 98 Mentions Selected", () => + { + if (selected.Value != null) + channelMap[selected.Value].Mentions.Value += 98; + }); + + AddStep("Clear Mentions Selected", () => + { + if (selected.Value != null) + channelMap[selected.Value].Mentions.Value = 0; + }); + } + + private void leaveChannel(Channel channel) + { + leaveText.Text = $"OnRequestLeave: {channel.Name}"; + leaveText.FadeOutFromOne(1000, Easing.InQuint); + } + + private static Channel createPublicChannel(string name) => + new Channel { Name = name, Type = ChannelType.Public, Id = 1234 }; + + private static Channel createPrivateChannel(string username, int id) + => new Channel(new APIUser { Id = id, Username = username }); + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs b/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs index 8e53c7c402..6bd6115e68 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestScenePopupDialog.cs @@ -40,6 +40,10 @@ namespace osu.Game.Tests.Visual.UserInterface { Text = @"You're a fake!", }, + new PopupDialogDangerousButton + { + Text = @"Careful with this one..", + }, }; } } diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index c6f69286cd..f90208d0c0 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -9,6 +9,7 @@ using osu.Framework.Testing; using osu.Game.Database; using osu.Game.Models; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays.BeatmapSet.Scores; using osu.Game.Rulesets; using osu.Game.Scoring; using Realms; @@ -169,7 +170,12 @@ namespace osu.Game.Beatmaps [Ignored] public APIBeatmap? OnlineInfo { get; set; } + /// + /// The maximum achievable combo on this beatmap, populated for online info purposes only. + /// Todo: This should never be used nor exist, but is still relied on in since can't be used yet. For now this is obsoleted until it is removed. + /// [Ignored] + [Obsolete("Use ScoreManager.GetMaximumAchievableComboAsync instead.")] public int? MaxCombo { get; set; } [Ignored] diff --git a/osu.Game/Beatmaps/EFBeatmapInfo.cs b/osu.Game/Beatmaps/EFBeatmapInfo.cs index 8daeaa7030..740adfd1c7 100644 --- a/osu.Game/Beatmaps/EFBeatmapInfo.cs +++ b/osu.Game/Beatmaps/EFBeatmapInfo.cs @@ -53,9 +53,6 @@ namespace osu.Game.Beatmaps [NotMapped] public APIBeatmap OnlineInfo { get; set; } - [NotMapped] - public int? MaxCombo { get; set; } - /// /// The playable length in milliseconds of this beatmap. /// diff --git a/osu.Game/Database/EFToRealmMigrator.cs b/osu.Game/Database/EFToRealmMigrator.cs index c9deee19fe..cbf5c5ffe9 100644 --- a/osu.Game/Database/EFToRealmMigrator.cs +++ b/osu.Game/Database/EFToRealmMigrator.cs @@ -295,7 +295,6 @@ namespace osu.Game.Database TimelineZoom = beatmap.TimelineZoom, Countdown = beatmap.Countdown, CountdownOffset = beatmap.CountdownOffset, - MaxCombo = beatmap.MaxCombo, Bookmarks = beatmap.Bookmarks, BeatmapSet = realmBeatmapSet, }; diff --git a/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs b/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs index 999dd183aa..b2f08eee0a 100644 --- a/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs +++ b/osu.Game/Graphics/Containers/HoldToConfirmContainer.cs @@ -28,6 +28,14 @@ namespace osu.Game.Graphics.Containers /// protected virtual bool AllowMultipleFires => false; + /// + /// Specify a custom activation delay, overriding the game-wide user setting. + /// + /// + /// This should be used in special cases where we want to be extra sure the user knows what they are doing. An example is when changes would be lost. + /// + protected virtual double? HoldActivationDelay => null; + public Bindable Progress = new BindableDouble(); private Bindable holdActivationDelay; @@ -35,7 +43,9 @@ namespace osu.Game.Graphics.Containers [BackgroundDependencyLoader] private void load(OsuConfigManager config) { - holdActivationDelay = config.GetBindable(OsuSetting.UIHoldActivationDelay); + holdActivationDelay = HoldActivationDelay != null + ? new Bindable(HoldActivationDelay.Value) + : config.GetBindable(OsuSetting.UIHoldActivationDelay); } protected void BeginConfirm() diff --git a/osu.Game/Graphics/UserInterface/DialogButton.cs b/osu.Game/Graphics/UserInterface/DialogButton.cs index 2f9e4dae51..ad69ec4078 100644 --- a/osu.Game/Graphics/UserInterface/DialogButton.cs +++ b/osu.Game/Graphics/UserInterface/DialogButton.cs @@ -45,8 +45,9 @@ namespace osu.Game.Graphics.UserInterface } } + protected readonly Container ColourContainer; + private readonly Container backgroundContainer; - private readonly Container colourContainer; private readonly Container glowContainer; private readonly Box leftGlow; private readonly Box centerGlow; @@ -113,7 +114,7 @@ namespace osu.Game.Graphics.UserInterface Masking = true, Children = new Drawable[] { - colourContainer = new Container + ColourContainer = new Container { RelativeSizeAxes = Axes.Both, Origin = Anchor.Centre, @@ -182,7 +183,7 @@ namespace osu.Game.Graphics.UserInterface { buttonColour = value; updateGlow(); - colourContainer.Colour = value; + ColourContainer.Colour = value; } } @@ -230,11 +231,11 @@ namespace osu.Game.Graphics.UserInterface Alpha = 0.05f }; - colourContainer.Add(flash); + ColourContainer.Add(flash); flash.FadeOutFromOne(100).Expire(); clickAnimating = true; - colourContainer.ResizeWidthTo(colourContainer.Width * 1.05f, 100, Easing.OutQuint) + ColourContainer.ResizeWidthTo(ColourContainer.Width * 1.05f, 100, Easing.OutQuint) .OnComplete(_ => { clickAnimating = false; @@ -246,14 +247,14 @@ namespace osu.Game.Graphics.UserInterface protected override bool OnMouseDown(MouseDownEvent e) { - colourContainer.ResizeWidthTo(hover_width * 0.98f, click_duration * 4, Easing.OutQuad); + ColourContainer.ResizeWidthTo(hover_width * 0.98f, click_duration * 4, Easing.OutQuad); return base.OnMouseDown(e); } protected override void OnMouseUp(MouseUpEvent e) { if (State == SelectionState.Selected) - colourContainer.ResizeWidthTo(hover_width, click_duration, Easing.In); + ColourContainer.ResizeWidthTo(hover_width, click_duration, Easing.In); base.OnMouseUp(e); } @@ -279,12 +280,12 @@ namespace osu.Game.Graphics.UserInterface if (newState == SelectionState.Selected) { spriteText.TransformSpacingTo(hoverSpacing, hover_duration, Easing.OutElastic); - colourContainer.ResizeWidthTo(hover_width, hover_duration, Easing.OutElastic); + ColourContainer.ResizeWidthTo(hover_width, hover_duration, Easing.OutElastic); glowContainer.FadeIn(hover_duration, Easing.OutQuint); } else { - colourContainer.ResizeWidthTo(idle_width, hover_duration, Easing.OutElastic); + ColourContainer.ResizeWidthTo(idle_width, hover_duration, Easing.OutElastic); spriteText.TransformSpacingTo(Vector2.Zero, hover_duration, Easing.OutElastic); glowContainer.FadeOut(hover_duration, Easing.OutQuint); } diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 25bd3d71de..4cd954a646 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -1046,6 +1046,10 @@ namespace osu.Game switch (e.Action) { + case GlobalAction.ToggleSkinEditor: + skinEditor.ToggleVisibility(); + return true; + case GlobalAction.ResetInputSettings: Host.ResetInputHandlers(); frameworkConfig.GetBindable(FrameworkSetting.ConfineMouseMode).SetDefault(); diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs index 5ef434c427..86e72e9faa 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs @@ -173,7 +173,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores { Text = score.MaxCombo.ToLocalisableString(@"0\x"), Font = OsuFont.GetFont(size: text_size), +#pragma warning disable 618 Colour = score.MaxCombo == score.BeatmapInfo.MaxCombo ? highAccuracyColour : Color4.White +#pragma warning restore 618 } }; diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs index 6f07b20049..7d59c95396 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs @@ -78,7 +78,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores // TODO: temporary. should be removed once `OrderByTotalScore` can accept `IScoreInfo`. var beatmapInfo = new BeatmapInfo { +#pragma warning disable 618 MaxCombo = apiBeatmap.MaxCombo, +#pragma warning restore 618 Status = apiBeatmap.Status, MD5Hash = apiBeatmap.MD5Hash }; diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs new file mode 100644 index 0000000000..43574351ed --- /dev/null +++ b/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.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. + +#nullable enable + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Online.Chat; +using osu.Game.Users.Drawables; +using osuTK; + +namespace osu.Game.Overlays.Chat.ChannelList +{ + public class ChannelListItem : OsuClickableContainer + { + public event Action? OnRequestSelect; + public event Action? OnRequestLeave; + + public readonly BindableInt Mentions = new BindableInt(); + + public readonly BindableBool Unread = new BindableBool(); + + private readonly Channel channel; + + private Box? hoverBox; + private Box? selectBox; + private OsuSpriteText? text; + private ChannelListItemCloseButton? close; + + [Resolved] + private Bindable selectedChannel { get; set; } = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public ChannelListItem(Channel channel) + { + this.channel = channel; + } + + [BackgroundDependencyLoader] + private void load() + { + Height = 30; + RelativeSizeAxes = Axes.X; + + Children = new Drawable[] + { + hoverBox = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background3, + Alpha = 0f, + }, + selectBox = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4, + Alpha = 0f, + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Left = 18, Right = 10 }, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(), + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.AutoSize), + }, + Content = new[] + { + new[] + { + createIcon(), + text = new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = channel.Name, + Font = OsuFont.Torus.With(size: 17, weight: FontWeight.SemiBold), + Colour = colourProvider.Light3, + Margin = new MarginPadding { Bottom = 2 }, + RelativeSizeAxes = Axes.X, + Truncate = true, + }, + new ChannelListItemMentionPill + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Right = 3 }, + Mentions = { BindTarget = Mentions }, + }, + close = new ChannelListItemCloseButton + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Margin = new MarginPadding { Right = 3 }, + Action = () => OnRequestLeave?.Invoke(channel), + } + } + }, + }, + }, + }; + + Action = () => OnRequestSelect?.Invoke(channel); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + selectedChannel.BindValueChanged(change => + { + if (change.NewValue == channel) + selectBox?.FadeIn(300, Easing.OutQuint); + else + selectBox?.FadeOut(200, Easing.OutQuint); + }, true); + + Unread.BindValueChanged(change => + { + text!.FadeColour(change.NewValue ? colourProvider.Content1 : colourProvider.Light3, 300, Easing.OutQuint); + }, true); + } + + protected override bool OnHover(HoverEvent e) + { + hoverBox?.FadeIn(300, Easing.OutQuint); + close?.FadeIn(300, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + hoverBox?.FadeOut(200, Easing.OutQuint); + close?.FadeOut(200, Easing.OutQuint); + base.OnHoverLost(e); + } + + private Drawable createIcon() + { + if (channel.Type != ChannelType.PM) + return Drawable.Empty(); + + return new UpdateableAvatar(channel.Users.First(), isInteractive: false) + { + Size = new Vector2(20), + Margin = new MarginPadding { Right = 5 }, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + CornerRadius = 10, + Masking = true, + }; + } + } +} diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelListItemCloseButton.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelListItemCloseButton.cs new file mode 100644 index 0000000000..65b9c4a79b --- /dev/null +++ b/osu.Game/Overlays/Chat/ChannelList/ChannelListItemCloseButton.cs @@ -0,0 +1,68 @@ +// 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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Chat.ChannelList +{ + public class ChannelListItemCloseButton : OsuClickableContainer + { + private SpriteIcon icon = null!; + + private Color4 normalColour; + private Color4 hoveredColour; + + [BackgroundDependencyLoader] + private void load(OsuColour osuColour) + { + normalColour = osuColour.Red2; + hoveredColour = Color4.White; + + Alpha = 0f; + Size = new Vector2(20); + Add(icon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(0.75f), + Icon = FontAwesome.Solid.TimesCircle, + RelativeSizeAxes = Axes.Both, + Colour = normalColour, + }); + } + + // Transforms matching OsuAnimatedButton + protected override bool OnHover(HoverEvent e) + { + icon.FadeColour(hoveredColour, 300, Easing.OutQuint); + return base.OnHover(e); + } + + protected override void OnHoverLost(HoverLostEvent e) + { + icon.FadeColour(normalColour, 300, Easing.OutQuint); + base.OnHoverLost(e); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + icon.ScaleTo(0.75f, 2000, Easing.OutQuint); + return base.OnMouseDown(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + icon.ScaleTo(1, 1000, Easing.OutElastic); + base.OnMouseUp(e); + } + } +} diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelListItemMentionPill.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelListItemMentionPill.cs new file mode 100644 index 0000000000..5018c8cd64 --- /dev/null +++ b/osu.Game/Overlays/Chat/ChannelList/ChannelListItemMentionPill.cs @@ -0,0 +1,71 @@ +// 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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Chat.ChannelList +{ + public class ChannelListItemMentionPill : CircularContainer + { + public readonly BindableInt Mentions = new BindableInt(); + + private OsuSpriteText countText = null!; + + private Box box = null!; + + [BackgroundDependencyLoader] + private void load(OsuColour osuColour, OverlayColourProvider colourProvider) + { + Masking = true; + Size = new Vector2(20, 12); + Alpha = 0f; + + Children = new Drawable[] + { + box = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = osuColour.Orange1, + }, + countText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Torus.With(size: 11, weight: FontWeight.Bold), + Margin = new MarginPadding { Bottom = 1 }, + Colour = colourProvider.Background5, + }, + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Mentions.BindValueChanged(change => + { + int mentionCount = change.NewValue; + + countText.Text = mentionCount > 99 ? "99+" : mentionCount.ToString(); + + if (mentionCount > 0) + { + this.FadeIn(1000, Easing.OutQuint); + box.FlashColour(Color4.White, 500, Easing.OutQuint); + } + else + this.FadeOut(100, Easing.OutQuint); + }, true); + } + } +} diff --git a/osu.Game/Overlays/Dialog/PopupDialog.cs b/osu.Game/Overlays/Dialog/PopupDialog.cs index 0f953f92bb..a70a7f26cc 100644 --- a/osu.Game/Overlays/Dialog/PopupDialog.cs +++ b/osu.Game/Overlays/Dialog/PopupDialog.cs @@ -219,7 +219,12 @@ namespace osu.Game.Overlays.Dialog /// /// Programmatically clicks the first . /// - public void PerformOkAction() => Buttons.OfType().First().TriggerClick(); + public void PerformOkAction() => PerformAction(); + + /// + /// Programmatically clicks the first button of the provided type. + /// + public void PerformAction() where T : PopupDialogButton => Buttons.OfType().First().TriggerClick(); protected override bool OnKeyDown(KeyDownEvent e) { diff --git a/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs b/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs new file mode 100644 index 0000000000..1911a4fa56 --- /dev/null +++ b/osu.Game/Overlays/Dialog/PopupDialogDangerousButton.cs @@ -0,0 +1,59 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Overlays.Dialog +{ + public class PopupDialogDangerousButton : PopupDialogButton + { + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + ButtonColour = colours.Red3; + + ColourContainer.Add(new ConfirmFillBox + { + Action = () => Action(), + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + }); + } + + private class ConfirmFillBox : HoldToConfirmContainer + { + private Box box; + + protected override double? HoldActivationDelay => 500; + + protected override void LoadComplete() + { + base.LoadComplete(); + + Child = box = new Box + { + RelativeSizeAxes = Axes.Both, + }; + + Progress.BindValueChanged(progress => box.Width = (float)progress.NewValue, true); + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + BeginConfirm(); + return true; + } + + protected override void OnMouseUp(MouseUpEvent e) + { + if (!e.HasAnyButtonPressed) + AbortConfirm(); + } + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index 475c4bff8d..a34776ddf0 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -64,7 +64,7 @@ namespace osu.Game.Overlays.Settings.Sections new SettingsButton { Text = SkinSettingsStrings.SkinLayoutEditor, - Action = () => skinEditor?.Toggle(), + Action = () => skinEditor?.ToggleVisibility(), }, new ExportSkinButton(), }; diff --git a/osu.Game/Rulesets/Scoring/HitResult.cs b/osu.Game/Rulesets/Scoring/HitResult.cs index 514232db69..9f03c381ee 100644 --- a/osu.Game/Rulesets/Scoring/HitResult.cs +++ b/osu.Game/Rulesets/Scoring/HitResult.cs @@ -122,7 +122,19 @@ namespace osu.Game.Rulesets.Scoring public static class HitResultExtensions { /// - /// Whether a increases/decreases the combo, and affects the combo portion of the score. + /// Whether a increases the combo. + /// + public static bool IncreasesCombo(this HitResult result) + => AffectsCombo(result) && IsHit(result); + + /// + /// Whether a breaks the combo and resets it back to zero. + /// + public static bool BreaksCombo(this HitResult result) + => AffectsCombo(result) && !IsHit(result); + + /// + /// Whether a increases/breaks the combo, and affects the combo portion of the score. /// public static bool AffectsCombo(this HitResult result) { diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index 0c585fac98..1e268bb2eb 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -166,20 +166,10 @@ namespace osu.Game.Rulesets.Scoring if (!result.Type.IsScorable()) return; - if (result.Type.AffectsCombo()) - { - switch (result.Type) - { - case HitResult.Miss: - case HitResult.LargeTickMiss: - Combo.Value = 0; - break; - - default: - Combo.Value++; - break; - } - } + if (result.Type.IncreasesCombo()) + Combo.Value++; + else if (result.Type.BreaksCombo()) + Combo.Value = 0; double scoreIncrease = result.Type.IsHit() ? result.Judgement.NumericResultFor(result) : 0; diff --git a/osu.Game/Rulesets/UI/Playfield.cs b/osu.Game/Rulesets/UI/Playfield.cs index cd8f99db8b..ea5ffb10c6 100644 --- a/osu.Game/Rulesets/UI/Playfield.cs +++ b/osu.Game/Rulesets/UI/Playfield.cs @@ -323,7 +323,7 @@ namespace osu.Game.Rulesets.UI /// /// The type. /// The receiver for s. - protected void RegisterPool(int initialSize, int? maximumSize = null) + public void RegisterPool(int initialSize, int? maximumSize = null) where TObject : HitObject where TDrawable : DrawableHitObject, new() => RegisterPool(new DrawablePool(initialSize, maximumSize)); diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index 4de1d580dc..d7185a1677 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -157,7 +157,7 @@ namespace osu.Game.Scoring public LocalisableString DisplayAccuracy => Accuracy.FormatAccuracy(); /// - /// Whether this represents a legacy (osu!stable) score. + /// Whether this represents a legacy (osu!stable) score. /// [Ignored] public bool IsLegacyScore => Mods.OfType().Any(); diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index 02a7d9a39f..83359838aa 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -134,35 +134,9 @@ namespace osu.Game.Scoring if (string.IsNullOrEmpty(score.BeatmapInfo.MD5Hash)) return score.TotalScore; - int beatmapMaxCombo; - - if (score.IsLegacyScore) - { - // This score is guaranteed to be an osu!stable score. - // The combo must be determined through either the beatmap's max combo value or the difficulty calculator, as lazer's scoring has changed and the score statistics cannot be used. - if (score.BeatmapInfo.MaxCombo != null) - beatmapMaxCombo = score.BeatmapInfo.MaxCombo.Value; - else - { - if (difficulties == null) - return score.TotalScore; - - // We can compute the max combo locally after the async beatmap difficulty computation. - var difficulty = await difficulties().GetDifficultyAsync(score.BeatmapInfo, score.Ruleset, score.Mods, cancellationToken).ConfigureAwait(false); - - // Something failed during difficulty calculation. Fall back to provided score. - if (difficulty == null) - return score.TotalScore; - - beatmapMaxCombo = difficulty.Value.MaxCombo; - } - } - else - { - // This is guaranteed to be a non-legacy score. - // The combo must be determined through the score's statistics, as both the beatmap's max combo and the difficulty calculator will provide osu!stable combo values. - beatmapMaxCombo = Enum.GetValues(typeof(HitResult)).OfType().Where(r => r.AffectsCombo()).Select(r => score.Statistics.GetValueOrDefault(r)).Sum(); - } + int? beatmapMaxCombo = await GetMaximumAchievableComboAsync(score, cancellationToken).ConfigureAwait(false); + if (beatmapMaxCombo == null) + return score.TotalScore; if (beatmapMaxCombo == 0) return 0; @@ -171,7 +145,37 @@ namespace osu.Game.Scoring var scoreProcessor = ruleset.CreateScoreProcessor(); scoreProcessor.Mods.Value = score.Mods; - return (long)Math.Round(scoreProcessor.ComputeFinalLegacyScore(mode, score, beatmapMaxCombo)); + return (long)Math.Round(scoreProcessor.ComputeFinalLegacyScore(mode, score, beatmapMaxCombo.Value)); + } + + /// + /// Retrieves the maximum achievable combo for the provided score. + /// + /// The to compute the maximum achievable combo for. + /// A to cancel the process. + /// The maximum achievable combo. A return value indicates the difficulty cache has failed to retrieve the combo. + public async Task GetMaximumAchievableComboAsync([NotNull] ScoreInfo score, CancellationToken cancellationToken = default) + { + if (score.IsLegacyScore) + { + // This score is guaranteed to be an osu!stable score. + // The combo must be determined through either the beatmap's max combo value or the difficulty calculator, as lazer's scoring has changed and the score statistics cannot be used. +#pragma warning disable CS0618 + if (score.BeatmapInfo.MaxCombo != null) + return score.BeatmapInfo.MaxCombo.Value; +#pragma warning restore CS0618 + + if (difficulties == null) + return null; + + // We can compute the max combo locally after the async beatmap difficulty computation. + var difficulty = await difficulties().GetDifficultyAsync(score.BeatmapInfo, score.Ruleset, score.Mods, cancellationToken).ConfigureAwait(false); + return difficulty?.MaxCombo; + } + + // This is guaranteed to be a non-legacy score. + // The combo must be determined through the score's statistics, as both the beatmap's max combo and the difficulty calculator will provide osu!stable combo values. + return Enum.GetValues(typeof(HitResult)).OfType().Where(r => r.AffectsCombo()).Select(r => score.Statistics.GetValueOrDefault(r)).Sum(); } /// diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index dcb7e3a282..57f7429e06 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -97,7 +97,7 @@ namespace osu.Game.Screens.Edit private bool canSave; - private bool exitConfirmed; + protected bool ExitConfirmed { get; private set; } private string lastSavedHash; @@ -586,7 +586,7 @@ namespace osu.Game.Screens.Edit public override bool OnExiting(IScreen next) { - if (!exitConfirmed) + if (!ExitConfirmed) { // dialog overlay may not be available in visual tests. if (dialogOverlay == null) @@ -595,12 +595,9 @@ namespace osu.Game.Screens.Edit return true; } - // if the dialog is already displayed, confirm exit with no save. - if (dialogOverlay.CurrentDialog is PromptForSaveDialog saveDialog) - { - saveDialog.PerformOkAction(); + // if the dialog is already displayed, block exiting until the user explicitly makes a decision. + if (dialogOverlay.CurrentDialog is PromptForSaveDialog) return true; - } if (isNewBeatmap || HasUnsavedChanges) { @@ -645,7 +642,7 @@ namespace osu.Game.Screens.Edit { Save(); - exitConfirmed = true; + ExitConfirmed = true; this.Exit(); } @@ -668,7 +665,7 @@ namespace osu.Game.Screens.Edit Beatmap.SetDefault(); } - exitConfirmed = true; + ExitConfirmed = true; this.Exit(); } diff --git a/osu.Game/Screens/Edit/PromptForSaveDialog.cs b/osu.Game/Screens/Edit/PromptForSaveDialog.cs index e308a9533d..4f70491ade 100644 --- a/osu.Game/Screens/Edit/PromptForSaveDialog.cs +++ b/osu.Game/Screens/Edit/PromptForSaveDialog.cs @@ -17,12 +17,12 @@ namespace osu.Game.Screens.Edit Buttons = new PopupDialogButton[] { - new PopupDialogCancelButton + new PopupDialogOkButton { Text = @"Save my masterpiece!", Action = saveAndExit }, - new PopupDialogOkButton + new PopupDialogDangerousButton { Text = @"Forget all changes", Action = exit diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs index eeafebfec0..879a21e7c1 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs @@ -7,7 +7,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; @@ -25,6 +24,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist /// public Action RequestEdit; + private MultiplayerPlaylistTabControl playlistTabControl; private MultiplayerQueueList queueList; private MultiplayerHistoryList historyList; private bool firstPopulation = true; @@ -36,7 +36,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist InternalChildren = new Drawable[] { - new OsuTabControl + playlistTabControl = new MultiplayerPlaylistTabControl { RelativeSizeAxes = Axes.X, Height = tab_control_height, @@ -64,6 +64,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist } } }; + + playlistTabControl.QueueItems.BindTarget = queueList.Items; } protected override void LoadComplete() diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs new file mode 100644 index 0000000000..583a05839f --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistTabControl.cs @@ -0,0 +1,39 @@ +// 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.Bindables; +using osu.Framework.Graphics.UserInterface; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Rooms; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist +{ + public class MultiplayerPlaylistTabControl : OsuTabControl + { + public readonly IBindableList QueueItems = new BindableList(); + + protected override TabItem CreateTabItem(MultiplayerPlaylistDisplayMode value) + { + if (value == MultiplayerPlaylistDisplayMode.Queue) + return new QueueTabItem { QueueItems = { BindTarget = QueueItems } }; + + return base.CreateTabItem(value); + } + + private class QueueTabItem : OsuTabItem + { + public readonly IBindableList QueueItems = new BindableList(); + + public QueueTabItem() + : base(MultiplayerPlaylistDisplayMode.Queue) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + QueueItems.BindCollectionChanged((_, __) => Text.Text = QueueItems.Count > 0 ? $"Queue ({QueueItems.Count})" : "Queue", true); + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs index bf1699dca0..c56d04d5ac 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs @@ -115,6 +115,8 @@ namespace osu.Game.Screens.OnlinePlay this.FadeIn(); waves.Show(); + Mods.SetDefault(); + if (loungeSubScreen.IsCurrentScreen()) loungeSubScreen.OnEntering(last); else diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs index 542731cf93..dca50c07ad 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs @@ -4,12 +4,16 @@ using System; using System.Linq; 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.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; using osuTK; @@ -19,11 +23,27 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters public class BarHitErrorMeter : HitErrorMeter { private const int judgement_line_width = 14; - private const int judgement_line_height = 4; + + [SettingSource("Judgement line thickness", "How thick the individual lines should be.")] + public BindableNumber JudgementLineThickness { get; } = new BindableNumber(4) + { + MinValue = 1, + MaxValue = 8, + Precision = 0.1f, + }; + + [SettingSource("Show moving average arrow", "Whether an arrow should move beneath the bar showing the average error.")] + public Bindable ShowMovingAverage { get; } = new BindableBool(true); + + [SettingSource("Centre marker style", "How to signify the centre of the display")] + public Bindable CentreMarkerStyle { get; } = new Bindable(CentreMarkerStyles.Circle); + + [SettingSource("Label style", "How to show early/late extremities")] + public Bindable LabelStyle { get; } = new Bindable(LabelStyles.Icons); private SpriteIcon arrow; - private SpriteIcon iconEarly; - private SpriteIcon iconLate; + private Drawable labelEarly; + private Drawable labelLate; private Container colourBarsEarly; private Container colourBarsLate; @@ -32,6 +52,18 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters private double maxHitWindow; + private double floatingAverage; + private Container colourBars; + private Container arrowContainer; + + private (HitResult result, double length)[] hitWindows; + + private const int max_concurrent_judgements = 50; + + private Drawable[] centreMarkerDrawables; + + private const int centre_marker_size = 8; + public BarHitErrorMeter() { AutoSizeAxes = Axes.Both; @@ -40,13 +72,11 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters [BackgroundDependencyLoader] private void load() { - const int centre_marker_size = 8; const int bar_height = 200; const int bar_width = 2; const float chevron_size = 8; - const float icon_size = 14; - var hitWindows = HitWindows.GetAllAvailableWindows().ToArray(); + hitWindows = HitWindows.GetAllAvailableWindows().ToArray(); InternalChild = new Container { @@ -65,22 +95,6 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters RelativeSizeAxes = Axes.Y, Children = new Drawable[] { - iconEarly = new SpriteIcon - { - Y = -10, - Size = new Vector2(icon_size), - Icon = FontAwesome.Solid.ShippingFast, - Anchor = Anchor.TopCentre, - Origin = Anchor.Centre, - }, - iconLate = new SpriteIcon - { - Y = 10, - Size = new Vector2(icon_size), - Icon = FontAwesome.Solid.Bicycle, - Anchor = Anchor.BottomCentre, - Origin = Anchor.Centre, - }, colourBarsEarly = new Container { Anchor = Anchor.Centre, @@ -98,14 +112,6 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters RelativeSizeAxes = Axes.Y, Height = 0.5f, }, - new Circle - { - Name = "middle marker behind", - Colour = GetColourForHitResult(hitWindows.Last().result), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(centre_marker_size), - }, judgementsContainer = new Container { Name = "judgements", @@ -114,24 +120,18 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters RelativeSizeAxes = Axes.Y, Width = judgement_line_width, }, - new Circle - { - Name = "middle marker in front", - Colour = GetColourForHitResult(hitWindows.Last().result).Darken(0.3f), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Size = new Vector2(centre_marker_size), - Scale = new Vector2(0.5f), - }, } }, - new Container + arrowContainer = new Container { Name = "average chevron", Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, + Origin = Anchor.CentreRight, Width = chevron_size, + X = chevron_size, RelativeSizeAxes = Axes.Y, + Alpha = 0, + Scale = new Vector2(0, 1), Child = arrow = new SpriteIcon { Anchor = Anchor.TopCentre, @@ -155,8 +155,180 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters colourBars.Height = 0; colourBars.ResizeHeightTo(1, 800, Easing.OutQuint); - arrow.Alpha = 0; - arrow.Delay(200).FadeInFromZero(600); + CentreMarkerStyle.BindValueChanged(style => recreateCentreMarker(style.NewValue), true); + LabelStyle.BindValueChanged(style => recreateLabels(style.NewValue), true); + + // delay the appearance animations for only the initial appearance. + using (arrowContainer.BeginDelayedSequence(450)) + { + ShowMovingAverage.BindValueChanged(visible => + { + arrowContainer.FadeTo(visible.NewValue ? 1 : 0, 250, Easing.OutQuint); + arrowContainer.ScaleTo(visible.NewValue ? new Vector2(1) : new Vector2(0, 1), 250, Easing.OutQuint); + }, true); + } + } + + private void recreateCentreMarker(CentreMarkerStyles style) + { + if (centreMarkerDrawables != null) + { + foreach (var d in centreMarkerDrawables) + { + d.ScaleTo(0, 500, Easing.OutQuint) + .FadeOut(500, Easing.OutQuint); + + d.Expire(); + } + + centreMarkerDrawables = null; + } + + switch (style) + { + case CentreMarkerStyles.None: + break; + + case CentreMarkerStyles.Circle: + centreMarkerDrawables = new Drawable[] + { + new Circle + { + Name = "middle marker behind", + Colour = GetColourForHitResult(hitWindows.Last().result), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Depth = float.MaxValue, + Size = new Vector2(centre_marker_size), + }, + new Circle + { + Name = "middle marker in front", + Colour = GetColourForHitResult(hitWindows.Last().result).Darken(0.3f), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Depth = float.MinValue, + Size = new Vector2(centre_marker_size / 2f), + }, + }; + break; + + case CentreMarkerStyles.Line: + const float border_size = 1.5f; + + centreMarkerDrawables = new Drawable[] + { + new Box + { + Name = "middle marker behind", + Colour = GetColourForHitResult(hitWindows.Last().result), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Depth = float.MaxValue, + Size = new Vector2(judgement_line_width, centre_marker_size / 3f), + }, + new Box + { + Name = "middle marker in front", + Colour = GetColourForHitResult(hitWindows.Last().result).Darken(0.3f), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Depth = float.MinValue, + Size = new Vector2(judgement_line_width - border_size, centre_marker_size / 3f - border_size), + }, + }; + break; + + default: + throw new ArgumentOutOfRangeException(nameof(style), style, null); + } + + if (centreMarkerDrawables != null) + { + foreach (var d in centreMarkerDrawables) + { + colourBars.Add(d); + + d.FadeInFromZero(500, Easing.OutQuint) + .ScaleTo(0).ScaleTo(1, 1000, Easing.OutElasticHalf); + } + } + } + + private void recreateLabels(LabelStyles style) + { + const float icon_size = 14; + + labelEarly?.Expire(); + labelEarly = null; + + labelLate?.Expire(); + labelLate = null; + + switch (style) + { + case LabelStyles.None: + break; + + case LabelStyles.Icons: + labelEarly = new SpriteIcon + { + Y = -10, + Size = new Vector2(icon_size), + Icon = FontAwesome.Solid.ShippingFast, + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + }; + + labelLate = new SpriteIcon + { + Y = 10, + Size = new Vector2(icon_size), + Icon = FontAwesome.Solid.Bicycle, + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + }; + + break; + + case LabelStyles.Text: + labelEarly = new OsuSpriteText + { + Y = -10, + Text = "Early", + Font = OsuFont.Default.With(size: 10), + Height = 12, + Anchor = Anchor.TopCentre, + Origin = Anchor.Centre, + }; + + labelLate = new OsuSpriteText + { + Y = 10, + Text = "Late", + Font = OsuFont.Default.With(size: 10), + Height = 12, + Anchor = Anchor.BottomCentre, + Origin = Anchor.Centre, + }; + + break; + + default: + throw new ArgumentOutOfRangeException(nameof(style), style, null); + } + + if (labelEarly != null) + { + colourBars.Add(labelEarly); + labelEarly.FadeInFromZero(500); + } + + if (labelLate != null) + { + colourBars.Add(labelLate); + labelLate.FadeInFromZero(500); + } } protected override void Update() @@ -164,8 +336,8 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters base.Update(); // undo any layout rotation to display icons in the correct orientation - iconEarly.Rotation = -Rotation; - iconLate.Rotation = -Rotation; + if (labelEarly != null) labelEarly.Rotation = -Rotation; + if (labelLate != null) labelLate.Rotation = -Rotation; } private void createColourBars((HitResult result, double length)[] windows) @@ -224,11 +396,6 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters } } - private double floatingAverage; - private Container colourBars; - - private const int max_concurrent_judgements = 50; - protected override void OnNewJudgement(JudgementResult judgement) { const int arrow_move_duration = 800; @@ -255,6 +422,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters judgementsContainer.Add(new JudgementLine { + JudgementLineThickness = { BindTarget = JudgementLineThickness }, Y = getRelativeJudgementPosition(judgement.TimeOffset), Colour = GetColourForHitResult(judgement.Type), }); @@ -268,11 +436,12 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters internal class JudgementLine : CompositeDrawable { + public readonly BindableNumber JudgementLineThickness = new BindableFloat(); + public JudgementLine() { RelativeSizeAxes = Axes.X; RelativePositionAxes = Axes.Y; - Height = judgement_line_height; Blending = BlendingParameters.Additive; @@ -295,6 +464,8 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters Alpha = 0; Width = 0; + JudgementLineThickness.BindValueChanged(thickness => Height = thickness.NewValue, true); + this .FadeTo(0.6f, judgement_fade_in_duration, Easing.OutQuint) .ResizeWidthTo(1, judgement_fade_in_duration, Easing.OutQuint) @@ -306,5 +477,19 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters } public override void Clear() => judgementsContainer.Clear(); + + public enum CentreMarkerStyles + { + None, + Circle, + Line + } + + public enum LabelStyles + { + None, + Icons, + Text + } } } diff --git a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs index 4087011933..3da63ec2cc 100644 --- a/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs +++ b/osu.Game/Screens/Play/HUD/HoldForMenuButton.cs @@ -84,7 +84,7 @@ namespace osu.Game.Screens.Play.HUD protected override bool OnMouseMove(MouseMoveEvent e) { - positionalAdjust = Vector2.Distance(e.ScreenSpaceMousePosition, button.ScreenSpaceDrawQuad.Centre) / 200; + positionalAdjust = Vector2.Distance(e.MousePosition, button.ToSpaceOfOtherDrawable(button.DrawRectangle.Centre, Parent)) / 100; return base.OnMouseMove(e); } diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index cb8f4b6020..73bdeb5783 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -615,16 +615,22 @@ namespace osu.Game.Screens.Play /// The destination time to seek to. internal void NonFrameStableSeek(double time) { - if (frameStablePlaybackResetDelegate?.Cancelled == false && !frameStablePlaybackResetDelegate.Completed) - frameStablePlaybackResetDelegate.RunTask(); + // TODO: This schedule should not be required and is a temporary hotfix. + // See https://github.com/ppy/osu/issues/17267 for the issue. + // See https://github.com/ppy/osu/pull/17302 for a better fix which needs some more time. + ScheduleAfterChildren(() => + { + if (frameStablePlaybackResetDelegate?.Cancelled == false && !frameStablePlaybackResetDelegate.Completed) + frameStablePlaybackResetDelegate.RunTask(); - bool wasFrameStable = DrawableRuleset.FrameStablePlayback; - DrawableRuleset.FrameStablePlayback = false; + bool wasFrameStable = DrawableRuleset.FrameStablePlayback; + DrawableRuleset.FrameStablePlayback = false; - Seek(time); + Seek(time); - // Delay resetting frame-stable playback for one frame to give the FrameStabilityContainer a chance to seek. - frameStablePlaybackResetDelegate = ScheduleAfterChildren(() => DrawableRuleset.FrameStablePlayback = wasFrameStable); + // Delay resetting frame-stable playback for one frame to give the FrameStabilityContainer a chance to seek. + frameStablePlaybackResetDelegate = ScheduleAfterChildren(() => DrawableRuleset.FrameStablePlayback = wasFrameStable); + }); } /// diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 41eb822e39..ba720af2a1 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -1,10 +1,11 @@ // 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; using System.Diagnostics; using System.Threading.Tasks; -using JetBrains.Annotations; using ManagedBass.Fx; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -48,31 +49,31 @@ namespace osu.Game.Screens.Play public override bool HandlePositionalInput => true; // We show the previous screen status - protected override UserActivity InitialActivity => null; + protected override UserActivity? InitialActivity => null; protected override bool PlayResumeSound => false; - protected BeatmapMetadataDisplay MetadataInfo { get; private set; } + protected BeatmapMetadataDisplay MetadataInfo { get; private set; } = null!; /// /// A fill flow containing the player settings groups, exposed for the ability to hide it from inheritors of the player loader. /// - protected FillFlowContainer PlayerSettings { get; private set; } + protected FillFlowContainer PlayerSettings { get; private set; } = null!; - protected VisualSettings VisualSettings { get; private set; } + protected VisualSettings VisualSettings { get; private set; } = null!; - protected AudioSettings AudioSettings { get; private set; } + protected AudioSettings AudioSettings { get; private set; } = null!; - protected Task LoadTask { get; private set; } + protected Task? LoadTask { get; private set; } - protected Task DisposalTask { get; private set; } + protected Task? DisposalTask { get; private set; } private bool backgroundBrightnessReduction; private readonly BindableDouble volumeAdjustment = new BindableDouble(1); - private AudioFilter lowPassFilter; - private AudioFilter highPassFilter; + private AudioFilter lowPassFilter = null!; + private AudioFilter highPassFilter = null!; protected bool BackgroundBrightnessReduction { @@ -90,47 +91,49 @@ namespace osu.Game.Screens.Play private bool readyForPush => !playerConsumed // don't push unless the player is completely loaded - && player?.LoadState == LoadState.Ready + && CurrentPlayer?.LoadState == LoadState.Ready // don't push if the user is hovering one of the panes, unless they are idle. && (IsHovered || idleTracker.IsIdle.Value) // don't push if the user is dragging a slider or otherwise. - && inputManager?.DraggedDrawable == null + && inputManager.DraggedDrawable == null // don't push if a focused overlay is visible, like settings. - && inputManager?.FocusedDrawable == null; + && inputManager.FocusedDrawable == null; private readonly Func createPlayer; - private Player player; + /// + /// The instance being loaded by this screen. + /// + public Player? CurrentPlayer { get; private set; } /// - /// Whether the curent player instance has been consumed via . + /// Whether the current player instance has been consumed via . /// private bool playerConsumed; - private LogoTrackingContainer content; + private LogoTrackingContainer content = null!; private bool hideOverlays; - private InputManager inputManager; + private InputManager inputManager = null!; - private IdleTracker idleTracker; + private IdleTracker idleTracker = null!; - private ScheduledDelegate scheduledPushPlayer; + private ScheduledDelegate? scheduledPushPlayer; - [CanBeNull] - private EpilepsyWarning epilepsyWarning; + private EpilepsyWarning? epilepsyWarning; [Resolved(CanBeNull = true)] - private NotificationOverlay notificationOverlay { get; set; } + private NotificationOverlay? notificationOverlay { get; set; } [Resolved(CanBeNull = true)] - private VolumeOverlay volumeOverlay { get; set; } + private VolumeOverlay? volumeOverlay { get; set; } [Resolved] - private AudioManager audioManager { get; set; } + private AudioManager audioManager { get; set; } = null!; [Resolved(CanBeNull = true)] - private BatteryInfo batteryInfo { get; set; } + private BatteryInfo? batteryInfo { get; set; } public PlayerLoader(Func createPlayer) { @@ -237,12 +240,14 @@ namespace osu.Game.Screens.Play { base.OnResuming(last); - var lastScore = player.Score; + Debug.Assert(CurrentPlayer != null); + + var lastScore = CurrentPlayer.Score; AudioSettings.ReferenceScore.Value = lastScore?.ScoreInfo; // prepare for a retry. - player = null; + CurrentPlayer = null; playerConsumed = false; cancelLoad(); @@ -344,9 +349,10 @@ namespace osu.Game.Screens.Play private Player consumePlayer() { Debug.Assert(!playerConsumed); + Debug.Assert(CurrentPlayer != null); playerConsumed = true; - return player; + return CurrentPlayer; } private void prepareNewPlayer() @@ -354,11 +360,11 @@ namespace osu.Game.Screens.Play if (!this.IsCurrentScreen()) return; - player = createPlayer(); - player.RestartCount = restartCount++; - player.RestartRequested = restartRequested; + CurrentPlayer = createPlayer(); + CurrentPlayer.RestartCount = restartCount++; + CurrentPlayer.RestartRequested = restartRequested; - LoadTask = LoadComponentAsync(player, _ => MetadataInfo.Loading = false); + LoadTask = LoadComponentAsync(CurrentPlayer, _ => MetadataInfo.Loading = false); } private void restartRequested() @@ -472,7 +478,7 @@ namespace osu.Game.Screens.Play if (isDisposing) { // if the player never got pushed, we should explicitly dispose it. - DisposalTask = LoadTask?.ContinueWith(_ => player?.Dispose()); + DisposalTask = LoadTask?.ContinueWith(_ => CurrentPlayer?.Dispose()); } } @@ -480,7 +486,7 @@ namespace osu.Game.Screens.Play #region Mute warning - private Bindable muteWarningShownOnce; + private Bindable muteWarningShownOnce = null!; private int restartCount; @@ -535,7 +541,7 @@ namespace osu.Game.Screens.Play #region Low battery warning - private Bindable batteryWarningShownOnce; + private Bindable batteryWarningShownOnce = null!; private void showBatteryWarningIfNeeded() { diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs index 7e39708e65..5b3129dad6 100644 --- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs @@ -65,10 +65,12 @@ namespace osu.Game.Screens.Ranking.Expanded var metadata = beatmap.BeatmapSet?.Metadata ?? beatmap.Metadata; string creator = metadata.Author.Username; + int? beatmapMaxCombo = scoreManager.GetMaximumAchievableComboAsync(score).GetResultSafely(); + var topStatistics = new List { new AccuracyStatistic(score.Accuracy), - new ComboStatistic(score.MaxCombo, !score.Statistics.TryGetValue(HitResult.Miss, out int missCount) || missCount == 0), + new ComboStatistic(score.MaxCombo, beatmapMaxCombo), new PerformanceStatistic(score), }; @@ -80,8 +82,6 @@ namespace osu.Game.Screens.Ranking.Expanded statisticDisplays.AddRange(topStatistics); statisticDisplays.AddRange(bottomStatistics); - var starDifficulty = beatmapDifficultyCache.GetDifficultyAsync(beatmap, score.Ruleset, score.Mods).GetResultSafely(); - AddInternal(new FillFlowContainer { RelativeSizeAxes = Axes.Both, @@ -224,6 +224,8 @@ namespace osu.Game.Screens.Ranking.Expanded if (score.Date != default) AddInternal(new PlayedOnText(score.Date)); + var starDifficulty = beatmapDifficultyCache.GetDifficultyAsync(beatmap, score.Ruleset, score.Mods).GetResultSafely(); + if (starDifficulty != null) { starAndModDisplay.Add(new StarRatingDisplay(starDifficulty.Value) diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs index b92c244174..0e42ec026a 100644 --- a/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/ComboStatistic.cs @@ -25,11 +25,11 @@ namespace osu.Game.Screens.Ranking.Expanded.Statistics /// Creates a new . /// /// The combo to be displayed. - /// Whether this is a perfect combo. - public ComboStatistic(int combo, bool isPerfect) - : base("combo", combo) + /// The maximum value of . + public ComboStatistic(int combo, int? maxCombo) + : base("combo", combo, maxCombo) { - this.isPerfect = isPerfect; + isPerfect = combo == maxCombo; } public override void Appear() diff --git a/osu.Game/Skinning/Editor/SkinComponentToolbox.cs b/osu.Game/Skinning/Editor/SkinComponentToolbox.cs index a586cdcd54..756f229927 100644 --- a/osu.Game/Skinning/Editor/SkinComponentToolbox.cs +++ b/osu.Game/Skinning/Editor/SkinComponentToolbox.cs @@ -51,7 +51,7 @@ namespace osu.Game.Skinning.Editor fill.Clear(); var skinnableTypes = typeof(OsuGame).Assembly.GetTypes() - .Where(t => !t.IsInterface) + .Where(t => !t.IsInterface && !t.IsAbstract) .Where(t => typeof(ISkinnableDrawable).IsAssignableFrom(t)) .OrderBy(t => t.Name) .ToArray(); diff --git a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs index 9fc233d3e3..497283a820 100644 --- a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs +++ b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs @@ -19,15 +19,15 @@ namespace osu.Game.Skinning.Editor /// A container which handles loading a skin editor on user request for a specified target. /// This also handles the scaling / positioning adjustment of the target. /// - public class SkinEditorOverlay : CompositeDrawable, IKeyBindingHandler + public class SkinEditorOverlay : OverlayContainer, IKeyBindingHandler { private readonly ScalingContainer scalingContainer; + protected override bool BlockNonPositionalInput => true; + [CanBeNull] private SkinEditor skinEditor; - public const float VISIBLE_TARGET_SCALE = 0.8f; - [Resolved(canBeNull: true)] private OsuGame game { get; set; } @@ -49,33 +49,13 @@ namespace osu.Game.Skinning.Editor Hide(); return true; - - case GlobalAction.ToggleSkinEditor: - Toggle(); - return true; } return false; } - public void Toggle() + protected override void PopIn() { - if (skinEditor == null) - Show(); - else - skinEditor.ToggleVisibility(); - } - - public override void Hide() - { - // base call intentionally omitted. - skinEditor?.Hide(); - } - - public override void Show() - { - // base call intentionally omitted as we have custom behaviour. - if (skinEditor != null) { skinEditor.Show(); @@ -83,29 +63,24 @@ namespace osu.Game.Skinning.Editor } var editor = new SkinEditor(); + editor.State.BindValueChanged(visibility => updateComponentVisibility()); skinEditor = editor; - // Schedule ensures that if `Show` is called before this overlay is loaded, - // it will not throw (LoadComponentAsync requires the load target to be in a loaded state). - Schedule(() => + LoadComponentAsync(editor, _ => { if (editor != skinEditor) return; - LoadComponentAsync(editor, _ => - { - if (editor != skinEditor) - return; + AddInternal(editor); - AddInternal(editor); - - SetTarget(lastTargetScreen); - }); + SetTarget(lastTargetScreen); }); } + protected override void PopOut() => skinEditor?.Hide(); + private void updateComponentVisibility() { Debug.Assert(skinEditor != null); diff --git a/osu.Game/Skinning/Editor/SkinEditorSceneLibrary.cs b/osu.Game/Skinning/Editor/SkinEditorSceneLibrary.cs index 5da6147e4c..d126eff075 100644 --- a/osu.Game/Skinning/Editor/SkinEditorSceneLibrary.cs +++ b/osu.Game/Skinning/Editor/SkinEditorSceneLibrary.cs @@ -94,7 +94,7 @@ namespace osu.Game.Skinning.Editor var replayGeneratingMod = ruleset.Value.CreateInstance().GetAutoplayMod(); if (replayGeneratingMod != null) - screen.Push(new ReplayPlayer((beatmap, mods) => replayGeneratingMod.CreateReplayScore(beatmap, mods))); + screen.Push(new PlayerLoader(() => new ReplayPlayer((beatmap, mods) => replayGeneratingMod.CreateReplayScore(beatmap, mods)))); }, new[] { typeof(Player), typeof(SongSelect) }) }, } @@ -104,7 +104,7 @@ namespace osu.Game.Skinning.Editor }; } - private class SceneButton : OsuButton + public class SceneButton : OsuButton { public SceneButton() { diff --git a/osu.Game/Stores/BeatmapImporter.cs b/osu.Game/Stores/BeatmapImporter.cs index e6b655589c..f04a0210ef 100644 --- a/osu.Game/Stores/BeatmapImporter.cs +++ b/osu.Game/Stores/BeatmapImporter.cs @@ -163,6 +163,12 @@ namespace osu.Game.Stores return existing.OnlineID == import.OnlineID && existingIds.SequenceEqual(importIds); } + protected override void UndeleteForReuse(BeatmapSetInfo existing) + { + base.UndeleteForReuse(existing); + existing.DateAdded = DateTimeOffset.UtcNow; + } + public override bool IsAvailableLocally(BeatmapSetInfo model) { return Realm.Run(realm => realm.All().Any(s => s.OnlineID == model.OnlineID)); diff --git a/osu.Game/Stores/RealmArchiveModelImporter.cs b/osu.Game/Stores/RealmArchiveModelImporter.cs index 3011bc0320..1d0e16d549 100644 --- a/osu.Game/Stores/RealmArchiveModelImporter.cs +++ b/osu.Game/Stores/RealmArchiveModelImporter.cs @@ -351,7 +351,8 @@ namespace osu.Game.Stores using (var transaction = realm.BeginWrite()) { - existing.DeletePending = false; + if (existing.DeletePending) + UndeleteForReuse(existing); transaction.Commit(); } @@ -387,7 +388,9 @@ namespace osu.Game.Stores { LogForModel(item, @$"Found existing {HumanisedModelName} for {item} (ID {existing.ID}) – skipping import."); - existing.DeletePending = false; + if (existing.DeletePending) + UndeleteForReuse(existing); + transaction.Commit(); return existing.ToLive(Realm); @@ -527,6 +530,15 @@ namespace osu.Game.Stores private bool checkAllFilesExist(TModel model) => model.Files.All(f => Files.Storage.Exists(f.File.GetStoragePath())); + /// + /// Called when an existing model is in a soft deleted state but being recovered. + /// + /// The existing model. + protected virtual void UndeleteForReuse(TModel existing) + { + existing.DeletePending = false; + } + /// /// Whether this specified path should be removed after successful import. /// diff --git a/osu.Game/Tests/Visual/EditorTestScene.cs b/osu.Game/Tests/Visual/EditorTestScene.cs index 24015590e2..51221cb8fe 100644 --- a/osu.Game/Tests/Visual/EditorTestScene.cs +++ b/osu.Game/Tests/Visual/EditorTestScene.cs @@ -7,10 +7,13 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.IO.Stores; using osu.Framework.Platform; +using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Online.API; +using osu.Game.Overlays; +using osu.Game.Overlays.Dialog; using osu.Game.Rulesets; using osu.Game.Rulesets.Edit; using osu.Game.Screens.Edit; @@ -93,6 +96,10 @@ namespace osu.Game.Tests.Visual protected class TestEditor : Editor { + [Resolved(canBeNull: true)] + [CanBeNull] + private DialogOverlay dialogOverlay { get; set; } + public new void Undo() => base.Undo(); public new void Redo() => base.Redo(); @@ -111,6 +118,18 @@ namespace osu.Game.Tests.Visual public new bool HasUnsavedChanges => base.HasUnsavedChanges; + public override bool OnExiting(IScreen next) + { + // For testing purposes allow the screen to exit without saving on second attempt. + if (!ExitConfirmed && dialogOverlay?.CurrentDialog is PromptForSaveDialog saveDialog) + { + saveDialog.PerformAction(); + return true; + } + + return base.OnExiting(next); + } + public TestEditor(EditorLoader loader = null) : base(loader) { diff --git a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs index f5da95bd7b..ac7cb43e02 100644 --- a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs +++ b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs @@ -88,7 +88,8 @@ namespace osu.Game.Tests.Visual.Spectator /// /// The user to send frames for. /// The total number of frames to send. - public void SendFramesFromUser(int userId, int count) + /// The time to start gameplay frames from. + public void SendFramesFromUser(int userId, int count, double startTime = 0) { var frames = new List(); @@ -102,7 +103,7 @@ namespace osu.Game.Tests.Visual.Spectator flush(); var buttonState = currentFrameIndex == lastFrameIndex ? ReplayButtonState.None : ReplayButtonState.Left1; - frames.Add(new LegacyReplayFrame(currentFrameIndex * 100, RNG.Next(0, 512), RNG.Next(0, 512), buttonState)); + frames.Add(new LegacyReplayFrame(currentFrameIndex * 100 + startTime, RNG.Next(0, 512), RNG.Next(0, 512), buttonState)); } flush(); diff --git a/osu.Game/Updater/SimpleUpdateManager.cs b/osu.Game/Updater/SimpleUpdateManager.cs index 61ca68a1ab..c57a7c768e 100644 --- a/osu.Game/Updater/SimpleUpdateManager.cs +++ b/osu.Game/Updater/SimpleUpdateManager.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using System.Runtime.InteropServices; using System.Threading.Tasks; using osu.Framework; using osu.Framework.Allocation; @@ -82,7 +83,8 @@ namespace osu.Game.Updater break; case RuntimeInfo.Platform.macOS: - bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".app.zip", StringComparison.Ordinal)); + string arch = RuntimeInformation.OSArchitecture == Architecture.Arm64 ? "Apple.Silicon" : "Intel"; + bestAsset = release.Assets?.Find(f => f.Name.EndsWith($".app.{arch}.zip", StringComparison.Ordinal)); break; case RuntimeInfo.Platform.Linux: diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 5e194e2aca..1c1deaae8e 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -31,7 +31,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive