diff --git a/osu.Android.props b/osu.Android.props index 219912425f..2c186a52dd 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -52,7 +52,7 @@ - + diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneCatchTouchInput.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchTouchInput.cs new file mode 100644 index 0000000000..cbf6e8f202 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneCatchTouchInput.cs @@ -0,0 +1,43 @@ +// 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 osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Catch.Tests +{ + [TestFixture] + public class TestSceneCatchTouchInput : OsuTestScene + { + private CatchTouchInputMapper catchTouchInputMapper = null!; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create input overlay", () => + { + Child = new CatchInputManager(new CatchRuleset().RulesetInfo) + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + catchTouchInputMapper = new CatchTouchInputMapper + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + } + } + }; + }); + } + + [Test] + public void TestBasic() + { + AddStep("show overlay", () => catchTouchInputMapper.Show()); + } + } +} diff --git a/osu.Game.Rulesets.Catch/CatchInputManager.cs b/osu.Game.Rulesets.Catch/CatchInputManager.cs index 86f35c8cee..5b62154a34 100644 --- a/osu.Game.Rulesets.Catch/CatchInputManager.cs +++ b/osu.Game.Rulesets.Catch/CatchInputManager.cs @@ -4,11 +4,13 @@ #nullable disable using System.ComponentModel; +using osu.Framework.Allocation; using osu.Framework.Input.Bindings; using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Catch { + [Cached] public class CatchInputManager : RulesetInputManager { public CatchInputManager(RulesetInfo ruleset) diff --git a/osu.Game.Rulesets.Catch/UI/CatchTouchInputMapper.cs b/osu.Game.Rulesets.Catch/UI/CatchTouchInputMapper.cs new file mode 100644 index 0000000000..e6736d6c93 --- /dev/null +++ b/osu.Game.Rulesets.Catch/UI/CatchTouchInputMapper.cs @@ -0,0 +1,277 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Graphics; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Rulesets.Catch.UI +{ + public class CatchTouchInputMapper : VisibilityContainer + { + public override bool PropagatePositionalInputSubTree => true; + public override bool PropagateNonPositionalInputSubTree => true; + + private readonly Dictionary trackedActionSources = new Dictionary(); + + private KeyBindingContainer keyBindingContainer = null!; + + private Container mainContent = null!; + + private InputArea leftBox = null!; + private InputArea rightBox = null!; + private InputArea leftDashBox = null!; + private InputArea rightDashBox = null!; + + [BackgroundDependencyLoader] + private void load(CatchInputManager catchInputManager, OsuColour colours) + { + const float width = 0.15f; + + keyBindingContainer = catchInputManager.KeyBindingContainer; + + RelativeSizeAxes = Axes.Both; + + Children = new Drawable[] + { + mainContent = new Container + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Width = width, + Children = new Drawable[] + { + leftDashBox = new InputArea(TouchCatchAction.DashLeft, trackedActionSources) + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + }, + leftBox = new InputArea(TouchCatchAction.MoveLeft, trackedActionSources) + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Colour = colours.Gray9, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }, + } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Width = width, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Children = new Drawable[] + { + rightBox = new InputArea(TouchCatchAction.MoveRight, trackedActionSources) + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Colour = colours.Gray9, + }, + rightDashBox = new InputArea(TouchCatchAction.DashRight, trackedActionSources) + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + }, + } + }, + }, + }, + }; + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + // Hide whenever the keyboard is used. + Hide(); + return false; + } + + protected override bool OnMouseDown(MouseDownEvent e) + { + return updateAction(e.Button, getTouchCatchActionFromInput(e.ScreenSpaceMousePosition)); + } + + protected override bool OnTouchDown(TouchDownEvent e) + { + return updateAction(e.Touch.Source, getTouchCatchActionFromInput(e.ScreenSpaceTouch.Position)); + } + + protected override bool OnMouseMove(MouseMoveEvent e) + { + Show(); + + TouchCatchAction? action = getTouchCatchActionFromInput(e.ScreenSpaceMousePosition); + + // multiple mouse buttons may be pressed and handling the same action. + foreach (MouseButton button in e.PressedButtons) + updateAction(button, action); + + return false; + } + + protected override void OnTouchMove(TouchMoveEvent e) + { + updateAction(e.Touch.Source, getTouchCatchActionFromInput(e.ScreenSpaceTouch.Position)); + base.OnTouchMove(e); + } + + protected override void OnMouseUp(MouseUpEvent e) + { + updateAction(e.Button, null); + base.OnMouseUp(e); + } + + protected override void OnTouchUp(TouchUpEvent e) + { + updateAction(e.Touch.Source, null); + base.OnTouchUp(e); + } + + private bool updateAction(object source, TouchCatchAction? newAction) + { + TouchCatchAction? actionBefore = null; + + if (trackedActionSources.TryGetValue(source, out TouchCatchAction found)) + actionBefore = found; + + if (actionBefore != newAction) + { + if (newAction != null) + trackedActionSources[source] = newAction.Value; + else + trackedActionSources.Remove(source); + + updatePressedActions(); + } + + return newAction != null; + } + + private void updatePressedActions() + { + Show(); + + if (trackedActionSources.ContainsValue(TouchCatchAction.DashLeft) || trackedActionSources.ContainsValue(TouchCatchAction.MoveLeft)) + keyBindingContainer.TriggerPressed(CatchAction.MoveLeft); + else + keyBindingContainer.TriggerReleased(CatchAction.MoveLeft); + + if (trackedActionSources.ContainsValue(TouchCatchAction.DashRight) || trackedActionSources.ContainsValue(TouchCatchAction.MoveRight)) + keyBindingContainer.TriggerPressed(CatchAction.MoveRight); + else + keyBindingContainer.TriggerReleased(CatchAction.MoveRight); + + if (trackedActionSources.ContainsValue(TouchCatchAction.DashLeft) || trackedActionSources.ContainsValue(TouchCatchAction.DashRight)) + keyBindingContainer.TriggerPressed(CatchAction.Dash); + else + keyBindingContainer.TriggerReleased(CatchAction.Dash); + } + + private TouchCatchAction? getTouchCatchActionFromInput(Vector2 screenSpaceInputPosition) + { + if (leftDashBox.Contains(screenSpaceInputPosition)) + return TouchCatchAction.DashLeft; + if (rightDashBox.Contains(screenSpaceInputPosition)) + return TouchCatchAction.DashRight; + if (leftBox.Contains(screenSpaceInputPosition)) + return TouchCatchAction.MoveLeft; + if (rightBox.Contains(screenSpaceInputPosition)) + return TouchCatchAction.MoveRight; + + return null; + } + + protected override void PopIn() => mainContent.FadeIn(300, Easing.OutQuint); + + protected override void PopOut() => mainContent.FadeOut(300, Easing.OutQuint); + + private class InputArea : CompositeDrawable, IKeyBindingHandler + { + private readonly TouchCatchAction handledAction; + + private readonly Box highlightOverlay; + + private readonly IEnumerable> trackedActions; + + private bool isHighlighted; + + public InputArea(TouchCatchAction handledAction, IEnumerable> trackedActions) + { + this.handledAction = handledAction; + this.trackedActions = trackedActions; + + InternalChildren = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Both, + Masking = true, + CornerRadius = 10, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0.15f, + }, + highlightOverlay = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Blending = BlendingParameters.Additive, + } + } + } + }; + } + + public bool OnPressed(KeyBindingPressEvent _) + { + updateHighlight(); + return false; + } + + public void OnReleased(KeyBindingReleaseEvent _) + { + updateHighlight(); + } + + private void updateHighlight() + { + bool isHandling = trackedActions.Any(a => a.Value == handledAction); + + if (isHandling == isHighlighted) + return; + + isHighlighted = isHandling; + highlightOverlay.FadeTo(isHighlighted ? 0.1f : 0, isHighlighted ? 80 : 400, Easing.OutQuint); + } + } + + public enum TouchCatchAction + { + MoveLeft, + MoveRight, + DashLeft, + DashRight, + } + } +} diff --git a/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs b/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs index b6cea92173..ef2936ac94 100644 --- a/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/UI/DrawableCatchRuleset.cs @@ -4,6 +4,7 @@ #nullable disable using System.Collections.Generic; +using osu.Framework.Allocation; using osu.Framework.Input; using osu.Game.Beatmaps; using osu.Game.Configuration; @@ -32,6 +33,12 @@ namespace osu.Game.Rulesets.Catch.UI TimeRange.Value = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450); } + [BackgroundDependencyLoader] + private void load() + { + KeyBindingInputManager.Add(new CatchTouchInputMapper()); + } + protected override ReplayInputHandler CreateReplayInputHandler(Replay replay) => new CatchFramedReplayInputHandler(replay); protected override ReplayRecorder CreateReplayRecorder(Score score) => new CatchReplayRecorder(score, (CatchPlayfield)Playfield); diff --git a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs index 35ad21442e..64e1f75e2c 100644 --- a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs +++ b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using osu.Framework.Configuration.Tracking; using osu.Game.Configuration; @@ -33,7 +31,7 @@ namespace osu.Game.Rulesets.Mania.Configuration scrollTime => new SettingDescription( rawValue: scrollTime, name: "Scroll Speed", - value: $"{(int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / scrollTime)} ({scrollTime}ms)" + value: $"{scrollTime}ms (speed {(int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / scrollTime)})" ) ) }; diff --git a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs index 43d4aa77a2..e239068d1d 100644 --- a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs +++ b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs @@ -1,8 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; @@ -34,7 +33,7 @@ namespace osu.Game.Rulesets.Mania LabelText = "Scrolling direction", Current = config.GetBindable(ManiaRulesetSetting.ScrollDirection) }, - new SettingsSlider + new SettingsSlider { LabelText = "Scroll speed", Current = config.GetBindable(ManiaRulesetSetting.ScrollTime), @@ -47,5 +46,10 @@ namespace osu.Game.Rulesets.Mania } }; } + + private class ManiaScrollSlider : OsuSliderBar + { + public override LocalisableString TooltipText => $"{Current.Value}ms (speed {(int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / Current.Value)})"; + } } } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs index 96c02a508b..056a325dce 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using osu.Framework.Localisation; using osu.Framework.Utils; @@ -9,6 +10,7 @@ using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Osu.Utils; @@ -25,40 +27,100 @@ namespace osu.Game.Rulesets.Osu.Mods private static readonly float playfield_diagonal = OsuPlayfield.BASE_SIZE.LengthFast; - private Random? rng; + private Random random = null!; public void ApplyToBeatmap(IBeatmap beatmap) { - if (!(beatmap is OsuBeatmap osuBeatmap)) + if (beatmap is not OsuBeatmap osuBeatmap) return; Seed.Value ??= RNG.Next(); - rng = new Random((int)Seed.Value); + random = new Random((int)Seed.Value); var positionInfos = OsuHitObjectGenerationUtils.GeneratePositionInfos(osuBeatmap.HitObjects); - float rateOfChangeMultiplier = 0; + // Offsets the angles of all hit objects in a "section" by the same amount. + float sectionOffset = 0; - foreach (var positionInfo in positionInfos) + // Whether the angles are positive or negative (clockwise or counter-clockwise flow). + bool flowDirection = false; + + for (int i = 0; i < positionInfos.Count; i++) { - // rateOfChangeMultiplier only changes every 5 iterations in a combo - // to prevent shaky-line-shaped streams - if (positionInfo.HitObject.IndexInCurrentCombo % 5 == 0) - rateOfChangeMultiplier = (float)rng.NextDouble() * 2 - 1; - - if (positionInfo == positionInfos.First()) + if (shouldStartNewSection(osuBeatmap, positionInfos, i)) { - positionInfo.DistanceFromPrevious = (float)(rng.NextDouble() * OsuPlayfield.BASE_SIZE.Y / 2); - positionInfo.RelativeAngle = (float)(rng.NextDouble() * 2 * Math.PI - Math.PI); + sectionOffset = OsuHitObjectGenerationUtils.RandomGaussian(random, 0, 0.0008f); + flowDirection = !flowDirection; + } + + if (i == 0) + { + positionInfos[i].DistanceFromPrevious = (float)(random.NextDouble() * OsuPlayfield.BASE_SIZE.Y / 2); + positionInfos[i].RelativeAngle = (float)(random.NextDouble() * 2 * Math.PI - Math.PI); } else { - positionInfo.RelativeAngle = rateOfChangeMultiplier * 2 * (float)Math.PI * Math.Min(1f, positionInfo.DistanceFromPrevious / (playfield_diagonal * 0.5f)); + // Offsets only the angle of the current hit object if a flow change occurs. + float flowChangeOffset = 0; + + // Offsets only the angle of the current hit object. + float oneTimeOffset = OsuHitObjectGenerationUtils.RandomGaussian(random, 0, 0.002f); + + if (shouldApplyFlowChange(positionInfos, i)) + { + flowChangeOffset = OsuHitObjectGenerationUtils.RandomGaussian(random, 0, 0.002f); + flowDirection = !flowDirection; + } + + float totalOffset = + // sectionOffset and oneTimeOffset should mainly affect patterns with large spacing. + (sectionOffset + oneTimeOffset) * positionInfos[i].DistanceFromPrevious + + // flowChangeOffset should mainly affect streams. + flowChangeOffset * (playfield_diagonal - positionInfos[i].DistanceFromPrevious); + + positionInfos[i].RelativeAngle = getRelativeTargetAngle(positionInfos[i].DistanceFromPrevious, totalOffset, flowDirection); } } osuBeatmap.HitObjects = OsuHitObjectGenerationUtils.RepositionHitObjects(positionInfos); } + + /// The target distance between the previous and the current . + /// The angle (in rad) by which the target angle should be offset. + /// Whether the relative angle should be positive or negative. + private static float getRelativeTargetAngle(float targetDistance, float offset, bool flowDirection) + { + float angle = (float)(2.16 / (1 + 200 * Math.Exp(0.036 * (targetDistance - 310))) + 0.5 + offset); + float relativeAngle = (float)Math.PI - angle; + return flowDirection ? -relativeAngle : relativeAngle; + } + + /// Whether a new section should be started at the current . + private bool shouldStartNewSection(OsuBeatmap beatmap, IReadOnlyList positionInfos, int i) + { + if (i == 0) + return true; + + // Exclude new-combo-spam and 1-2-combos. + bool previousObjectStartedCombo = positionInfos[Math.Max(0, i - 2)].HitObject.IndexInCurrentCombo > 1 && + positionInfos[i - 1].HitObject.NewCombo; + bool previousObjectWasOnDownbeat = OsuHitObjectGenerationUtils.IsHitObjectOnBeat(beatmap, positionInfos[i - 1].HitObject, true); + bool previousObjectWasOnBeat = OsuHitObjectGenerationUtils.IsHitObjectOnBeat(beatmap, positionInfos[i - 1].HitObject); + + return (previousObjectStartedCombo && random.NextDouble() < 0.6f) || + previousObjectWasOnDownbeat || + (previousObjectWasOnBeat && random.NextDouble() < 0.4f); + } + + /// Whether a flow change should be applied at the current . + private bool shouldApplyFlowChange(IReadOnlyList positionInfos, int i) + { + // Exclude new-combo-spam and 1-2-combos. + bool previousObjectStartedCombo = positionInfos[Math.Max(0, i - 2)].HitObject.IndexInCurrentCombo > 1 && + positionInfos[i - 1].HitObject.NewCombo; + + return previousObjectStartedCombo && random.NextDouble() < 0.6f; + } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs index 554ea3ac90..97cebc3123 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Default/SpinnerRotationTracker.cs @@ -110,7 +110,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default currentRotation += angle; // rate has to be applied each frame, because it's not guaranteed to be constant throughout playback // (see: ModTimeRamp) - drawableSpinner.Result.RateAdjustedRotation += (float)(Math.Abs(angle) * (gameplayClock?.TrueGameplayRate ?? Clock.Rate)); + drawableSpinner.Result.RateAdjustedRotation += (float)(Math.Abs(angle) * (gameplayClock?.GetTrueGameplayRate() ?? Clock.Rate)); } private void resetState(DrawableHitObject obj) diff --git a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs index 5e827d4782..3a8b3f67d0 100644 --- a/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs +++ b/osu.Game.Rulesets.Osu/Utils/OsuHitObjectGenerationUtils.cs @@ -8,6 +8,7 @@ using System.Linq; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Objects; using osuTK; @@ -186,5 +187,39 @@ namespace osu.Game.Rulesets.Osu.Utils length * MathF.Sin(angle) ); } + + /// The beatmap hitObject is a part of. + /// The that should be checked. + /// If true, this method only returns true if hitObject is on a downbeat. + /// If false, it returns true if hitObject is on any beat. + /// true if hitObject is on a (down-)beat, false otherwise. + public static bool IsHitObjectOnBeat(OsuBeatmap beatmap, OsuHitObject hitObject, bool downbeatsOnly = false) + { + var timingPoint = beatmap.ControlPointInfo.TimingPointAt(hitObject.StartTime); + + double timeSinceTimingPoint = hitObject.StartTime - timingPoint.Time; + + double beatLength = timingPoint.BeatLength; + + if (downbeatsOnly) + beatLength *= timingPoint.TimeSignature.Numerator; + + // Ensure within 1ms of expected location. + return Math.Abs(timeSinceTimingPoint + 1) % beatLength < 2; + } + + /// + /// Generates a random number from a normal distribution using the Box-Muller transform. + /// + public static float RandomGaussian(Random rng, float mean = 0, float stdDev = 1) + { + // Generate 2 random numbers in the interval (0,1]. + // x1 must not be 0 since log(0) = undefined. + double x1 = 1 - rng.NextDouble(); + double x2 = 1 - rng.NextDouble(); + + double stdNormal = Math.Sqrt(-2 * Math.Log(x1)) * Math.Sin(2 * Math.PI * x2); + return mean + stdDev * (float)stdNormal; + } } } diff --git a/osu.Game.Tests/NonVisual/GameplayClockContainerTest.cs b/osu.Game.Tests/NonVisual/GameplayClockContainerTest.cs index f9f4ead644..363c7a459e 100644 --- a/osu.Game.Tests/NonVisual/GameplayClockContainerTest.cs +++ b/osu.Game.Tests/NonVisual/GameplayClockContainerTest.cs @@ -1,8 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; using NUnit.Framework; +using osu.Framework.Audio; +using osu.Framework.Bindables; using osu.Framework.Timing; using osu.Game.Screens.Play; @@ -13,21 +14,20 @@ namespace osu.Game.Tests.NonVisual { [TestCase(0)] [TestCase(1)] - public void TestTrueGameplayRateWithZeroAdjustment(double underlyingClockRate) + public void TestTrueGameplayRateWithGameplayAdjustment(double underlyingClockRate) { var framedClock = new FramedClock(new ManualClock { Rate = underlyingClockRate }); var gameplayClock = new TestGameplayClockContainer(framedClock); - Assert.That(gameplayClock.TrueGameplayRate, Is.EqualTo(0)); + Assert.That(gameplayClock.GetTrueGameplayRate(), Is.EqualTo(2)); } private class TestGameplayClockContainer : GameplayClockContainer { - public override IEnumerable NonGameplayAdjustments => new[] { 0.0 }; - public TestGameplayClockContainer(IFrameBasedClock underlyingClock) : base(underlyingClock) { + AdjustmentsFromMods.AddAdjustment(AdjustableProperty.Frequency, new BindableDouble(2.0)); } } } diff --git a/osu.Game.Tests/Resources/Archives/modified-default-20220818.osk b/osu.Game.Tests/Resources/Archives/modified-default-20220818.osk new file mode 100644 index 0000000000..92215cbf86 Binary files /dev/null and b/osu.Game.Tests/Resources/Archives/modified-default-20220818.osk differ diff --git a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs index c7eb334f25..1b03f8ef6b 100644 --- a/osu.Game.Tests/Skins/SkinDeserialisationTest.cs +++ b/osu.Game.Tests/Skins/SkinDeserialisationTest.cs @@ -36,7 +36,9 @@ namespace osu.Game.Tests.Skins "Archives/modified-default-20220723.osk", "Archives/modified-classic-20220723.osk", // Covers legacy song progress, UR counter, colour hit error metre. - "Archives/modified-classic-20220801.osk" + "Archives/modified-classic-20220801.osk", + // Covers clicks/s counter + "Archives/modified-default-20220818.osk" }; /// diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs index 6bcc45d97e..f95e3b9990 100644 --- a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs +++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs @@ -15,12 +15,14 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Beatmaps.Drawables.Cards; +using osu.Game.Beatmaps.Drawables.Cards.Buttons; using osu.Game.Graphics.Containers; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osuTK; +using osuTK.Input; namespace osu.Game.Tests.Visual.Beatmaps { @@ -295,5 +297,22 @@ namespace osu.Game.Tests.Visual.Beatmaps BeatmapCardNormal firstCard() => this.ChildrenOfType().First(); } + + [Test] + public void TestPlayButtonByTouchInput() + { + AddStep("create cards", () => Child = createContent(OverlayColourScheme.Blue, beatmapSetInfo => new BeatmapCardNormal(beatmapSetInfo))); + + // mimics touch input + AddStep("touch play button area on first card", () => + { + InputManager.MoveMouseTo(firstCard().ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("first card is playing", () => firstCard().ChildrenOfType().Single().Playing.Value); + + BeatmapCardNormal firstCard() => this.ChildrenOfType().First(); + } } } diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardDownloadButton.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardDownloadButton.cs index 3308ffe714..10515fd95f 100644 --- a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardDownloadButton.cs +++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardDownloadButton.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables.Cards.Buttons; using osu.Game.Configuration; +using osu.Game.Online; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Resources.Localisation.Web; @@ -58,6 +59,7 @@ namespace osu.Game.Tests.Visual.Beatmaps { Anchor = Anchor.Centre, Origin = Anchor.Centre, + State = { Value = DownloadState.NotDownloaded }, Scale = new Vector2(2) }; }); diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardThumbnail.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardThumbnail.cs index 0af876bfe8..5afda0ad0c 100644 --- a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardThumbnail.cs +++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardThumbnail.cs @@ -42,7 +42,7 @@ namespace osu.Game.Tests.Visual.Beatmaps }; }); AddStep("enable dim", () => thumbnail.Dimmed.Value = true); - AddUntilStep("button visible", () => playButton.IsPresent); + AddUntilStep("button visible", () => playButton.Alpha == 1); AddStep("click button", () => { @@ -70,7 +70,7 @@ namespace osu.Game.Tests.Visual.Beatmaps AddStep("disable dim", () => thumbnail.Dimmed.Value = false); AddWaitStep("wait some", 3); - AddAssert("button still visible", () => playButton.IsPresent); + AddAssert("button still visible", () => playButton.Alpha == 1); // The track plays in real-time, so we need to check for progress in increments to avoid timeout. AddUntilStep("progress > 0.25", () => thumbnail.ChildrenOfType().Single().Progress.Value > 0.25); @@ -78,7 +78,7 @@ namespace osu.Game.Tests.Visual.Beatmaps AddUntilStep("progress > 0.75", () => thumbnail.ChildrenOfType().Single().Progress.Value > 0.75); AddUntilStep("wait for track to end", () => !playButton.Playing.Value); - AddUntilStep("button hidden", () => !playButton.IsPresent); + AddUntilStep("button hidden", () => playButton.Alpha == 0); } private void iconIs(IconUsage usage) => AddUntilStep("icon is correct", () => playButton.ChildrenOfType().Any(icon => icon.Icon.Equals(usage))); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneClicksPerSecondCalculator.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneClicksPerSecondCalculator.cs new file mode 100644 index 0000000000..2dad5e2c32 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneClicksPerSecondCalculator.cs @@ -0,0 +1,130 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD.ClicksPerSecond; +using osuTK; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneClicksPerSecondCalculator : OsuTestScene + { + private ClicksPerSecondCalculator calculator = null!; + + private TestGameplayClock manualGameplayClock = null!; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create components", () => + { + manualGameplayClock = new TestGameplayClock(); + + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] { (typeof(IGameplayClock), manualGameplayClock) }, + Children = new Drawable[] + { + calculator = new ClicksPerSecondCalculator(), + new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] { (typeof(ClicksPerSecondCalculator), calculator) }, + Child = new ClicksPerSecondCounter + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(5), + } + } + }, + }; + }); + } + + [Test] + public void TestBasicConsistency() + { + seek(1000); + AddStep("add inputs in past", () => addInputs(new double[] { 0, 100, 200, 300, 400, 500, 600, 700, 800, 900 })); + checkClicksPerSecondValue(10); + } + + [Test] + public void TestRateAdjustConsistency() + { + seek(1000); + AddStep("add inputs in past", () => addInputs(new double[] { 0, 100, 200, 300, 400, 500, 600, 700, 800, 900 })); + checkClicksPerSecondValue(10); + AddStep("set rate 0.5x", () => manualGameplayClock.TrueGameplayRate = 0.5); + checkClicksPerSecondValue(5); + } + + [Test] + public void TestInputsDiscardedOnRewind() + { + seek(1000); + AddStep("add inputs in past", () => addInputs(new double[] { 0, 100, 200, 300, 400, 500, 600, 700, 800, 900 })); + checkClicksPerSecondValue(10); + seek(500); + checkClicksPerSecondValue(6); + seek(1000); + checkClicksPerSecondValue(6); + } + + private void checkClicksPerSecondValue(int i) => AddAssert("clicks/s is correct", () => calculator.Value, () => Is.EqualTo(i)); + + private void seekClockImmediately(double time) => manualGameplayClock.CurrentTime = time; + + private void seek(double time) => AddStep($"Seek to {time}ms", () => seekClockImmediately(time)); + + private void addInputs(IEnumerable inputs) + { + double baseTime = manualGameplayClock.CurrentTime; + + foreach (double timestamp in inputs) + { + seekClockImmediately(timestamp); + calculator.AddInputTimestamp(); + } + + seekClockImmediately(baseTime); + } + + private class TestGameplayClock : IGameplayClock + { + public double CurrentTime { get; set; } + + public double Rate => 1; + + public bool IsRunning => true; + + public double TrueGameplayRate { set => adjustableAudioComponent.Tempo.Value = value; } + + private readonly AudioAdjustments adjustableAudioComponent = new AudioAdjustments(); + + public void ProcessFrame() + { + } + + public double ElapsedFrameTime => throw new NotImplementedException(); + public double FramesPerSecond => throw new NotImplementedException(); + public FrameTimeInfo TimeInfo => throw new NotImplementedException(); + public double StartTime => throw new NotImplementedException(); + + public IAdjustableAudioComponent AdjustmentsFromMods => adjustableAudioComponent; + + public IEnumerable NonGameplayAdjustments => throw new NotImplementedException(); + public IBindable IsPaused => throw new NotImplementedException(); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs index 34723e3329..3fc456c411 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneDrawableStoryboardSprite.cs @@ -78,8 +78,9 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("allow skin lookup", () => storyboard.UseSkinSprites = true); AddStep("create sprites", () => SetContents(_ => createSprite(lookup_name, Anchor.TopLeft, Vector2.Zero))); + AddAssert("sprites present", () => sprites.All(s => s.IsPresent)); AddStep("scale sprite", () => sprites.ForEach(s => s.VectorScale = new Vector2(0, 1))); - AddAssert("zero width", () => sprites.All(s => s.ScreenSpaceDrawQuad.Width == 0)); + AddAssert("sprites not present", () => sprites.All(s => !s.IsPresent)); } [Test] diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs index a6abdd7ee1..d0371acce7 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs @@ -370,7 +370,7 @@ namespace osu.Game.Tests.Visual.Gameplay private void confirmNoTrackAdjustments() { - AddAssert("track has no adjustments", () => Beatmap.Value.Track.AggregateFrequency.Value == 1); + AddUntilStep("track has no adjustments", () => Beatmap.Value.Track.AggregateFrequency.Value, () => Is.EqualTo(1)); } private void restart() => AddStep("restart", () => Player.Restart()); diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs index f3e436e31f..247b822dc3 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs @@ -81,9 +81,11 @@ namespace osu.Game.Tests.Visual.Gameplay CreateTest(); AddUntilStep("fail screen displayed", () => Player.ChildrenOfType().First().State.Value == Visibility.Visible); + AddUntilStep("wait for button clickable", () => Player.ChildrenOfType().First().ChildrenOfType().First().Enabled.Value); + AddUntilStep("score not in database", () => Realm.Run(r => r.Find(Player.Score.ScoreInfo.ID) == null)); AddStep("click save button", () => Player.ChildrenOfType().First().ChildrenOfType().First().TriggerClick()); - AddUntilStep("score not in database", () => Realm.Run(r => r.Find(Player.Score.ScoreInfo.ID) != null)); + AddUntilStep("score in database", () => Realm.Run(r => r.Find(Player.Score.ScoreInfo.ID) != null)); } [Test] diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs index 9d70d1ef33..cd227630c1 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs @@ -202,7 +202,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for load", () => downloadButton.IsLoaded); - AddAssert("state is not downloaded", () => downloadButton.State.Value == DownloadState.NotDownloaded); + AddAssert("state is unknown", () => downloadButton.State.Value == DownloadState.Unknown); AddAssert("button is not enabled", () => !downloadButton.ChildrenOfType().First().Enabled.Value); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs index bd274dfef5..578718b7c9 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -22,7 +20,7 @@ namespace osu.Game.Tests.Visual.Gameplay { public class TestSceneSkinEditor : PlayerTestScene { - private SkinEditor skinEditor; + private SkinEditor? skinEditor; protected override bool Autoplay => true; @@ -42,29 +40,33 @@ namespace osu.Game.Tests.Visual.Gameplay Player.ScaleTo(0.4f); LoadComponentAsync(skinEditor = new SkinEditor(Player), Add); }); - AddUntilStep("wait for loaded", () => skinEditor.IsLoaded); + AddUntilStep("wait for loaded", () => skinEditor!.IsLoaded); } [Test] public void TestToggleEditor() { - AddToggleStep("toggle editor visibility", _ => skinEditor.ToggleVisibility()); + AddToggleStep("toggle editor visibility", _ => skinEditor!.ToggleVisibility()); } [Test] public void TestEditComponent() { - BarHitErrorMeter hitErrorMeter = null; + 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.Clear(); skinEditor.SelectedComponents.Add(blueprint.Item); }); + AddStep("move by keyboard", () => InputManager.Key(Key.Right)); + + AddAssert("hitErrorMeter moved", () => hitErrorMeter.X != 0); + AddAssert("value is default", () => hitErrorMeter.JudgementLineThickness.IsDefault); AddStep("hover first slider", () => diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs index a800b21bc9..12e7394c93 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs @@ -91,8 +91,7 @@ namespace osu.Game.Tests.Visual.Multiplayer break; case StopCountdownRequest: - multiplayerRoom.Countdown = null; - raiseRoomUpdated(); + clearRoomCountdown(); break; } }); @@ -244,14 +243,14 @@ namespace osu.Game.Tests.Visual.Multiplayer }); AddStep("start countdown", () => multiplayerClient.Object.SendMatchRequest(new StartMatchCountdownRequest { Duration = TimeSpan.FromMinutes(1) }).WaitSafely()); - AddUntilStep("countdown started", () => multiplayerRoom.Countdown != null); + AddUntilStep("countdown started", () => multiplayerRoom.ActiveCountdowns.Any()); AddStep("transfer host to local user", () => transferHost(localUser)); AddUntilStep("local user is host", () => multiplayerRoom.Host?.Equals(multiplayerClient.Object.LocalUser) == true); ClickButtonWhenEnabled(); checkLocalUserState(MultiplayerUserState.Ready); - AddAssert("countdown still active", () => multiplayerRoom.Countdown != null); + AddAssert("countdown still active", () => multiplayerRoom.ActiveCountdowns.Any()); } [Test] @@ -392,7 +391,13 @@ namespace osu.Game.Tests.Visual.Multiplayer private void setRoomCountdown(TimeSpan duration) { - multiplayerRoom.Countdown = new MatchStartCountdown { TimeRemaining = duration }; + multiplayerRoom.ActiveCountdowns.Add(new MatchStartCountdown { TimeRemaining = duration }); + raiseRoomUpdated(); + } + + private void clearRoomCountdown() + { + multiplayerRoom.ActiveCountdowns.Clear(); raiseRoomUpdated(); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs index a11a67aebd..70f498e7f2 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiSpectatorScreen.cs @@ -13,9 +13,11 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.UI; using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; using osu.Game.Screens.Play; @@ -332,6 +334,18 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("player 2 playing from correct point in time", () => getPlayer(PLAYER_2_ID).ChildrenOfType().Single().FrameStableClock.CurrentTime > 30000); } + [Test] + public void TestGameplayRateAdjust() + { + start(getPlayerIds(4), mods: new[] { new APIMod(new OsuModDoubleTime()) }); + + loadSpectateScreen(); + + sendFrames(getPlayerIds(4), 300); + + AddUntilStep("wait for correct track speed", () => Beatmap.Value.Track.Rate, () => Is.EqualTo(1.5)); + } + [Test] public void TestPlayersLeaveWhileSpectating() { @@ -420,7 +434,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private void start(int userId, int? beatmapId = null) => start(new[] { userId }, beatmapId); - private void start(int[] userIds, int? beatmapId = null) + private void start(int[] userIds, int? beatmapId = null, APIMod[]? mods = null) { AddStep("start play", () => { @@ -429,10 +443,11 @@ namespace osu.Game.Tests.Visual.Multiplayer var user = new MultiplayerRoomUser(id) { User = new APIUser { Id = id }, + Mods = mods ?? Array.Empty(), }; - OnlinePlayDependencies.MultiplayerClient.AddUser(user.User, true); - SpectatorClient.SendStartPlay(id, beatmapId ?? importedBeatmapId); + OnlinePlayDependencies.MultiplayerClient.AddUser(user, true); + SpectatorClient.SendStartPlay(id, beatmapId ?? importedBeatmapId, mods); playingUsers.Add(user); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs index 2281235f25..b87321c047 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs @@ -19,7 +19,6 @@ using osu.Game.Database; using osu.Game.Online.Rooms; using osu.Game.Overlays.Mods; using osu.Game.Rulesets; -using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; @@ -68,37 +67,6 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for present", () => songSelect.IsCurrentScreen() && songSelect.BeatmapSetsLoaded); } - [Test] - public void TestBeatmapRevertedOnExitIfNoSelection() - { - BeatmapInfo selectedBeatmap = null; - - AddStep("select beatmap", - () => songSelect.Carousel.SelectBeatmap(selectedBeatmap = beatmaps.Where(beatmap => beatmap.Ruleset.OnlineID == new OsuRuleset().LegacyID).ElementAt(1))); - AddUntilStep("wait for selection", () => Beatmap.Value.BeatmapInfo.Equals(selectedBeatmap)); - - AddStep("exit song select", () => songSelect.Exit()); - AddAssert("beatmap reverted", () => Beatmap.IsDefault); - } - - [Test] - public void TestModsRevertedOnExitIfNoSelection() - { - AddStep("change mods", () => SelectedMods.Value = new[] { new OsuModDoubleTime() }); - - AddStep("exit song select", () => songSelect.Exit()); - AddAssert("mods reverted", () => SelectedMods.Value.Count == 0); - } - - [Test] - public void TestRulesetRevertedOnExitIfNoSelection() - { - AddStep("change ruleset", () => Ruleset.Value = new CatchRuleset().RulesetInfo); - - AddStep("exit song select", () => songSelect.Exit()); - AddAssert("ruleset reverted", () => Ruleset.Value.Equals(new OsuRuleset().RulesetInfo)); - } - [Test] public void TestBeatmapConfirmed() { @@ -152,8 +120,8 @@ namespace osu.Game.Tests.Visual.Multiplayer public new BeatmapCarousel Carousel => base.Carousel; - public TestMultiplayerMatchSongSelect(Room room, WorkingBeatmap beatmap = null, RulesetInfo ruleset = null) - : base(room, null, beatmap, ruleset) + public TestMultiplayerMatchSongSelect(Room room) + : base(room) { } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs index cfa9f77634..a0f76c4e14 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs @@ -154,7 +154,7 @@ namespace osu.Game.Tests.Visual.Online }); } - private int onlineID = 1; + private ulong onlineID = 1; private APIScoresCollection createScores() { diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs index 44cb438a6b..198be4035b 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; @@ -20,7 +18,7 @@ namespace osu.Game.Tests.Visual.Ranking { public class TestSceneHitEventTimingDistributionGraph : OsuTestScene { - private HitEventTimingDistributionGraph graph; + private HitEventTimingDistributionGraph graph = null!; private static readonly HitObject placeholder_object = new HitCircle(); @@ -43,6 +41,65 @@ namespace osu.Game.Tests.Visual.Ranking createTest(Enumerable.Range(-150, 300).Select(i => new HitEvent(i / 50f, HitResult.Perfect, placeholder_object, placeholder_object, null)).ToList()); } + [Test] + public void TestSparse() + { + createTest(new List + { + new HitEvent(-7, HitResult.Perfect, placeholder_object, placeholder_object, null), + new HitEvent(-6, HitResult.Perfect, placeholder_object, placeholder_object, null), + new HitEvent(-5, HitResult.Perfect, placeholder_object, placeholder_object, null), + new HitEvent(5, HitResult.Perfect, placeholder_object, placeholder_object, null), + new HitEvent(6, HitResult.Perfect, placeholder_object, placeholder_object, null), + new HitEvent(7, HitResult.Perfect, placeholder_object, placeholder_object, null), + }); + } + + [Test] + public void TestVariousTypesOfHitResult() + { + createTest(CreateDistributedHitEvents(0, 50).Select(h => + { + double offset = Math.Abs(h.TimeOffset); + HitResult result = offset > 36 ? HitResult.Miss + : offset > 32 ? HitResult.Meh + : offset > 24 ? HitResult.Ok + : offset > 16 ? HitResult.Good + : offset > 8 ? HitResult.Great + : HitResult.Perfect; + return new HitEvent(h.TimeOffset, result, placeholder_object, placeholder_object, null); + }).ToList()); + } + + [Test] + public void TestMultipleWindowsOfHitResult() + { + var wide = CreateDistributedHitEvents(0, 50).Select(h => + { + double offset = Math.Abs(h.TimeOffset); + HitResult result = offset > 36 ? HitResult.Miss + : offset > 32 ? HitResult.Meh + : offset > 24 ? HitResult.Ok + : offset > 16 ? HitResult.Good + : offset > 8 ? HitResult.Great + : HitResult.Perfect; + + return new HitEvent(h.TimeOffset, result, placeholder_object, placeholder_object, null); + }); + var narrow = CreateDistributedHitEvents(0, 50).Select(h => + { + double offset = Math.Abs(h.TimeOffset); + HitResult result = offset > 25 ? HitResult.Miss + : offset > 20 ? HitResult.Meh + : offset > 15 ? HitResult.Ok + : offset > 10 ? HitResult.Good + : offset > 5 ? HitResult.Great + : HitResult.Perfect; + return new HitEvent(h.TimeOffset, result, placeholder_object, placeholder_object, null); + }); + createTest(wide.Concat(narrow).ToList()); + } + [Test] public void TestZeroTimeOffset() { diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index 5db46e3097..cc8746959b 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -1,10 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using NUnit.Framework; @@ -13,6 +12,7 @@ using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics.Containers; using osu.Framework.Platform; using osu.Framework.Screens; @@ -46,11 +46,12 @@ namespace osu.Game.Tests.Visual.SongSelect [TestFixture] public class TestScenePlaySongSelect : ScreenTestScene { - private BeatmapManager manager; - private RulesetStore rulesets; - private MusicController music; - private WorkingBeatmap defaultBeatmap; - private TestSongSelect songSelect; + private BeatmapManager manager = null!; + private RulesetStore rulesets = null!; + private MusicController music = null!; + private WorkingBeatmap defaultBeatmap = null!; + private OsuConfigManager config = null!; + private TestSongSelect? songSelect; [BackgroundDependencyLoader] private void load(GameHost host, AudioManager audio) @@ -69,8 +70,6 @@ namespace osu.Game.Tests.Visual.SongSelect Dependencies.Cache(config = new OsuConfigManager(LocalStorage)); } - private OsuConfigManager config; - public override void SetUpSteps() { base.SetUpSteps(); @@ -85,7 +84,7 @@ namespace osu.Game.Tests.Visual.SongSelect songSelect = null; }); - AddStep("delete all beatmaps", () => manager?.Delete()); + AddStep("delete all beatmaps", () => manager.Delete()); } [Test] @@ -98,7 +97,7 @@ namespace osu.Game.Tests.Visual.SongSelect addRulesetImportStep(0); AddUntilStep("wait for placeholder hidden", () => getPlaceholder()?.State.Value == Visibility.Hidden); - AddStep("delete all beatmaps", () => manager?.Delete()); + AddStep("delete all beatmaps", () => manager.Delete()); AddUntilStep("wait for placeholder visible", () => getPlaceholder()?.State.Value == Visibility.Visible); } @@ -144,7 +143,7 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); - AddAssert("filter count is 1", () => songSelect.FilterCount == 1); + AddAssert("filter count is 1", () => songSelect?.FilterCount == 1); } [Test] @@ -156,7 +155,7 @@ namespace osu.Game.Tests.Visual.SongSelect waitForInitialSelection(); - WorkingBeatmap selected = null; + WorkingBeatmap? selected = null; AddStep("store selected beatmap", () => selected = Beatmap.Value); @@ -166,7 +165,7 @@ namespace osu.Game.Tests.Visual.SongSelect InputManager.Key(Key.Enter); }); - AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); AddAssert("ensure selection changed", () => selected != Beatmap.Value); } @@ -179,7 +178,7 @@ namespace osu.Game.Tests.Visual.SongSelect waitForInitialSelection(); - WorkingBeatmap selected = null; + WorkingBeatmap? selected = null; AddStep("store selected beatmap", () => selected = Beatmap.Value); @@ -189,7 +188,7 @@ namespace osu.Game.Tests.Visual.SongSelect InputManager.Key(Key.Down); }); - AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); AddAssert("ensure selection didn't change", () => selected == Beatmap.Value); } @@ -202,23 +201,23 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("wait for initial selection", () => !Beatmap.IsDefault); - WorkingBeatmap selected = null; + WorkingBeatmap? selected = null; AddStep("store selected beatmap", () => selected = Beatmap.Value); - AddUntilStep("wait for beatmaps to load", () => songSelect.Carousel.ChildrenOfType().Any()); + AddUntilStep("wait for beatmaps to load", () => songSelect!.Carousel.ChildrenOfType().Any()); AddStep("select next and enter", () => { - InputManager.MoveMouseTo(songSelect.Carousel.ChildrenOfType() - .First(b => !((CarouselBeatmap)b.Item).BeatmapInfo.Equals(songSelect.Carousel.SelectedBeatmapInfo))); + InputManager.MoveMouseTo(songSelect!.Carousel.ChildrenOfType() + .First(b => !((CarouselBeatmap)b.Item).BeatmapInfo.Equals(songSelect!.Carousel.SelectedBeatmapInfo))); InputManager.Click(MouseButton.Left); InputManager.Key(Key.Enter); }); - AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); AddAssert("ensure selection changed", () => selected != Beatmap.Value); } @@ -231,14 +230,14 @@ namespace osu.Game.Tests.Visual.SongSelect waitForInitialSelection(); - WorkingBeatmap selected = null; + WorkingBeatmap? selected = null; AddStep("store selected beatmap", () => selected = Beatmap.Value); AddStep("select next and enter", () => { - InputManager.MoveMouseTo(songSelect.Carousel.ChildrenOfType() - .First(b => !((CarouselBeatmap)b.Item).BeatmapInfo.Equals(songSelect.Carousel.SelectedBeatmapInfo))); + InputManager.MoveMouseTo(songSelect!.Carousel.ChildrenOfType() + .First(b => !((CarouselBeatmap)b.Item).BeatmapInfo.Equals(songSelect!.Carousel.SelectedBeatmapInfo))); InputManager.PressButton(MouseButton.Left); @@ -247,7 +246,7 @@ namespace osu.Game.Tests.Visual.SongSelect InputManager.ReleaseButton(MouseButton.Left); }); - AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); AddAssert("ensure selection didn't change", () => selected == Beatmap.Value); } @@ -260,11 +259,11 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child"))); - AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); - AddStep("return", () => songSelect.MakeCurrent()); - AddUntilStep("wait for current", () => songSelect.IsCurrentScreen()); - AddAssert("filter count is 1", () => songSelect.FilterCount == 1); + AddStep("return", () => songSelect!.MakeCurrent()); + AddUntilStep("wait for current", () => songSelect!.IsCurrentScreen()); + AddAssert("filter count is 1", () => songSelect!.FilterCount == 1); } [Test] @@ -278,13 +277,13 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child"))); - AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); - AddStep("return", () => songSelect.MakeCurrent()); - AddUntilStep("wait for current", () => songSelect.IsCurrentScreen()); - AddAssert("filter count is 2", () => songSelect.FilterCount == 2); + AddStep("return", () => songSelect!.MakeCurrent()); + AddUntilStep("wait for current", () => songSelect!.IsCurrentScreen()); + AddAssert("filter count is 2", () => songSelect!.FilterCount == 2); } [Test] @@ -295,7 +294,7 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child"))); - AddUntilStep("wait for not current", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); AddStep("update beatmap", () => { @@ -304,9 +303,9 @@ namespace osu.Game.Tests.Visual.SongSelect Beatmap.Value = manager.GetWorkingBeatmap(anotherBeatmap); }); - AddStep("return", () => songSelect.MakeCurrent()); - AddUntilStep("wait for current", () => songSelect.IsCurrentScreen()); - AddAssert("carousel updated", () => songSelect.Carousel.SelectedBeatmapInfo.Equals(Beatmap.Value.BeatmapInfo)); + AddStep("return", () => songSelect!.MakeCurrent()); + AddUntilStep("wait for current", () => songSelect!.IsCurrentScreen()); + AddAssert("carousel updated", () => songSelect!.Carousel.SelectedBeatmapInfo?.Equals(Beatmap.Value.BeatmapInfo) == true); } [Test] @@ -318,15 +317,15 @@ namespace osu.Game.Tests.Visual.SongSelect addRulesetImportStep(0); checkMusicPlaying(true); - AddStep("select first", () => songSelect.Carousel.SelectBeatmap(songSelect.Carousel.BeatmapSets.First().Beatmaps.First())); + AddStep("select first", () => songSelect!.Carousel.SelectBeatmap(songSelect!.Carousel.BeatmapSets.First().Beatmaps.First())); checkMusicPlaying(true); AddStep("manual pause", () => music.TogglePause()); checkMusicPlaying(false); - AddStep("select next difficulty", () => songSelect.Carousel.SelectNext(skipDifficulties: false)); + AddStep("select next difficulty", () => songSelect!.Carousel.SelectNext(skipDifficulties: false)); checkMusicPlaying(false); - AddStep("select next set", () => songSelect.Carousel.SelectNext()); + AddStep("select next set", () => songSelect!.Carousel.SelectNext()); checkMusicPlaying(true); } @@ -366,13 +365,13 @@ namespace osu.Game.Tests.Visual.SongSelect public void TestDummy() { createSongSelect(); - AddUntilStep("dummy selected", () => songSelect.CurrentBeatmap == defaultBeatmap); + AddUntilStep("dummy selected", () => songSelect!.CurrentBeatmap == defaultBeatmap); - AddUntilStep("dummy shown on wedge", () => songSelect.CurrentBeatmapDetailsBeatmap == defaultBeatmap); + AddUntilStep("dummy shown on wedge", () => songSelect!.CurrentBeatmapDetailsBeatmap == defaultBeatmap); addManyTestMaps(); - AddUntilStep("random map selected", () => songSelect.CurrentBeatmap != defaultBeatmap); + AddUntilStep("random map selected", () => songSelect!.CurrentBeatmap != defaultBeatmap); } [Test] @@ -381,7 +380,7 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); addManyTestMaps(); - AddUntilStep("random map selected", () => songSelect.CurrentBeatmap != defaultBeatmap); + AddUntilStep("random map selected", () => songSelect!.CurrentBeatmap != defaultBeatmap); AddStep(@"Sort by Artist", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Artist)); AddStep(@"Sort by Title", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Title)); @@ -398,7 +397,7 @@ namespace osu.Game.Tests.Visual.SongSelect { createSongSelect(); addRulesetImportStep(2); - AddUntilStep("no selection", () => songSelect.Carousel.SelectedBeatmapInfo == null); + AddUntilStep("no selection", () => songSelect!.Carousel.SelectedBeatmapInfo == null); } [Test] @@ -408,13 +407,13 @@ namespace osu.Game.Tests.Visual.SongSelect changeRuleset(2); addRulesetImportStep(2); addRulesetImportStep(1); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo.Ruleset.OnlineID == 2); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.Ruleset.OnlineID == 2); changeRuleset(1); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo.Ruleset.OnlineID == 1); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.Ruleset.OnlineID == 1); changeRuleset(0); - AddUntilStep("no selection", () => songSelect.Carousel.SelectedBeatmapInfo == null); + AddUntilStep("no selection", () => songSelect!.Carousel.SelectedBeatmapInfo == null); } [Test] @@ -423,7 +422,7 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); changeRuleset(0); - Live original = null!; + Live? original = null; int originalOnlineSetID = 0; AddStep(@"Sort by artist", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Artist)); @@ -431,12 +430,17 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("import original", () => { original = manager.Import(new ImportTask(TestResources.GetQuickTestBeatmapForImport())).GetResultSafely(); - originalOnlineSetID = original!.Value.OnlineID; + + Debug.Assert(original != null); + + originalOnlineSetID = original.Value.OnlineID; }); // This will move the beatmap set to a different location in the carousel. AddStep("Update original with bogus info", () => { + Debug.Assert(original != null); + original.PerformWrite(set => { foreach (var beatmap in set.Beatmaps) @@ -457,13 +461,19 @@ namespace osu.Game.Tests.Visual.SongSelect manager.Import(testBeatmapSetInfo); }, 10); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo?.BeatmapSet?.OnlineID == originalOnlineSetID); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.BeatmapSet?.OnlineID == originalOnlineSetID); - Task> updateTask = null!; - AddStep("update beatmap", () => updateTask = manager.ImportAsUpdate(new ProgressNotification(), new ImportTask(TestResources.GetQuickTestBeatmapForImport()), original.Value)); + Task?> updateTask = null!; + + AddStep("update beatmap", () => + { + Debug.Assert(original != null); + + updateTask = manager.ImportAsUpdate(new ProgressNotification(), new ImportTask(TestResources.GetQuickTestBeatmapForImport()), original.Value); + }); AddUntilStep("wait for update completion", () => updateTask.IsCompleted); - AddUntilStep("retained selection", () => songSelect.Carousel.SelectedBeatmapInfo?.BeatmapSet?.OnlineID == originalOnlineSetID); + AddUntilStep("retained selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.BeatmapSet?.OnlineID == originalOnlineSetID); } [Test] @@ -473,13 +483,13 @@ namespace osu.Game.Tests.Visual.SongSelect changeRuleset(2); addRulesetImportStep(2); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo.Ruleset.OnlineID == 2); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.Ruleset.OnlineID == 2); addRulesetImportStep(0); addRulesetImportStep(0); addRulesetImportStep(0); - BeatmapInfo target = null; + BeatmapInfo? target = null; AddStep("select beatmap/ruleset externally", () => { @@ -490,10 +500,10 @@ namespace osu.Game.Tests.Visual.SongSelect Beatmap.Value = manager.GetWorkingBeatmap(target); }); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo.Equals(target)); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.Equals(target) == true); // this is an important check, to make sure updateComponentFromBeatmap() was actually run - AddUntilStep("selection shown on wedge", () => songSelect.CurrentBeatmapDetailsBeatmap.BeatmapInfo.MatchesOnlineID(target)); + AddUntilStep("selection shown on wedge", () => songSelect!.CurrentBeatmapDetailsBeatmap.BeatmapInfo.MatchesOnlineID(target)); } [Test] @@ -503,13 +513,13 @@ namespace osu.Game.Tests.Visual.SongSelect changeRuleset(2); addRulesetImportStep(2); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo.Ruleset.OnlineID == 2); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.Ruleset.OnlineID == 2); addRulesetImportStep(0); addRulesetImportStep(0); addRulesetImportStep(0); - BeatmapInfo target = null; + BeatmapInfo? target = null; AddStep("select beatmap/ruleset externally", () => { @@ -520,12 +530,12 @@ namespace osu.Game.Tests.Visual.SongSelect Ruleset.Value = rulesets.AvailableRulesets.First(r => r.OnlineID == 0); }); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo.Equals(target)); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo?.Equals(target) == true); AddUntilStep("has correct ruleset", () => Ruleset.Value.OnlineID == 0); // this is an important check, to make sure updateComponentFromBeatmap() was actually run - AddUntilStep("selection shown on wedge", () => songSelect.CurrentBeatmapDetailsBeatmap.BeatmapInfo.MatchesOnlineID(target)); + AddUntilStep("selection shown on wedge", () => songSelect!.CurrentBeatmapDetailsBeatmap.BeatmapInfo.MatchesOnlineID(target)); } [Test] @@ -543,12 +553,12 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("change ruleset", () => { SelectedMods.ValueChanged += onModChange; - songSelect.Ruleset.ValueChanged += onRulesetChange; + songSelect!.Ruleset.ValueChanged += onRulesetChange; Ruleset.Value = new TaikoRuleset().RulesetInfo; SelectedMods.ValueChanged -= onModChange; - songSelect.Ruleset.ValueChanged -= onRulesetChange; + songSelect!.Ruleset.ValueChanged -= onRulesetChange; }); AddAssert("mods changed before ruleset", () => modChangeIndex < rulesetChangeIndex); @@ -579,18 +589,18 @@ namespace osu.Game.Tests.Visual.SongSelect { createSongSelect(); addManyTestMaps(); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo != null); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo != null); bool startRequested = false; AddStep("set filter and finalize", () => { - songSelect.StartRequested = () => startRequested = true; + songSelect!.StartRequested = () => startRequested = true; - songSelect.Carousel.Filter(new FilterCriteria { SearchText = "somestringthatshouldn'tbematchable" }); - songSelect.FinaliseSelection(); + songSelect!.Carousel.Filter(new FilterCriteria { SearchText = "somestringthatshouldn'tbematchable" }); + songSelect!.FinaliseSelection(); - songSelect.StartRequested = null; + songSelect!.StartRequested = null; }); AddAssert("start not requested", () => !startRequested); @@ -610,15 +620,15 @@ namespace osu.Game.Tests.Visual.SongSelect // used for filter check below AddStep("allow convert display", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo != null); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo != null); - AddStep("set filter text", () => songSelect.FilterControl.ChildrenOfType().First().Text = "nonono"); + AddStep("set filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = "nonono"); AddUntilStep("dummy selected", () => Beatmap.Value is DummyWorkingBeatmap); - AddUntilStep("has no selection", () => songSelect.Carousel.SelectedBeatmapInfo == null); + AddUntilStep("has no selection", () => songSelect!.Carousel.SelectedBeatmapInfo == null); - BeatmapInfo target = null; + BeatmapInfo? target = null; int targetRuleset = differentRuleset ? 1 : 0; @@ -632,24 +642,24 @@ namespace osu.Game.Tests.Visual.SongSelect Beatmap.Value = manager.GetWorkingBeatmap(target); }); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo != null); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo != null); AddAssert("selected only shows expected ruleset (plus converts)", () => { - var selectedPanel = songSelect.Carousel.ChildrenOfType().First(s => s.Item.State.Value == CarouselItemState.Selected); + var selectedPanel = songSelect!.Carousel.ChildrenOfType().First(s => s.Item.State.Value == CarouselItemState.Selected); // special case for converts checked here. return selectedPanel.ChildrenOfType().All(i => i.IsFiltered || i.Item.BeatmapInfo.Ruleset.OnlineID == targetRuleset || i.Item.BeatmapInfo.Ruleset.OnlineID == 0); }); - AddUntilStep("carousel has correct", () => songSelect.Carousel.SelectedBeatmapInfo?.MatchesOnlineID(target) == true); + AddUntilStep("carousel has correct", () => songSelect!.Carousel.SelectedBeatmapInfo?.MatchesOnlineID(target) == true); AddUntilStep("game has correct", () => Beatmap.Value.BeatmapInfo.MatchesOnlineID(target)); - AddStep("reset filter text", () => songSelect.FilterControl.ChildrenOfType().First().Text = string.Empty); + AddStep("reset filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = string.Empty); AddAssert("game still correct", () => Beatmap.Value?.BeatmapInfo.MatchesOnlineID(target) == true); - AddAssert("carousel still correct", () => songSelect.Carousel.SelectedBeatmapInfo.MatchesOnlineID(target)); + AddAssert("carousel still correct", () => songSelect!.Carousel.SelectedBeatmapInfo.MatchesOnlineID(target)); } [Test] @@ -662,15 +672,15 @@ namespace osu.Game.Tests.Visual.SongSelect changeRuleset(0); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo != null); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo != null); - AddStep("set filter text", () => songSelect.FilterControl.ChildrenOfType().First().Text = "nonono"); + AddStep("set filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = "nonono"); AddUntilStep("dummy selected", () => Beatmap.Value is DummyWorkingBeatmap); - AddUntilStep("has no selection", () => songSelect.Carousel.SelectedBeatmapInfo == null); + AddUntilStep("has no selection", () => songSelect!.Carousel.SelectedBeatmapInfo == null); - BeatmapInfo target = null; + BeatmapInfo? target = null; AddStep("select beatmap externally", () => { @@ -682,15 +692,15 @@ namespace osu.Game.Tests.Visual.SongSelect Beatmap.Value = manager.GetWorkingBeatmap(target); }); - AddUntilStep("has selection", () => songSelect.Carousel.SelectedBeatmapInfo != null); + AddUntilStep("has selection", () => songSelect!.Carousel.SelectedBeatmapInfo != null); - AddUntilStep("carousel has correct", () => songSelect.Carousel.SelectedBeatmapInfo?.MatchesOnlineID(target) == true); + AddUntilStep("carousel has correct", () => songSelect!.Carousel.SelectedBeatmapInfo?.MatchesOnlineID(target) == true); AddUntilStep("game has correct", () => Beatmap.Value.BeatmapInfo.MatchesOnlineID(target)); - AddStep("set filter text", () => songSelect.FilterControl.ChildrenOfType().First().Text = "nononoo"); + AddStep("set filter text", () => songSelect!.FilterControl.ChildrenOfType().First().Text = "nononoo"); AddUntilStep("game lost selection", () => Beatmap.Value is DummyWorkingBeatmap); - AddAssert("carousel lost selection", () => songSelect.Carousel.SelectedBeatmapInfo == null); + AddAssert("carousel lost selection", () => songSelect!.Carousel.SelectedBeatmapInfo == null); } [Test] @@ -711,11 +721,11 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("wait for player", () => Stack.CurrentScreen is PlayerLoader); - AddAssert("autoplay selected", () => songSelect.Mods.Value.Single() is ModAutoplay); + AddAssert("autoplay selected", () => songSelect!.Mods.Value.Single() is ModAutoplay); - AddUntilStep("wait for return to ss", () => songSelect.IsCurrentScreen()); + AddUntilStep("wait for return to ss", () => songSelect!.IsCurrentScreen()); - AddAssert("no mods selected", () => songSelect.Mods.Value.Count == 0); + AddAssert("no mods selected", () => songSelect!.Mods.Value.Count == 0); } [Test] @@ -738,11 +748,11 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("wait for player", () => Stack.CurrentScreen is PlayerLoader); - AddAssert("autoplay selected", () => songSelect.Mods.Value.Single() is ModAutoplay); + AddAssert("autoplay selected", () => songSelect!.Mods.Value.Single() is ModAutoplay); - AddUntilStep("wait for return to ss", () => songSelect.IsCurrentScreen()); + AddUntilStep("wait for return to ss", () => songSelect!.IsCurrentScreen()); - AddAssert("autoplay still selected", () => songSelect.Mods.Value.Single() is ModAutoplay); + AddAssert("autoplay still selected", () => songSelect!.Mods.Value.Single() is ModAutoplay); } [Test] @@ -765,11 +775,11 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("wait for player", () => Stack.CurrentScreen is PlayerLoader); - AddAssert("only autoplay selected", () => songSelect.Mods.Value.Single() is ModAutoplay); + AddAssert("only autoplay selected", () => songSelect!.Mods.Value.Single() is ModAutoplay); - AddUntilStep("wait for return to ss", () => songSelect.IsCurrentScreen()); + AddUntilStep("wait for return to ss", () => songSelect!.IsCurrentScreen()); - AddAssert("relax returned", () => songSelect.Mods.Value.Single() is ModRelax); + AddAssert("relax returned", () => songSelect!.Mods.Value.Single() is ModRelax); } [Test] @@ -778,10 +788,10 @@ namespace osu.Game.Tests.Visual.SongSelect Guid? previousID = null; createSongSelect(); addRulesetImportStep(0); - AddStep("Move to last difficulty", () => songSelect.Carousel.SelectBeatmap(songSelect.Carousel.BeatmapSets.First().Beatmaps.Last())); - AddStep("Store current ID", () => previousID = songSelect.Carousel.SelectedBeatmapInfo.ID); - AddStep("Hide first beatmap", () => manager.Hide(songSelect.Carousel.SelectedBeatmapSet.Beatmaps.First())); - AddAssert("Selected beatmap has not changed", () => songSelect.Carousel.SelectedBeatmapInfo.ID == previousID); + AddStep("Move to last difficulty", () => songSelect!.Carousel.SelectBeatmap(songSelect!.Carousel.BeatmapSets.First().Beatmaps.Last())); + AddStep("Store current ID", () => previousID = songSelect!.Carousel.SelectedBeatmapInfo!.ID); + AddStep("Hide first beatmap", () => manager.Hide(songSelect!.Carousel.SelectedBeatmapSet!.Beatmaps.First())); + AddAssert("Selected beatmap has not changed", () => songSelect!.Carousel.SelectedBeatmapInfo?.ID == previousID); } [Test] @@ -792,17 +802,24 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("wait for selection", () => !Beatmap.IsDefault); - DrawableCarouselBeatmapSet set = null; + DrawableCarouselBeatmapSet set = null!; AddStep("Find the DrawableCarouselBeatmapSet", () => { - set = songSelect.Carousel.ChildrenOfType().First(); + set = songSelect!.Carousel.ChildrenOfType().First(); }); - FilterableDifficultyIcon difficultyIcon = null; + FilterableDifficultyIcon difficultyIcon = null!; + AddUntilStep("Find an icon", () => { - return (difficultyIcon = set.ChildrenOfType() - .FirstOrDefault(icon => getDifficultyIconIndex(set, icon) != getCurrentBeatmapIndex())) != null; + var foundIcon = set.ChildrenOfType() + .FirstOrDefault(icon => getDifficultyIconIndex(set, icon) != getCurrentBeatmapIndex()); + + if (foundIcon == null) + return false; + + difficultyIcon = foundIcon; + return true; }); AddStep("Click on a difficulty", () => @@ -815,21 +832,24 @@ namespace osu.Game.Tests.Visual.SongSelect AddAssert("Selected beatmap correct", () => getCurrentBeatmapIndex() == getDifficultyIconIndex(set, difficultyIcon)); double? maxBPM = null; - AddStep("Filter some difficulties", () => songSelect.Carousel.Filter(new FilterCriteria + AddStep("Filter some difficulties", () => songSelect!.Carousel.Filter(new FilterCriteria { BPM = new FilterCriteria.OptionalRange { - Min = maxBPM = songSelect.Carousel.SelectedBeatmapSet.MaxBPM, + Min = maxBPM = songSelect!.Carousel.SelectedBeatmapSet!.MaxBPM, IsLowerInclusive = true } })); - BeatmapInfo filteredBeatmap = null; - FilterableDifficultyIcon filteredIcon = null; + BeatmapInfo? filteredBeatmap = null; + FilterableDifficultyIcon? filteredIcon = null; AddStep("Get filtered icon", () => { - var selectedSet = songSelect.Carousel.SelectedBeatmapSet; + var selectedSet = songSelect!.Carousel.SelectedBeatmapSet; + + Debug.Assert(selectedSet != null); + filteredBeatmap = selectedSet.Beatmaps.First(b => b.BPM < maxBPM); int filteredBeatmapIndex = getBeatmapIndex(selectedSet, filteredBeatmap); filteredIcon = set.ChildrenOfType().ElementAt(filteredBeatmapIndex); @@ -842,7 +862,7 @@ namespace osu.Game.Tests.Visual.SongSelect InputManager.Click(MouseButton.Left); }); - AddAssert("Selected beatmap correct", () => songSelect.Carousel.SelectedBeatmapInfo.Equals(filteredBeatmap)); + AddAssert("Selected beatmap correct", () => songSelect!.Carousel.SelectedBeatmapInfo?.Equals(filteredBeatmap) == true); } [Test] @@ -907,14 +927,14 @@ namespace osu.Game.Tests.Visual.SongSelect manager.Import(TestResources.CreateTestBeatmapSetInfo(3, usableRulesets)); }); - DrawableCarouselBeatmapSet set = null; + DrawableCarouselBeatmapSet? set = null; AddUntilStep("Find the DrawableCarouselBeatmapSet", () => { - set = songSelect.Carousel.ChildrenOfType().FirstOrDefault(); + set = songSelect!.Carousel.ChildrenOfType().FirstOrDefault(); return set != null; }); - FilterableDifficultyIcon difficultyIcon = null; + FilterableDifficultyIcon? difficultyIcon = null; AddUntilStep("Find an icon for different ruleset", () => { difficultyIcon = set.ChildrenOfType() @@ -937,7 +957,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("Check ruleset changed to mania", () => Ruleset.Value.OnlineID == 3); - AddAssert("Selected beatmap still same set", () => songSelect.Carousel.SelectedBeatmapInfo.BeatmapSet?.OnlineID == previousSetID); + AddAssert("Selected beatmap still same set", () => songSelect!.Carousel.SelectedBeatmapInfo?.BeatmapSet?.OnlineID == previousSetID); AddAssert("Selected beatmap is mania", () => Beatmap.Value.BeatmapInfo.Ruleset.OnlineID == 3); } @@ -948,7 +968,7 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); - BeatmapSetInfo imported = null; + BeatmapSetInfo? imported = null; AddStep("import huge difficulty count map", () => { @@ -956,20 +976,27 @@ namespace osu.Game.Tests.Visual.SongSelect imported = manager.Import(TestResources.CreateTestBeatmapSetInfo(50, usableRulesets))?.Value; }); - AddStep("select the first beatmap of import", () => Beatmap.Value = manager.GetWorkingBeatmap(imported.Beatmaps.First())); + AddStep("select the first beatmap of import", () => Beatmap.Value = manager.GetWorkingBeatmap(imported?.Beatmaps.First())); - DrawableCarouselBeatmapSet set = null; + DrawableCarouselBeatmapSet? set = null; AddUntilStep("Find the DrawableCarouselBeatmapSet", () => { - set = songSelect.Carousel.ChildrenOfType().FirstOrDefault(); + set = songSelect!.Carousel.ChildrenOfType().FirstOrDefault(); return set != null; }); - GroupedDifficultyIcon groupIcon = null; + GroupedDifficultyIcon groupIcon = null!; + AddUntilStep("Find group icon for different ruleset", () => { - return (groupIcon = set.ChildrenOfType() - .FirstOrDefault(icon => icon.Items.First().BeatmapInfo.Ruleset.OnlineID == 3)) != null; + var foundIcon = set.ChildrenOfType() + .FirstOrDefault(icon => icon.Items.First().BeatmapInfo.Ruleset.OnlineID == 3); + + if (foundIcon == null) + return false; + + groupIcon = foundIcon; + return true; }); AddAssert("Check ruleset is osu!", () => Ruleset.Value.OnlineID == 0); @@ -1004,7 +1031,7 @@ namespace osu.Game.Tests.Visual.SongSelect // this ruleset change should be overridden by the present. Ruleset.Value = getSwitchBeatmap().Ruleset; - songSelect.PresentScore(new ScoreInfo + songSelect!.PresentScore(new ScoreInfo { User = new APIUser { Username = "woo" }, BeatmapInfo = getPresentBeatmap(), @@ -1012,7 +1039,7 @@ namespace osu.Game.Tests.Visual.SongSelect }); }); - AddUntilStep("wait for results screen presented", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for results screen presented", () => !songSelect!.IsCurrentScreen()); AddAssert("check beatmap is correct for score", () => Beatmap.Value.BeatmapInfo.MatchesOnlineID(getPresentBeatmap())); AddAssert("check ruleset is correct for score", () => Ruleset.Value.OnlineID == 0); @@ -1038,10 +1065,10 @@ namespace osu.Game.Tests.Visual.SongSelect // this beatmap change should be overridden by the present. Beatmap.Value = manager.GetWorkingBeatmap(getSwitchBeatmap()); - songSelect.PresentScore(TestResources.CreateTestScoreInfo(getPresentBeatmap())); + songSelect!.PresentScore(TestResources.CreateTestScoreInfo(getPresentBeatmap())); }); - AddUntilStep("wait for results screen presented", () => !songSelect.IsCurrentScreen()); + AddUntilStep("wait for results screen presented", () => !songSelect!.IsCurrentScreen()); AddAssert("check beatmap is correct for score", () => Beatmap.Value.BeatmapInfo.MatchesOnlineID(getPresentBeatmap())); AddAssert("check ruleset is correct for score", () => Ruleset.Value.OnlineID == 0); @@ -1054,23 +1081,29 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); AddStep("toggle mod overlay on", () => InputManager.Key(Key.F1)); - AddUntilStep("mod overlay shown", () => songSelect.ModSelect.State.Value == Visibility.Visible); + AddUntilStep("mod overlay shown", () => songSelect!.ModSelect.State.Value == Visibility.Visible); AddStep("toggle mod overlay off", () => InputManager.Key(Key.F1)); - AddUntilStep("mod overlay hidden", () => songSelect.ModSelect.State.Value == Visibility.Hidden); + AddUntilStep("mod overlay hidden", () => songSelect!.ModSelect.State.Value == Visibility.Hidden); } private void waitForInitialSelection() { AddUntilStep("wait for initial selection", () => !Beatmap.IsDefault); - AddUntilStep("wait for difficulty panels visible", () => songSelect.Carousel.ChildrenOfType().Any()); + AddUntilStep("wait for difficulty panels visible", () => songSelect!.Carousel.ChildrenOfType().Any()); } private int getBeatmapIndex(BeatmapSetInfo set, BeatmapInfo info) => set.Beatmaps.IndexOf(info); - private NoResultsPlaceholder getPlaceholder() => songSelect.ChildrenOfType().FirstOrDefault(); + private NoResultsPlaceholder? getPlaceholder() => songSelect!.ChildrenOfType().FirstOrDefault(); - private int getCurrentBeatmapIndex() => getBeatmapIndex(songSelect.Carousel.SelectedBeatmapSet, songSelect.Carousel.SelectedBeatmapInfo); + private int getCurrentBeatmapIndex() + { + Debug.Assert(songSelect!.Carousel.SelectedBeatmapSet != null); + Debug.Assert(songSelect!.Carousel.SelectedBeatmapInfo != null); + + return getBeatmapIndex(songSelect!.Carousel.SelectedBeatmapSet, songSelect!.Carousel.SelectedBeatmapInfo); + } private int getDifficultyIconIndex(DrawableCarouselBeatmapSet set, FilterableDifficultyIcon icon) { @@ -1079,14 +1112,14 @@ namespace osu.Game.Tests.Visual.SongSelect private void addRulesetImportStep(int id) { - Live imported = null; + Live? imported = null; AddStep($"import test map for ruleset {id}", () => imported = importForRuleset(id)); // This is specifically for cases where the add is happening post song select load. // For cases where song select is null, the assertions are provided by the load checks. - AddUntilStep("wait for imported to arrive in carousel", () => songSelect == null || songSelect.Carousel.BeatmapSets.Any(s => s.ID == imported?.ID)); + AddUntilStep("wait for imported to arrive in carousel", () => songSelect == null || songSelect!.Carousel.BeatmapSets.Any(s => s.ID == imported?.ID)); } - private Live importForRuleset(int id) => manager.Import(TestResources.CreateTestBeatmapSetInfo(3, rulesets.AvailableRulesets.Where(r => r.OnlineID == id).ToArray())); + private Live? importForRuleset(int id) => manager.Import(TestResources.CreateTestBeatmapSetInfo(3, rulesets.AvailableRulesets.Where(r => r.OnlineID == id).ToArray())); private void checkMusicPlaying(bool playing) => AddUntilStep($"music {(playing ? "" : "not ")}playing", () => music.IsPlaying == playing); @@ -1098,8 +1131,8 @@ namespace osu.Game.Tests.Visual.SongSelect private void createSongSelect() { AddStep("create song select", () => LoadScreen(songSelect = new TestSongSelect())); - AddUntilStep("wait for present", () => songSelect.IsCurrentScreen()); - AddUntilStep("wait for carousel loaded", () => songSelect.Carousel.IsAlive); + AddUntilStep("wait for present", () => songSelect!.IsCurrentScreen()); + AddUntilStep("wait for carousel loaded", () => songSelect!.Carousel.IsAlive); } /// @@ -1123,12 +1156,14 @@ namespace osu.Game.Tests.Visual.SongSelect protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - rulesets?.Dispose(); + + if (rulesets.IsNotNull()) + rulesets.Dispose(); } private class TestSongSelect : PlaySongSelect { - public Action StartRequested; + public Action? StartRequested; public new Bindable Ruleset => base.Ruleset; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSizePreservingSpriteText.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSizePreservingSpriteText.cs new file mode 100644 index 0000000000..c4568d9aeb --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSizePreservingSpriteText.cs @@ -0,0 +1,94 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Globalization; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneSizePreservingSpriteText : OsuGridTestScene + { + private readonly List parentContainers = new List(); + private readonly List childContainers = new List(); + private readonly OsuSpriteText osuSpriteText = new OsuSpriteText(); + private readonly SizePreservingSpriteText sizePreservingSpriteText = new SizePreservingSpriteText(); + + public TestSceneSizePreservingSpriteText() + : base(1, 2) + { + for (int i = 0; i < 2; i++) + { + UprightAspectMaintainingContainer childContainer; + Container parentContainer = new Container + { + Origin = Anchor.BottomRight, + Anchor = Anchor.BottomCentre, + AutoSizeAxes = Axes.Both, + Rotation = 45, + Y = -200, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Red, + }, + childContainer = new UprightAspectMaintainingContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Blue, + }, + } + }, + } + }; + + Container cellInfo = new Container + { + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + Margin = new MarginPadding + { + Top = 100, + }, + Child = new OsuSpriteText + { + Text = (i == 0) ? "OsuSpriteText" : "SizePreservingSpriteText", + Font = OsuFont.GetFont(Typeface.Inter, weight: FontWeight.Bold, size: 40), + Origin = Anchor.TopCentre, + Anchor = Anchor.TopCentre, + }, + }; + + parentContainers.Add(parentContainer); + childContainers.Add(childContainer); + Cell(i).Add(cellInfo); + Cell(i).Add(parentContainer); + } + + childContainers[0].Add(osuSpriteText); + childContainers[1].Add(sizePreservingSpriteText); + osuSpriteText.Font = sizePreservingSpriteText.Font = OsuFont.GetFont(Typeface.Venera, weight: FontWeight.Bold, size: 20); + } + + protected override void Update() + { + base.Update(); + osuSpriteText.Text = sizePreservingSpriteText.Text = DateTime.Now.ToString(CultureInfo.InvariantCulture); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneUprightAspectMaintainingContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneUprightAspectMaintainingContainer.cs new file mode 100644 index 0000000000..67c26829df --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneUprightAspectMaintainingContainer.cs @@ -0,0 +1,244 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics; +using osu.Framework.Utils; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics; +using osuTK.Graphics; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneUprightAspectMaintainingContainer : OsuGridTestScene + { + private const int rows = 3; + private const int columns = 4; + + private readonly ScaleMode[] scaleModeValues = { ScaleMode.NoScaling, ScaleMode.Horizontal, ScaleMode.Vertical }; + private readonly float[] scalingFactorValues = { 1.0f / 3, 1.0f / 2, 1.0f, 1.5f }; + + private readonly List> parentContainers = new List>(rows); + private readonly List> childContainers = new List>(rows); + + // Preferably should be set to (4 * 2^n) + private const int rotation_step_count = 3; + + private readonly List flipStates = new List(); + private readonly List rotationSteps = new List(); + private readonly List scaleSteps = new List(); + + public TestSceneUprightAspectMaintainingContainer() + : base(rows, columns) + { + for (int i = 0; i < rows; i++) + { + parentContainers.Add(new List()); + childContainers.Add(new List()); + + for (int j = 0; j < columns; j++) + { + UprightAspectMaintainingContainer child; + Container parent = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Height = 80, + Width = 80, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = new Color4(255, 0, 0, 160), + }, + new OsuSpriteText + { + Text = "Parent", + }, + child = new UprightAspectMaintainingContainer + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + AutoSizeAxes = Axes.Both, + + // These are the parameters being Tested + Scaling = scaleModeValues[i], + ScalingFactor = scalingFactorValues[j], + + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = new Color4(0, 0, 255, 160), + }, + new OsuSpriteText + { + Text = "Text", + Font = OsuFont.Numeric, + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Padding = new MarginPadding + { + Horizontal = 4, + Vertical = 4, + } + }, + } + } + } + }; + + Container cellInfo = new Container + { + Children = new Drawable[] + { + new OsuSpriteText + { + Text = "Scaling: " + scaleModeValues[i].ToString(), + }, + new OsuSpriteText + { + Text = "ScalingFactor: " + scalingFactorValues[j].ToString("0.00"), + Margin = new MarginPadding + { + Top = 15, + }, + }, + }, + }; + + Cell(i * columns + j).Add(cellInfo); + Cell(i * columns + j).Add(parent); + parentContainers[i].Add(parent); + childContainers[i].Add(child); + } + } + + flipStates.AddRange(new[] { 1, -1 }); + rotationSteps.AddRange(Enumerable.Range(0, rotation_step_count).Select(x => 360f * ((float)x / rotation_step_count))); + scaleSteps.AddRange(new[] { 1, 0.3f, 1.5f }); + } + + [Test] + public void ExplicitlySizedParent() + { + var parentStates = from xFlip in flipStates + from yFlip in flipStates + from xScale in scaleSteps + from yScale in scaleSteps + from rotation in rotationSteps + select new { xFlip, yFlip, xScale, yScale, rotation }; + + foreach (var state in parentStates) + { + Vector2 parentScale = new Vector2(state.xFlip * state.xScale, state.yFlip * state.yScale); + float parentRotation = state.rotation; + + AddStep("S: (" + parentScale.X.ToString("0.00") + ", " + parentScale.Y.ToString("0.00") + "), R: " + parentRotation.ToString("0.00"), () => + { + foreach (List list in parentContainers) + { + foreach (Container container in list) + { + container.Scale = parentScale; + container.Rotation = parentRotation; + } + } + }); + + AddAssert("Check if state is valid", () => + { + foreach (int i in Enumerable.Range(0, parentContainers.Count)) + { + foreach (int j in Enumerable.Range(0, parentContainers[i].Count)) + { + if (!uprightAspectMaintainingContainerStateIsValid(parentContainers[i][j], childContainers[i][j])) + return false; + } + } + + return true; + }); + } + } + + private bool uprightAspectMaintainingContainerStateIsValid(Container parent, UprightAspectMaintainingContainer child) + { + Matrix3 parentMatrix = parent.DrawInfo.Matrix; + Matrix3 childMatrix = child.DrawInfo.Matrix; + Vector3 childScale = childMatrix.ExtractScale(); + Vector3 parentScale = parentMatrix.ExtractScale(); + + // Orientation check + if (!(isNearlyZero(MathF.Abs(childMatrix.M21)) && isNearlyZero(MathF.Abs(childMatrix.M12)))) + return false; + + // flip check + if (!(childMatrix.M11 * childMatrix.M22 > 0)) + return false; + + // Aspect ratio check + if (!isNearlyZero(childScale.X - childScale.Y)) + return false; + + // ScalingMode check + switch (child.Scaling) + { + case ScaleMode.NoScaling: + if (!(isNearlyZero(childMatrix.M11 - 1.0f) && isNearlyZero(childMatrix.M22 - 1.0f))) + return false; + + break; + + case ScaleMode.Vertical: + if (!(checkScaling(child.ScalingFactor, parentScale.Y, childScale.Y))) + return false; + + break; + + case ScaleMode.Horizontal: + if (!(checkScaling(child.ScalingFactor, parentScale.X, childScale.X))) + return false; + + break; + } + + return true; + } + + private bool checkScaling(float scalingFactor, float parentScale, float childScale) + { + if (scalingFactor <= 1.0f) + { + if (!isNearlyZero(1.0f + (parentScale - 1.0f) * scalingFactor - childScale)) + return false; + } + else if (scalingFactor > 1.0f) + { + if (parentScale < 1.0f) + { + if (!isNearlyZero((parentScale * (1.0f / scalingFactor)) - childScale)) + return false; + } + else if (!isNearlyZero(parentScale * scalingFactor - childScale)) + return false; + } + + return true; + } + + private bool isNearlyZero(float f, float epsilon = Precision.FLOAT_EPSILON) + { + return f < epsilon; + } + } +} diff --git a/osu.Game.Tournament/Models/TournamentMatch.cs b/osu.Game.Tournament/Models/TournamentMatch.cs index 2f72dc9257..97c2060f2c 100644 --- a/osu.Game.Tournament/Models/TournamentMatch.cs +++ b/osu.Game.Tournament/Models/TournamentMatch.cs @@ -106,13 +106,16 @@ namespace osu.Game.Tournament.Models } /// - /// Initialise this match with zeroed scores. Will be a noop if either team is not present. + /// Initialise this match with zeroed scores. Will be a noop if either team is not present or if either of the scores are non-zero. /// public void StartMatch() { if (Team1.Value == null || Team2.Value == null) return; + if (Team1Score.Value > 0 || Team2Score.Value > 0) + return; + Team1Score.Value = 0; Team2Score.Value = 0; } diff --git a/osu.Game/BackgroundBeatmapProcessor.cs b/osu.Game/BackgroundBeatmapProcessor.cs index 14fdb2e1ef..ea5904a8d3 100644 --- a/osu.Game/BackgroundBeatmapProcessor.cs +++ b/osu.Game/BackgroundBeatmapProcessor.cs @@ -7,6 +7,7 @@ using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Newtonsoft.Json; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -14,6 +15,7 @@ using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Rulesets; +using osu.Game.Scoring; using osu.Game.Screens.Play; namespace osu.Game @@ -23,6 +25,9 @@ namespace osu.Game [Resolved] private RulesetStore rulesetStore { get; set; } = null!; + [Resolved] + private ScoreManager scoreManager { get; set; } = null!; + [Resolved] private RealmAccess realmAccess { get; set; } = null!; @@ -46,6 +51,7 @@ namespace osu.Game Logger.Log("Beginning background beatmap processing.."); checkForOutdatedStarRatings(); processBeatmapSetsWithMissingMetrics(); + processScoresWithMissingStatistics(); }).ContinueWith(t => { if (t.Exception?.InnerException is ObjectDisposedException) @@ -140,5 +146,52 @@ namespace osu.Game }); } } + + private void processScoresWithMissingStatistics() + { + HashSet scoreIds = new HashSet(); + + Logger.Log("Querying for scores to reprocess..."); + + realmAccess.Run(r => + { + foreach (var score in r.All()) + { + if (score.Statistics.Sum(kvp => kvp.Value) > 0 && score.MaximumStatistics.Sum(kvp => kvp.Value) == 0) + scoreIds.Add(score.ID); + } + }); + + Logger.Log($"Found {scoreIds.Count} scores which require reprocessing."); + + foreach (var id in scoreIds) + { + while (localUserPlayInfo?.IsPlaying.Value == true) + { + Logger.Log("Background processing sleeping due to active gameplay..."); + Thread.Sleep(TimeToSleepDuringGameplay); + } + + try + { + var score = scoreManager.Query(s => s.ID == id); + + scoreManager.PopulateMaximumStatistics(score); + + // Can't use async overload because we're not on the update thread. + // ReSharper disable once MethodHasAsyncOverload + realmAccess.Write(r => + { + r.Find(id).MaximumStatisticsJson = JsonConvert.SerializeObject(score.MaximumStatistics); + }); + + Logger.Log($"Populated maximum statistics for score {id}"); + } + catch (Exception e) + { + Logger.Log(@$"Failed to populate maximum statistics for {id}: {e}"); + } + } + } } } diff --git a/osu.Game/Beatmaps/Drawables/Cards/Buttons/DownloadButton.cs b/osu.Game/Beatmaps/Drawables/Cards/Buttons/DownloadButton.cs index fdb43cb47e..1b15b2498c 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Buttons/DownloadButton.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Buttons/DownloadButton.cs @@ -17,8 +17,7 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons { public class DownloadButton : BeatmapCardIconButton { - public IBindable State => state; - private readonly Bindable state = new Bindable(); + public Bindable State { get; } = new Bindable(); private readonly APIBeatmapSet beatmapSet; @@ -48,14 +47,19 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons { base.LoadComplete(); preferNoVideo.BindValueChanged(_ => updateState()); - state.BindValueChanged(_ => updateState(), true); + State.BindValueChanged(_ => updateState(), true); FinishTransforms(true); } private void updateState() { - switch (state.Value) + switch (State.Value) { + case DownloadState.Unknown: + Action = null; + TooltipText = string.Empty; + break; + case DownloadState.Downloading: case DownloadState.Importing: Action = null; diff --git a/osu.Game/Beatmaps/Drawables/Cards/Buttons/PlayButton.cs b/osu.Game/Beatmaps/Drawables/Cards/Buttons/PlayButton.cs index 8ab632a757..c5436182a4 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/Buttons/PlayButton.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/Buttons/PlayButton.cs @@ -41,6 +41,9 @@ namespace osu.Game.Beatmaps.Drawables.Cards.Buttons Anchor = Origin = Anchor.Centre; + // needed for touch input to work when card is not hovered/expanded + AlwaysPresent = true; + Children = new Drawable[] { icon = new SpriteIcon diff --git a/osu.Game/Beatmaps/FramedBeatmapClock.cs b/osu.Game/Beatmaps/FramedBeatmapClock.cs index a4787a34e8..c7050cc50f 100644 --- a/osu.Game/Beatmaps/FramedBeatmapClock.cs +++ b/osu.Game/Beatmaps/FramedBeatmapClock.cs @@ -121,7 +121,18 @@ namespace osu.Game.Beatmaps protected override void Update() { base.Update(); - finalClockSource.ProcessFrame(); + + if (Source != null && Source is not IAdjustableClock && Source.CurrentTime < decoupledClock.CurrentTime) + { + // InterpolatingFramedClock won't interpolate backwards unless its source has an ElapsedFrameTime. + // See https://github.com/ppy/osu-framework/blob/ba1385330cc501f34937e08257e586c84e35d772/osu.Framework/Timing/InterpolatingFramedClock.cs#L91-L93 + // This is not always the case here when doing large seeks. + // (Of note, this is not an issue if the source is adjustable, as the source is seeked to be in time by DecoupleableInterpolatingFramedClock). + // Rather than trying to get around this by fixing the framework clock stack, let's work around it for now. + Seek(Source.CurrentTime); + } + else + finalClockSource.ProcessFrame(); } public double TotalAppliedOffset diff --git a/osu.Game/Graphics/Containers/UprightAspectMaintainingContainer.cs b/osu.Game/Graphics/Containers/UprightAspectMaintainingContainer.cs new file mode 100644 index 0000000000..300b5bd4b4 --- /dev/null +++ b/osu.Game/Graphics/Containers/UprightAspectMaintainingContainer.cs @@ -0,0 +1,119 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Layout; +using osuTK; + +namespace osu.Game.Graphics.Containers +{ + /// + /// A container that reverts any rotation (and optionally scale) applied by its direct parent. + /// + public class UprightAspectMaintainingContainer : Container + { + /// + /// Controls how much this container scales compared to its parent (default is 1.0f). + /// + public float ScalingFactor { get; set; } = 1; + + /// + /// Controls the scaling of this container. + /// + public ScaleMode Scaling { get; set; } = ScaleMode.Vertical; + + private readonly LayoutValue layout = new LayoutValue(Invalidation.DrawInfo, InvalidationSource.Parent); + + public UprightAspectMaintainingContainer() + { + AddLayout(layout); + } + + protected override void Update() + { + base.Update(); + + if (!layout.IsValid) + { + keepUprightAndUnstretched(); + layout.Validate(); + } + } + + /// + /// Keeps the drawable upright and unstretched preventing it from being rotated, sheared, scaled or flipped with its Parent. + /// + private void keepUprightAndUnstretched() + { + // Decomposes the inverse of the parent DrawInfo.Matrix into rotation, shear and scale. + var parentMatrix = Parent.DrawInfo.Matrix; + + // Remove Translation.> + parentMatrix.M31 = 0.0f; + parentMatrix.M32 = 0.0f; + + Matrix3 reversedParent = parentMatrix.Inverted(); + + // Extract the rotation. + float angle = MathF.Atan2(reversedParent.M12, reversedParent.M11); + Rotation = MathHelper.RadiansToDegrees(angle); + + // Remove rotation from the C matrix so that it only contains shear and scale. + Matrix3 m = Matrix3.CreateRotationZ(-angle); + reversedParent *= m; + + // Extract shear. + float alpha = reversedParent.M21 / reversedParent.M22; + Shear = new Vector2(-alpha, 0); + + // Etract scale. + float sx = reversedParent.M11; + float sy = reversedParent.M22; + + Vector3 parentScale = parentMatrix.ExtractScale(); + + float usedScale = 1.0f; + + switch (Scaling) + { + case ScaleMode.Horizontal: + usedScale = parentScale.X; + break; + + case ScaleMode.Vertical: + usedScale = parentScale.Y; + break; + } + + if (Scaling != ScaleMode.NoScaling) + { + if (ScalingFactor < 1.0f) + usedScale = 1.0f + (usedScale - 1.0f) * ScalingFactor; + if (ScalingFactor > 1.0f) + usedScale = (usedScale < 1.0f) ? usedScale * (1.0f / ScalingFactor) : usedScale * ScalingFactor; + } + + Scale = new Vector2(sx * usedScale, sy * usedScale); + } + } + + public enum ScaleMode + { + /// + /// Prevent this container from scaling. + /// + NoScaling, + + /// + /// Scale uniformly (maintaining aspect ratio) based on the vertical scale of the parent. + /// + Vertical, + + /// + /// Scale uniformly (maintaining aspect ratio) based on the horizontal scale of the parent. + /// + Horizontal, + } +} diff --git a/osu.Game/Graphics/OsuColour.cs b/osu.Game/Graphics/OsuColour.cs index 6e2f460930..91161d5c71 100644 --- a/osu.Game/Graphics/OsuColour.cs +++ b/osu.Game/Graphics/OsuColour.cs @@ -102,26 +102,31 @@ namespace osu.Game.Graphics /// /// Retrieves the colour for a . /// - public Color4 ForHitResult(HitResult judgement) + public Color4 ForHitResult(HitResult result) { - switch (judgement) + switch (result) { - case HitResult.Perfect: - case HitResult.Great: - return Blue; - - case HitResult.Ok: - case HitResult.Good: - return Green; + case HitResult.SmallTickMiss: + case HitResult.LargeTickMiss: + case HitResult.Miss: + return Red; case HitResult.Meh: return Yellow; - case HitResult.Miss: - return Red; + case HitResult.Ok: + return Green; + + case HitResult.Good: + return GreenLight; + + case HitResult.SmallTickHit: + case HitResult.LargeTickHit: + case HitResult.Great: + return Blue; default: - return Color4.White; + return BlueLight; } } diff --git a/osu.Game/Graphics/Sprites/SizePreservingSpriteText.cs b/osu.Game/Graphics/Sprites/SizePreservingSpriteText.cs new file mode 100644 index 0000000000..baffe106ed --- /dev/null +++ b/osu.Game/Graphics/Sprites/SizePreservingSpriteText.cs @@ -0,0 +1,108 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Graphics.Sprites +{ + /// + /// A wrapped version of which will expand in size based on text content, but never shrink back down. + /// + public class SizePreservingSpriteText : CompositeDrawable + { + private readonly OsuSpriteText text = new OsuSpriteText(); + + private Vector2 maximumSize; + + public SizePreservingSpriteText(Vector2? minimumSize = null) + { + text.Origin = Anchor.Centre; + text.Anchor = Anchor.Centre; + + AddInternal(text); + maximumSize = minimumSize ?? Vector2.Zero; + } + + protected override void Update() + { + Width = maximumSize.X = MathF.Max(maximumSize.X, text.Width); + Height = maximumSize.Y = MathF.Max(maximumSize.Y, text.Height); + } + + public new Axes AutoSizeAxes + { + get => Axes.None; + set => throw new InvalidOperationException("You can't set AutoSizeAxes of this container"); + } + + /// + /// Gets or sets the text to be displayed. + /// + public LocalisableString Text + { + get => text.Text; + set => text.Text = value; + } + + /// + /// Contains information on the font used to display the text. + /// + public FontUsage Font + { + get => text.Font; + set => text.Font = value; + } + + /// + /// True if a shadow should be displayed around the text. + /// + public bool Shadow + { + get => text.Shadow; + set => text.Shadow = value; + } + + /// + /// The colour of the shadow displayed around the text. A shadow will only be displayed if the property is set to true. + /// + public Color4 ShadowColour + { + get => text.ShadowColour; + set => text.ShadowColour = value; + } + + /// + /// The offset of the shadow displayed around the text. A shadow will only be displayed if the property is set to true. + /// + public Vector2 ShadowOffset + { + get => text.ShadowOffset; + set => text.ShadowOffset = value; + } + + /// + /// True if the 's vertical size should be equal to (the full height) or precisely the size of used characters. + /// Set to false to allow better centering of individual characters/numerals/etc. + /// + public bool UseFullGlyphHeight + { + get => text.UseFullGlyphHeight; + set => text.UseFullGlyphHeight = value; + } + + public override bool IsPresent => text.IsPresent; + + public override string ToString() => text.ToString(); + + public float LineBaseHeight => text.LineBaseHeight; + + public IEnumerable FilterTerms => text.FilterTerms; + } +} diff --git a/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs b/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs index d7b97cdddf..2c9f250028 100644 --- a/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs +++ b/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs @@ -81,12 +81,12 @@ namespace osu.Game.Online.API.Requests.Responses public int? LegacyTotalScore { get; set; } [JsonProperty("legacy_score_id")] - public uint? LegacyScoreId { get; set; } + public ulong? LegacyScoreId { get; set; } #region osu-web API additions (not stored to database). [JsonProperty("id")] - public long? ID { get; set; } + public ulong? ID { get; set; } [JsonProperty("user")] public APIUser? User { get; set; } @@ -190,6 +190,6 @@ namespace osu.Game.Online.API.Requests.Responses MaximumStatistics = score.MaximumStatistics.Where(kvp => kvp.Value != 0).ToDictionary(kvp => kvp.Key, kvp => kvp.Value), }; - public long OnlineID => ID ?? -1; + public long OnlineID => (long?)ID ?? -1; } } diff --git a/osu.Game/Online/DownloadState.cs b/osu.Game/Online/DownloadState.cs index a58c40d16a..f4ecb28b90 100644 --- a/osu.Game/Online/DownloadState.cs +++ b/osu.Game/Online/DownloadState.cs @@ -5,6 +5,7 @@ namespace osu.Game.Online { public enum DownloadState { + Unknown, NotDownloaded, Downloading, Importing, diff --git a/osu.Game/Online/Multiplayer/Countdown/CountdownChangedEvent.cs b/osu.Game/Online/Multiplayer/Countdown/CountdownChangedEvent.cs deleted file mode 100644 index 649e3c8389..0000000000 --- a/osu.Game/Online/Multiplayer/Countdown/CountdownChangedEvent.cs +++ /dev/null @@ -1,20 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using MessagePack; - -namespace osu.Game.Online.Multiplayer.Countdown -{ - /// - /// Indicates a change to the 's countdown. - /// - [MessagePackObject] - public class CountdownChangedEvent : MatchServerEvent - { - /// - /// The new countdown. - /// - [Key(0)] - public MultiplayerCountdown? Countdown { get; set; } - } -} diff --git a/osu.Game/Online/Multiplayer/Countdown/CountdownStartedEvent.cs b/osu.Game/Online/Multiplayer/Countdown/CountdownStartedEvent.cs new file mode 100644 index 0000000000..1dbb27bce6 --- /dev/null +++ b/osu.Game/Online/Multiplayer/Countdown/CountdownStartedEvent.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using MessagePack; +using Newtonsoft.Json; + +namespace osu.Game.Online.Multiplayer.Countdown +{ + /// + /// Indicates that a countdown started in the . + /// + [MessagePackObject] + public class CountdownStartedEvent : MatchServerEvent + { + /// + /// The countdown that was started. + /// + [Key(0)] + public readonly MultiplayerCountdown Countdown; + + [JsonConstructor] + [SerializationConstructor] + public CountdownStartedEvent(MultiplayerCountdown countdown) + { + Countdown = countdown; + } + } +} diff --git a/osu.Game/Online/Multiplayer/Countdown/CountdownStoppedEvent.cs b/osu.Game/Online/Multiplayer/Countdown/CountdownStoppedEvent.cs new file mode 100644 index 0000000000..b46ed0e5e0 --- /dev/null +++ b/osu.Game/Online/Multiplayer/Countdown/CountdownStoppedEvent.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using MessagePack; +using Newtonsoft.Json; + +namespace osu.Game.Online.Multiplayer.Countdown +{ + /// + /// Indicates that a countdown was stopped in the . + /// + [MessagePackObject] + public class CountdownStoppedEvent : MatchServerEvent + { + /// + /// The identifier of the countdown that was stopped. + /// + [Key(0)] + public readonly int ID; + + [JsonConstructor] + [SerializationConstructor] + public CountdownStoppedEvent(int id) + { + ID = id; + } + } +} diff --git a/osu.Game/Online/Multiplayer/Countdown/StopCountdownRequest.cs b/osu.Game/Online/Multiplayer/Countdown/StopCountdownRequest.cs index bd0c381c0b..495252c044 100644 --- a/osu.Game/Online/Multiplayer/Countdown/StopCountdownRequest.cs +++ b/osu.Game/Online/Multiplayer/Countdown/StopCountdownRequest.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using MessagePack; +using Newtonsoft.Json; namespace osu.Game.Online.Multiplayer.Countdown { @@ -11,5 +12,14 @@ namespace osu.Game.Online.Multiplayer.Countdown [MessagePackObject] public class StopCountdownRequest : MatchUserRequest { + [Key(0)] + public readonly int ID; + + [JsonConstructor] + [SerializationConstructor] + public StopCountdownRequest(int id) + { + ID = id; + } } } diff --git a/osu.Game/Online/Multiplayer/ForceGameplayStartCountdown.cs b/osu.Game/Online/Multiplayer/ForceGameplayStartCountdown.cs index 7f5c0f0a05..bbfc5a02c6 100644 --- a/osu.Game/Online/Multiplayer/ForceGameplayStartCountdown.cs +++ b/osu.Game/Online/Multiplayer/ForceGameplayStartCountdown.cs @@ -13,7 +13,7 @@ namespace osu.Game.Online.Multiplayer /// and forcing progression of any clients that are blocking load due to user interaction. /// [MessagePackObject] - public class ForceGameplayStartCountdown : MultiplayerCountdown + public sealed class ForceGameplayStartCountdown : MultiplayerCountdown { } } diff --git a/osu.Game/Online/Multiplayer/MatchServerEvent.cs b/osu.Game/Online/Multiplayer/MatchServerEvent.cs index 20bf9e5141..376ff4d261 100644 --- a/osu.Game/Online/Multiplayer/MatchServerEvent.cs +++ b/osu.Game/Online/Multiplayer/MatchServerEvent.cs @@ -13,7 +13,8 @@ namespace osu.Game.Online.Multiplayer [Serializable] [MessagePackObject] // IMPORTANT: Add rules to SignalRUnionWorkaroundResolver for new derived types. - [Union(0, typeof(CountdownChangedEvent))] + [Union(0, typeof(CountdownStartedEvent))] + [Union(1, typeof(CountdownStoppedEvent))] public abstract class MatchServerEvent { } diff --git a/osu.Game/Online/Multiplayer/MatchStartCountdown.cs b/osu.Game/Online/Multiplayer/MatchStartCountdown.cs index 5d3365c947..fe65ebb059 100644 --- a/osu.Game/Online/Multiplayer/MatchStartCountdown.cs +++ b/osu.Game/Online/Multiplayer/MatchStartCountdown.cs @@ -9,7 +9,7 @@ namespace osu.Game.Online.Multiplayer /// A which will start the match after ending. /// [MessagePackObject] - public class MatchStartCountdown : MultiplayerCountdown + public sealed class MatchStartCountdown : MultiplayerCountdown { } } diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 364309ffe4..c398d72118 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -552,8 +552,14 @@ namespace osu.Game.Online.Multiplayer switch (e) { - case CountdownChangedEvent countdownChangedEvent: - Room.Countdown = countdownChangedEvent.Countdown; + case CountdownStartedEvent countdownStartedEvent: + Room.ActiveCountdowns.Add(countdownStartedEvent.Countdown); + break; + + case CountdownStoppedEvent countdownStoppedEvent: + MultiplayerCountdown? countdown = Room.ActiveCountdowns.FirstOrDefault(countdown => countdown.ID == countdownStoppedEvent.ID); + if (countdown != null) + Room.ActiveCountdowns.Remove(countdown); break; } diff --git a/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs b/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs index 621f9236fd..61637ae970 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerCountdown.cs @@ -15,13 +15,24 @@ namespace osu.Game.Online.Multiplayer [Union(1, typeof(ForceGameplayStartCountdown))] public abstract class MultiplayerCountdown { + /// + /// A unique identifier for this countdown. + /// + [Key(0)] + public int ID { get; set; } + /// /// The amount of time remaining in the countdown. /// /// - /// This is only sent once from the server upon initial retrieval of the or via a . + /// This is only sent once from the server upon initial retrieval of the or via a . /// - [Key(0)] + [Key(1)] public TimeSpan TimeRemaining { get; set; } + + /// + /// Whether only a single instance of this type may be active at any one time. + /// + public virtual bool IsExclusive => true; } } diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs index fb05c03256..00048fa931 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs @@ -53,10 +53,10 @@ namespace osu.Game.Online.Multiplayer public IList Playlist { get; set; } = new List(); /// - /// The currently-running countdown. + /// The currently running countdowns. /// [Key(7)] - public MultiplayerCountdown? Countdown { get; set; } + public IList ActiveCountdowns { get; set; } = new List(); [JsonConstructor] [SerializationConstructor] diff --git a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs index bb8ec4f6ff..7f8f9703e4 100644 --- a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs @@ -114,6 +114,7 @@ namespace osu.Game.Online.Rooms switch (downloadTracker.State.Value) { + case DownloadState.Unknown: case DownloadState.NotDownloaded: availability.Value = BeatmapAvailability.NotDownloaded(); break; diff --git a/osu.Game/Online/SignalRWorkaroundTypes.cs b/osu.Game/Online/SignalRWorkaroundTypes.cs index 29e01e13ae..3518fbb4fe 100644 --- a/osu.Game/Online/SignalRWorkaroundTypes.cs +++ b/osu.Game/Online/SignalRWorkaroundTypes.cs @@ -23,7 +23,8 @@ namespace osu.Game.Online (typeof(ChangeTeamRequest), typeof(MatchUserRequest)), (typeof(StartMatchCountdownRequest), typeof(MatchUserRequest)), (typeof(StopCountdownRequest), typeof(MatchUserRequest)), - (typeof(CountdownChangedEvent), typeof(MatchServerEvent)), + (typeof(CountdownStartedEvent), typeof(MatchServerEvent)), + (typeof(CountdownStoppedEvent), typeof(MatchServerEvent)), (typeof(TeamVersusRoomState), typeof(MatchRoomState)), (typeof(TeamVersusUserState), typeof(MatchUserState)), (typeof(MatchStartCountdown), typeof(MultiplayerCountdown)), diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 108153fd9d..9e2384322a 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -839,7 +839,9 @@ namespace osu.Game OnHome = delegate { CloseAllOverlays(false); - menuScreen?.MakeCurrent(); + + if (menuScreen?.GetChildScreen() != null) + menuScreen.MakeCurrent(); }, }, topMostOverlayContent.Add); diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index c95a281f09..97142d5472 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -390,11 +390,6 @@ namespace osu.Game var framedClock = new FramedClock(beatmap.Track); beatmapClock.ChangeSource(framedClock); - - // Normally the internal decoupled clock will seek the *track* to the decoupled time, but we blocked this. - // It won't behave nicely unless we also set it to the track's time. - // Probably another thing which should be fixed in the decoupled mess (or just replaced). - beatmapClock.Seek(beatmap.Track.CurrentTime); } protected virtual void InitialiseFonts() diff --git a/osu.Game/Rulesets/Mods/ModTimeRamp.cs b/osu.Game/Rulesets/Mods/ModTimeRamp.cs index 7031489d0e..72a7f4b9a3 100644 --- a/osu.Game/Rulesets/Mods/ModTimeRamp.cs +++ b/osu.Game/Rulesets/Mods/ModTimeRamp.cs @@ -19,6 +19,8 @@ namespace osu.Game.Rulesets.Mods /// public const double FINAL_RATE_PROGRESS = 0.75f; + public override double ScoreMultiplier => 0.5; + [SettingSource("Initial rate", "The starting speed of the track")] public abstract BindableNumber InitialRate { get; } diff --git a/osu.Game/Rulesets/Mods/ModWindDown.cs b/osu.Game/Rulesets/Mods/ModWindDown.cs index e84bdab69c..22ed7c2efd 100644 --- a/osu.Game/Rulesets/Mods/ModWindDown.cs +++ b/osu.Game/Rulesets/Mods/ModWindDown.cs @@ -16,7 +16,6 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "WD"; public override LocalisableString Description => "Sloooow doooown..."; public override IconUsage? Icon => FontAwesome.Solid.ChevronCircleDown; - public override double ScoreMultiplier => 1.0; [SettingSource("Initial rate", "The starting speed of the track")] public override BindableNumber InitialRate { get; } = new BindableDouble diff --git a/osu.Game/Rulesets/Mods/ModWindUp.cs b/osu.Game/Rulesets/Mods/ModWindUp.cs index 39cee50f96..13ece6d9a3 100644 --- a/osu.Game/Rulesets/Mods/ModWindUp.cs +++ b/osu.Game/Rulesets/Mods/ModWindUp.cs @@ -16,7 +16,6 @@ namespace osu.Game.Rulesets.Mods public override string Acronym => "WU"; public override LocalisableString Description => "Can you keep up?"; public override IconUsage? Icon => FontAwesome.Solid.ChevronCircleUp; - public override double ScoreMultiplier => 1.0; [SettingSource("Initial rate", "The starting speed of the track")] public override BindableNumber InitialRate { get; } = new BindableDouble diff --git a/osu.Game/Rulesets/Scoring/HitResult.cs b/osu.Game/Rulesets/Scoring/HitResult.cs index 5047fdea82..96e13e5861 100644 --- a/osu.Game/Rulesets/Scoring/HitResult.cs +++ b/osu.Game/Rulesets/Scoring/HitResult.cs @@ -1,13 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; +using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; using System.Linq; using System.Runtime.Serialization; +using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Utils; namespace osu.Game.Rulesets.Scoring @@ -135,6 +135,8 @@ namespace osu.Game.Rulesets.Scoring #pragma warning disable CS0618 public static class HitResultExtensions { + private static readonly IList order = EnumExtensions.GetValuesInOrder().ToList(); + /// /// Whether a increases the combo. /// @@ -282,6 +284,13 @@ namespace osu.Game.Rulesets.Scoring Debug.Assert(minResult <= maxResult); return result > minResult && result < maxResult; } + + /// + /// Ordered index of a . Used for consistent order when displaying hit results to the user. + /// + /// The to get the index of. + /// The index of . + public static int GetIndexForOrderedDisplay(this HitResult result) => order.IndexOf(result); } #pragma warning restore CS0618 } diff --git a/osu.Game/Rulesets/UI/DrawableRuleset.cs b/osu.Game/Rulesets/UI/DrawableRuleset.cs index 59c1146995..73acb1759f 100644 --- a/osu.Game/Rulesets/UI/DrawableRuleset.cs +++ b/osu.Game/Rulesets/UI/DrawableRuleset.cs @@ -30,6 +30,7 @@ using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD.ClicksPerSecond; using osuTK; namespace osu.Game.Rulesets.UI @@ -38,7 +39,7 @@ namespace osu.Game.Rulesets.UI /// Displays an interactive ruleset gameplay instance. /// /// The type of HitObject contained by this DrawableRuleset. - public abstract class DrawableRuleset : DrawableRuleset, IProvideCursor, ICanAttachKeyCounter + public abstract class DrawableRuleset : DrawableRuleset, IProvideCursor, ICanAttachHUDPieces where TObject : HitObject { public override event Action NewResult; @@ -338,7 +339,10 @@ namespace osu.Game.Rulesets.UI public abstract DrawableHitObject CreateDrawableRepresentation(TObject h); public void Attach(KeyCounterDisplay keyCounter) => - (KeyBindingInputManager as ICanAttachKeyCounter)?.Attach(keyCounter); + (KeyBindingInputManager as ICanAttachHUDPieces)?.Attach(keyCounter); + + public void Attach(ClicksPerSecondCalculator calculator) => + (KeyBindingInputManager as ICanAttachHUDPieces)?.Attach(calculator); /// /// Creates a key conversion input manager. An exception will be thrown if a valid is not returned. diff --git a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs index 18d0ff0bed..3b35fba122 100644 --- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs +++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs @@ -2,15 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Diagnostics; -using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Timing; -using osu.Framework.Utils; using osu.Game.Input.Handlers; using osu.Game.Screens.Play; @@ -22,7 +20,7 @@ namespace osu.Game.Rulesets.UI /// [Cached(typeof(IGameplayClock))] [Cached(typeof(IFrameStableClock))] - public sealed class FrameStabilityContainer : Container, IHasReplayHandler, IFrameStableClock, IGameplayClock + public sealed class FrameStabilityContainer : Container, IHasReplayHandler, IFrameStableClock { public ReplayInputHandler? ReplayInputHandler { get; set; } @@ -263,27 +261,11 @@ namespace osu.Game.Rulesets.UI public FrameTimeInfo TimeInfo => framedClock.TimeInfo; - public double TrueGameplayRate - { - get - { - double baseRate = Rate; - - foreach (double adjustment in NonGameplayAdjustments) - { - if (Precision.AlmostEquals(adjustment, 0)) - return 0; - - baseRate /= adjustment; - } - - return baseRate; - } - } - public double StartTime => parentGameplayClock?.StartTime ?? 0; - public IEnumerable NonGameplayAdjustments => parentGameplayClock?.NonGameplayAdjustments ?? Enumerable.Empty(); + private readonly AudioAdjustments gameplayAdjustments = new AudioAdjustments(); + + public IAdjustableAudioComponent AdjustmentsFromMods => parentGameplayClock?.AdjustmentsFromMods ?? gameplayAdjustments; #endregion diff --git a/osu.Game/Rulesets/UI/IFrameStableClock.cs b/osu.Game/Rulesets/UI/IFrameStableClock.cs index 569ef5e06c..4e50d059e9 100644 --- a/osu.Game/Rulesets/UI/IFrameStableClock.cs +++ b/osu.Game/Rulesets/UI/IFrameStableClock.cs @@ -2,11 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; -using osu.Framework.Timing; +using osu.Game.Screens.Play; namespace osu.Game.Rulesets.UI { - public interface IFrameStableClock : IFrameBasedClock + public interface IFrameStableClock : IGameplayClock { IBindable IsCatchingUp { get; } diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs index 7c37913576..1a97153f2f 100644 --- a/osu.Game/Rulesets/UI/RulesetInputManager.cs +++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs @@ -20,11 +20,12 @@ using osu.Game.Input.Bindings; using osu.Game.Input.Handlers; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD.ClicksPerSecond; using static osu.Game.Input.Handlers.ReplayInputHandler; namespace osu.Game.Rulesets.UI { - public abstract class RulesetInputManager : PassThroughInputManager, ICanAttachKeyCounter, IHasReplayHandler, IHasRecordingHandler + public abstract class RulesetInputManager : PassThroughInputManager, ICanAttachHUDPieces, IHasReplayHandler, IHasRecordingHandler where T : struct { public readonly KeyBindingContainer KeyBindingContainer; @@ -168,7 +169,7 @@ namespace osu.Game.Rulesets.UI .Select(action => new KeyCounterAction(action))); } - public class ActionReceptor : KeyCounterDisplay.Receptor, IKeyBindingHandler + private class ActionReceptor : KeyCounterDisplay.Receptor, IKeyBindingHandler { public ActionReceptor(KeyCounterDisplay target) : base(target) @@ -186,6 +187,37 @@ namespace osu.Game.Rulesets.UI #endregion + #region Keys per second Counter Attachment + + public void Attach(ClicksPerSecondCalculator calculator) + { + var listener = new ActionListener(calculator); + + KeyBindingContainer.Add(listener); + } + + private class ActionListener : Component, IKeyBindingHandler + { + private readonly ClicksPerSecondCalculator calculator; + + public ActionListener(ClicksPerSecondCalculator calculator) + { + this.calculator = calculator; + } + + public bool OnPressed(KeyBindingPressEvent e) + { + calculator.AddInputTimestamp(); + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + } + + #endregion + protected virtual KeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) => new RulesetKeyBindingContainer(ruleset, variant, unique); @@ -221,12 +253,13 @@ namespace osu.Game.Rulesets.UI } /// - /// Supports attaching a . + /// Supports attaching various HUD pieces. /// Keys will be populated automatically and a receptor will be injected inside. /// - public interface ICanAttachKeyCounter + public interface ICanAttachHUDPieces { void Attach(KeyCounterDisplay keyCounter); + void Attach(ClicksPerSecondCalculator calculator); } public class RulesetInputManagerInputState : InputState diff --git a/osu.Game/Scoring/ScoreImporter.cs b/osu.Game/Scoring/ScoreImporter.cs index 45f827354e..5c8e21014c 100644 --- a/osu.Game/Scoring/ScoreImporter.cs +++ b/osu.Game/Scoring/ScoreImporter.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; using Newtonsoft.Json; @@ -16,6 +17,8 @@ using osu.Game.Scoring.Legacy; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; using Realms; namespace osu.Game.Scoring @@ -71,6 +74,8 @@ namespace osu.Game.Scoring if (model.BeatmapInfo == null) throw new ArgumentNullException(nameof(model.BeatmapInfo)); if (model.Ruleset == null) throw new ArgumentNullException(nameof(model.Ruleset)); + PopulateMaximumStatistics(model); + if (string.IsNullOrEmpty(model.StatisticsJson)) model.StatisticsJson = JsonConvert.SerializeObject(model.Statistics); @@ -78,6 +83,68 @@ namespace osu.Game.Scoring model.MaximumStatisticsJson = JsonConvert.SerializeObject(model.MaximumStatistics); } + /// + /// Populates the for a given . + /// + /// The score to populate the statistics of. + public void PopulateMaximumStatistics(ScoreInfo score) + { + if (score.MaximumStatistics.Select(kvp => kvp.Value).Sum() > 0) + return; + + var beatmap = score.BeatmapInfo.Detach(); + var ruleset = score.Ruleset.Detach(); + var rulesetInstance = ruleset.CreateInstance(); + + Debug.Assert(rulesetInstance != null); + + // Populate the maximum statistics. + HitResult maxBasicResult = rulesetInstance.GetHitResults() + .Select(h => h.result) + .Where(h => h.IsBasic()) + .OrderByDescending(Judgement.ToNumericResult).First(); + + foreach ((HitResult result, int count) in score.Statistics) + { + switch (result) + { + case HitResult.LargeTickHit: + case HitResult.LargeTickMiss: + score.MaximumStatistics[HitResult.LargeTickHit] = score.MaximumStatistics.GetValueOrDefault(HitResult.LargeTickHit) + count; + break; + + case HitResult.SmallTickHit: + case HitResult.SmallTickMiss: + score.MaximumStatistics[HitResult.SmallTickHit] = score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit) + count; + break; + + case HitResult.IgnoreHit: + case HitResult.IgnoreMiss: + case HitResult.SmallBonus: + case HitResult.LargeBonus: + break; + + default: + score.MaximumStatistics[maxBasicResult] = score.MaximumStatistics.GetValueOrDefault(maxBasicResult) + count; + break; + } + } + + if (!score.IsLegacyScore) + return; + +#pragma warning disable CS0618 + // In osu! and osu!mania, some judgements affect combo but aren't stored to scores. + // A special hit result is used to pad out the combo value to match, based on the max combo from the difficulty attributes. + var calculator = rulesetInstance.CreateDifficultyCalculator(beatmaps().GetWorkingBeatmap(beatmap)); + var attributes = calculator.Calculate(score.Mods); + + int maxComboFromStatistics = score.MaximumStatistics.Where(kvp => kvp.Key.AffectsCombo()).Select(kvp => kvp.Value).DefaultIfEmpty(0).Sum(); + if (attributes.MaxCombo > maxComboFromStatistics) + score.MaximumStatistics[HitResult.LegacyComboIncrease] = attributes.MaxCombo - maxComboFromStatistics; +#pragma warning restore CS0618 + } + protected override void PostImport(ScoreInfo model, Realm realm, bool batchImport) { base.PostImport(model, realm, batchImport); diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index 782590114f..6bb31eb4db 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -28,7 +28,8 @@ namespace osu.Game.Scoring private readonly OsuConfigManager configManager; private readonly ScoreImporter scoreImporter; - public ScoreManager(RulesetStore rulesets, Func beatmaps, Storage storage, RealmAccess realm, IAPIProvider api, OsuConfigManager configManager = null) + public ScoreManager(RulesetStore rulesets, Func beatmaps, Storage storage, RealmAccess realm, IAPIProvider api, + OsuConfigManager configManager = null) : base(storage, realm) { this.configManager = configManager; @@ -178,6 +179,12 @@ namespace osu.Game.Scoring public Live Import(ScoreInfo item, ArchiveReader archive = null, bool batchImport = false, CancellationToken cancellationToken = default) => scoreImporter.ImportModel(item, archive, batchImport, cancellationToken); + /// + /// Populates the for a given . + /// + /// The score to populate the statistics of. + public void PopulateMaximumStatistics(ScoreInfo score) => scoreImporter.PopulateMaximumStatistics(score); + #region Implementation of IPresentImports public Action>> PresentImport diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs index beecd56b52..bda616d5c3 100644 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs +++ b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs @@ -561,6 +561,10 @@ namespace osu.Game.Screens.OnlinePlay { switch (state.NewValue) { + case DownloadState.Unknown: + // Ignore initial state to ensure the button doesn't briefly appear. + break; + case DownloadState.LocallyAvailable: // Perform a local query of the beatmap by beatmap checksum, and reset the state if not matching. if (beatmapManager.QueryBeatmap(b => b.MD5Hash == beatmap.MD5Hash) == null) diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs index 03216180fb..00c819e5e4 100644 --- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs @@ -433,6 +433,9 @@ namespace osu.Game.Screens.OnlinePlay.Match private void updateWorkingBeatmap() { + if (SelectedItem.Value == null || !this.IsCurrentScreen()) + return; + var beatmap = SelectedItem.Value?.Beatmap; // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs index 501c76f65b..f048ae59cd 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs @@ -109,7 +109,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match Debug.Assert(clickOperation == null); clickOperation = ongoingOperationTracker.BeginOperation(); - if (isReady() && Client.IsHost && Room.Countdown == null) + if (isReady() && Client.IsHost && !Room.ActiveCountdowns.Any(c => c is MatchStartCountdown)) startMatch(); else toggleReady(); @@ -140,10 +140,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private void cancelCountdown() { + if (Client.Room == null) + return; + Debug.Assert(clickOperation == null); clickOperation = ongoingOperationTracker.BeginOperation(); - Client.SendMatchRequest(new StopCountdownRequest()).ContinueWith(_ => endOperation()); + MultiplayerCountdown countdown = Client.Room.ActiveCountdowns.Single(c => c is MatchStartCountdown); + Client.SendMatchRequest(new StopCountdownRequest(countdown.ID)).ContinueWith(_ => endOperation()); } private void endOperation() @@ -192,7 +196,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match // When the local user is the host and spectating the match, the ready button should be enabled only if any users are ready. if (localUser?.State == MultiplayerUserState.Spectating) - readyButton.Enabled.Value &= Client.IsHost && newCountReady > 0 && Room.Countdown == null; + readyButton.Enabled.Value &= Client.IsHost && newCountReady > 0 && !Room.ActiveCountdowns.Any(c => c is MatchStartCountdown); if (newCountReady == countReady) return; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.cs index 14bf1a8375..cd94b47d9e 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.cs @@ -4,6 +4,7 @@ #nullable disable using System; +using System.Linq; using Humanizer; using osu.Framework.Allocation; using osu.Framework.Extensions; @@ -79,7 +80,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private void onRoomUpdated() => Scheduler.AddOnce(() => { - bool countdownActive = multiplayerClient.Room?.Countdown is MatchStartCountdown; + bool countdownActive = multiplayerClient.Room?.ActiveCountdowns.Any(c => c is MatchStartCountdown) == true; if (countdownActive) { @@ -121,7 +122,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match }); } - if (multiplayerClient.Room?.Countdown != null && multiplayerClient.IsHost) + if (multiplayerClient.Room?.ActiveCountdowns.Any(c => c is MatchStartCountdown) == true && multiplayerClient.IsHost) { flow.Add(new OsuButton { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs index 3d6127e8e7..75bd6eb04d 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs @@ -1,12 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.ComponentModel; using System.Diagnostics; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; @@ -30,12 +27,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { public class MultiplayerMatchSettingsOverlay : RoomSettingsOverlay { - private MatchSettings settings; + private MatchSettings settings = null!; protected override OsuButton SubmitButton => settings.ApplyButton; [Resolved] - private OngoingOperationTracker ongoingOperationTracker { get; set; } + private OngoingOperationTracker ongoingOperationTracker { get; set; } = null!; protected override bool IsLoading => ongoingOperationTracker.InProgress.Value; @@ -57,20 +54,21 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { private const float disabled_alpha = 0.2f; - public Action SettingsApplied; + public Action? SettingsApplied; - public OsuTextBox NameField, MaxParticipantsField; - public MatchTypePicker TypePicker; - public OsuEnumDropdown QueueModeDropdown; - public OsuTextBox PasswordTextBox; - public OsuCheckbox AutoSkipCheckbox; - public TriangleButton ApplyButton; + public OsuTextBox NameField = null!; + public OsuTextBox MaxParticipantsField = null!; + public MatchTypePicker TypePicker = null!; + public OsuEnumDropdown QueueModeDropdown = null!; + public OsuTextBox PasswordTextBox = null!; + public OsuCheckbox AutoSkipCheckbox = null!; + public TriangleButton ApplyButton = null!; - public OsuSpriteText ErrorText; + public OsuSpriteText ErrorText = null!; - private OsuEnumDropdown startModeDropdown; - private OsuSpriteText typeLabel; - private LoadingLayer loadingLayer; + private OsuEnumDropdown startModeDropdown = null!; + private OsuSpriteText typeLabel = null!; + private LoadingLayer loadingLayer = null!; public void SelectBeatmap() { @@ -79,26 +77,23 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match } [Resolved] - private MultiplayerMatchSubScreen matchSubScreen { get; set; } + private MultiplayerMatchSubScreen matchSubScreen { get; set; } = null!; [Resolved] - private IRoomManager manager { get; set; } + private IRoomManager manager { get; set; } = null!; [Resolved] - private MultiplayerClient client { get; set; } + private MultiplayerClient client { get; set; } = null!; [Resolved] - private OngoingOperationTracker ongoingOperationTracker { get; set; } + private OngoingOperationTracker ongoingOperationTracker { get; set; } = null!; private readonly IBindable operationInProgress = new BindableBool(); - - [CanBeNull] - private IDisposable applyingSettingsOperation; - private readonly Room room; - private Drawable playlistContainer; - private DrawableRoomPlaylist drawablePlaylist; + private IDisposable? applyingSettingsOperation; + private Drawable playlistContainer = null!; + private DrawableRoomPlaylist drawablePlaylist = null!; public MatchSettings(Room room) { @@ -423,7 +418,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match else room.MaxParticipants.Value = null; - manager?.CreateRoom(room, onSuccess, onError); + manager.CreateRoom(room, onSuccess, onError); } } @@ -466,7 +461,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match public class CreateOrUpdateButton : TriangleButton { [Resolved(typeof(Room), nameof(Room.RoomID))] - private Bindable roomId { get; set; } + private Bindable roomId { get; set; } = null!; protected override void LoadComplete() { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs index c5d6da1ebc..b4ff34cbc2 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs @@ -57,23 +57,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private void onRoomUpdated() => Scheduler.AddOnce(() => { - MultiplayerCountdown newCountdown; - - switch (room?.Countdown) - { - case MatchStartCountdown: - newCountdown = room.Countdown; - break; - - // Clear the countdown with any other (including non-null) countdown values. - default: - newCountdown = null; - break; - } + MultiplayerCountdown newCountdown = room?.ActiveCountdowns.SingleOrDefault(c => c is MatchStartCountdown); if (newCountdown != countdown) { - countdown = room?.Countdown; + countdown = newCountdown; countdownChangeTime = Time.Current; } @@ -213,7 +201,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match case MultiplayerUserState.Spectating: case MultiplayerUserState.Ready: - if (room?.Host?.Equals(localUser) == true && room.Countdown == null) + if (room?.Host?.Equals(localUser) == true && !room.ActiveCountdowns.Any(c => c is MatchStartCountdown)) setGreen(); else setYellow(); @@ -248,8 +236,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { get { - if (room?.Countdown != null && multiplayerClient.IsHost && multiplayerClient.LocalUser?.State == MultiplayerUserState.Ready && !room.Settings.AutoStartEnabled) + if (room?.ActiveCountdowns.Any(c => c is MatchStartCountdown) == true + && multiplayerClient.IsHost + && multiplayerClient.LocalUser?.State == MultiplayerUserState.Ready + && !room.Settings.AutoStartEnabled) + { return "Cancel countdown"; + } return base.TooltipText; } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs index dbd679104e..3fe236bd7a 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs @@ -8,11 +8,9 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Logging; using osu.Framework.Screens; -using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; -using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Select; @@ -27,7 +25,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private OngoingOperationTracker operationTracker { get; set; } = null!; private readonly IBindable operationInProgress = new Bindable(); - private readonly long? itemToEdit; + private readonly PlaylistItem? itemToEdit; private LoadingLayer loadingLayer = null!; private IDisposable? selectionOperation; @@ -37,21 +35,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer /// /// The room. /// The item to be edited. May be null, in which case a new item will be added to the playlist. - /// An optional initial beatmap selection to perform. - /// An optional initial ruleset selection to perform. - public MultiplayerMatchSongSelect(Room room, long? itemToEdit = null, WorkingBeatmap? beatmap = null, RulesetInfo? ruleset = null) - : base(room) + public MultiplayerMatchSongSelect(Room room, PlaylistItem? itemToEdit = null) + : base(room, itemToEdit) { this.itemToEdit = itemToEdit; - - if (beatmap != null || ruleset != null) - { - Schedule(() => - { - if (beatmap != null) Beatmap.Value = beatmap; - if (ruleset != null) Ruleset.Value = ruleset; - }); - } } [BackgroundDependencyLoader] @@ -80,7 +67,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { if (operationInProgress.Value) { - Logger.Log($"{nameof(SelectedItem)} aborted due to {nameof(operationInProgress)}"); + Logger.Log($"{nameof(SelectItem)} aborted due to {nameof(operationInProgress)}"); return false; } @@ -92,7 +79,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer var multiplayerItem = new MultiplayerPlaylistItem { - ID = itemToEdit ?? 0, + ID = itemToEdit?.ID ?? 0, BeatmapID = item.Beatmap.OnlineID, BeatmapChecksum = item.Beatmap.MD5Hash, RulesetID = item.RulesetID, diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index ceadfa1527..db752f2b42 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -49,11 +49,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer [Resolved] private MultiplayerClient client { get; set; } - [Resolved] - private BeatmapManager beatmapManager { get; set; } - - private readonly IBindable isConnected = new Bindable(); - private AddItemButton addItemButton; public MultiplayerMatchSubScreen(Room room) @@ -227,12 +222,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer if (!this.IsCurrentScreen()) return; - int id = itemToEdit?.Beatmap.OnlineID ?? Room.Playlist.Last().Beatmap.OnlineID; - var localBeatmap = beatmapManager.QueryBeatmap(b => b.OnlineID == id); - - var workingBeatmap = localBeatmap == null ? null : beatmapManager.GetWorkingBeatmap(localBeatmap); - - this.Push(new MultiplayerMatchSongSelect(Room, itemToEdit?.ID, workingBeatmap)); + this.Push(new MultiplayerMatchSongSelect(Room, itemToEdit)); } protected override Drawable CreateFooter() => new MultiplayerMatchFooter(); @@ -424,7 +414,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer return; } - this.Push(new MultiplayerMatchSongSelect(Room, client.Room.Settings.PlaylistItemId, beatmap, ruleset)); + this.Push(new MultiplayerMatchSongSelect(Room, Room.Playlist.Single(item => item.ID == client.Room.Settings.PlaylistItemId))); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs index d351d121c6..8e79c89685 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorPlayer.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Audio; using osu.Game.Beatmaps; using osu.Game.Scoring; using osu.Game.Screens.Play; @@ -13,6 +14,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// public class MultiSpectatorPlayer : SpectatorPlayer { + /// + /// All adjustments applied to the clock of this which come from mods. + /// + public IAggregateAudioAdjustment ClockAdjustmentsFromMods => clockAdjustmentsFromMods; + + private readonly AudioAdjustments clockAdjustmentsFromMods = new AudioAdjustments(); private readonly SpectatorPlayerClock spectatorPlayerClock; /// @@ -53,6 +60,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate } protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) - => new GameplayClockContainer(spectatorPlayerClock); + { + var gameplayClockContainer = new GameplayClockContainer(spectatorPlayerClock); + clockAdjustmentsFromMods.BindAdjustments(gameplayClockContainer.AdjustmentsFromMods); + return gameplayClockContainer; + } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs index c2ece90472..1fd04d35f8 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Audio; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -43,6 +44,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate [Resolved] private MultiplayerClient multiplayerClient { get; set; } = null!; + private IAggregateAudioAdjustment? boundAdjustments; + private readonly PlayerArea[] instances; private MasterGameplayClockContainer masterClockContainer = null!; private SpectatorSyncManager syncManager = null!; @@ -157,6 +160,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate base.LoadComplete(); masterClockContainer.Reset(); + + // Start with adjustments from the first player to keep a sane state. + bindAudioAdjustments(instances.First()); } protected override void Update() @@ -169,11 +175,24 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate .OrderBy(i => Math.Abs(i.SpectatorPlayerClock.CurrentTime - syncManager.CurrentMasterTime)) .FirstOrDefault(); + // Only bind adjustments if there's actually a valid source, else just use the previous ones to ensure no sudden changes to audio. + if (currentAudioSource != null) + bindAudioAdjustments(currentAudioSource); + foreach (var instance in instances) instance.Mute = instance != currentAudioSource; } } + private void bindAudioAdjustments(PlayerArea first) + { + if (boundAdjustments != null) + masterClockContainer.AdjustmentsFromMods.UnbindAdjustments(boundAdjustments); + + boundAdjustments = first.ClockAdjustmentsFromMods; + masterClockContainer.AdjustmentsFromMods.BindAdjustments(boundAdjustments); + } + private bool isCandidateAudioSource(SpectatorPlayerClock? clock) => clock?.IsRunning == true && !clock.IsCatchingUp && !clock.WaitingOnFrames; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs index 36f6631ebf..96f134568d 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs @@ -42,6 +42,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate /// public readonly SpectatorPlayerClock SpectatorPlayerClock; + /// + /// The clock adjustments applied by the loaded in this area. + /// + public IAggregateAudioAdjustment ClockAdjustmentsFromMods => clockAdjustmentsFromMods; + /// /// The currently-loaded score. /// @@ -50,6 +55,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate [Resolved] private IBindable beatmap { get; set; } = null!; + private readonly AudioAdjustments clockAdjustmentsFromMods = new AudioAdjustments(); private readonly BindableDouble volumeAdjustment = new BindableDouble(); private readonly Container gameplayContent; private readonly LoadingLayer loadingLayer; @@ -97,6 +103,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate { var player = new MultiSpectatorPlayer(Score, SpectatorPlayerClock); player.OnGameplayStarted += () => OnGameplayStarted?.Invoke(); + + clockAdjustmentsFromMods.BindAdjustments(player.ClockAdjustmentsFromMods); + return player; })); diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index f26480909e..ea20270c1e 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -1,13 +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 disable - using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using Humanizer; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -35,32 +33,34 @@ namespace osu.Game.Screens.OnlinePlay public override bool AllowEditing => false; [Resolved(typeof(Room), nameof(Room.Playlist))] - protected BindableList Playlist { get; private set; } - - [CanBeNull] - [Resolved(CanBeNull = true)] - protected IBindable SelectedItem { get; private set; } + protected BindableList Playlist { get; private set; } = null!; [Resolved] - private RulesetStore rulesets { get; set; } + private RulesetStore rulesets { get; set; } = null!; + + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; protected override UserActivity InitialActivity => new UserActivity.InLobby(room); protected readonly Bindable> FreeMods = new Bindable>(Array.Empty()); private readonly Room room; - - private WorkingBeatmap initialBeatmap; - private RulesetInfo initialRuleset; - private IReadOnlyList initialMods; - private bool itemSelected; - + private readonly PlaylistItem? initialItem; private readonly FreeModSelectOverlay freeModSelectOverlay; - private IDisposable freeModSelectOverlayRegistration; - protected OnlinePlaySongSelect(Room room) + private IDisposable? freeModSelectOverlayRegistration; + + /// + /// Creates a new . + /// + /// The room. + /// An optional initial to use for the initial beatmap/ruleset/mods. + /// If null, the last in the room will be used. + protected OnlinePlaySongSelect(Room room, PlaylistItem? initialItem = null) { this.room = room; + this.initialItem = initialItem ?? room.Playlist.LastOrDefault(); Padding = new MarginPadding { Horizontal = HORIZONTAL_OVERFLOW_PADDING }; @@ -75,11 +75,6 @@ namespace osu.Game.Screens.OnlinePlay private void load() { LeftArea.Padding = new MarginPadding { Top = Header.HEIGHT }; - - initialBeatmap = Beatmap.Value; - initialRuleset = Ruleset.Value; - initialMods = Mods.Value.ToList(); - LoadComponent(freeModSelectOverlay); } @@ -87,14 +82,35 @@ namespace osu.Game.Screens.OnlinePlay { base.LoadComplete(); - var rulesetInstance = SelectedItem?.Value?.RulesetID == null ? null : rulesets.GetRuleset(SelectedItem.Value.RulesetID)?.CreateInstance(); - - if (rulesetInstance != null) + if (initialItem != null) { - // At this point, Mods contains both the required and allowed mods. For selection purposes, it should only contain the required mods. - // Similarly, freeMods is currently empty but should only contain the allowed mods. - Mods.Value = SelectedItem.Value.RequiredMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); - FreeMods.Value = SelectedItem.Value.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); + // Prefer using a local databased beatmap lookup since OnlineId may be -1 for an invalid beatmap selection. + BeatmapInfo? beatmapInfo = initialItem.Beatmap as BeatmapInfo; + + // And in the case that this isn't a local databased beatmap, query by online ID. + if (beatmapInfo == null) + { + int onlineId = initialItem.Beatmap.OnlineID; + beatmapInfo = beatmapManager.QueryBeatmap(b => b.OnlineID == onlineId); + } + + if (beatmapInfo != null) + Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapInfo); + + RulesetInfo? ruleset = rulesets.GetRuleset(initialItem.RulesetID); + + if (ruleset != null) + { + Ruleset.Value = ruleset; + + var rulesetInstance = ruleset.CreateInstance(); + Debug.Assert(rulesetInstance != null); + + // At this point, Mods contains both the required and allowed mods. For selection purposes, it should only contain the required mods. + // Similarly, freeMods is currently empty but should only contain the allowed mods. + Mods.Value = initialItem.RequiredMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); + FreeMods.Value = initialItem.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); + } } Mods.BindValueChanged(onModsChanged); @@ -125,13 +141,7 @@ namespace osu.Game.Screens.OnlinePlay AllowedMods = FreeMods.Value.Select(m => new APIMod(m)).ToArray() }; - if (SelectItem(item)) - { - itemSelected = true; - return true; - } - - return false; + return SelectItem(item); } /// @@ -154,15 +164,7 @@ namespace osu.Game.Screens.OnlinePlay public override bool OnExiting(ScreenExitEvent e) { - if (!itemSelected) - { - Beatmap.Value = initialBeatmap; - Ruleset.Value = initialRuleset; - Mods.Value = initialMods; - } - freeModSelectOverlay.Hide(); - return base.OnExiting(e); } @@ -199,7 +201,6 @@ namespace osu.Game.Screens.OnlinePlay protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - freeModSelectOverlayRegistration?.Dispose(); } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs index 73765fc661..e3f7b5dfc4 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Linq; using osu.Framework.Screens; using osu.Game.Online.API; diff --git a/osu.Game/Screens/Play/GameplayClockContainer.cs b/osu.Game/Screens/Play/GameplayClockContainer.cs index 6de88d7ad0..35b79fd628 100644 --- a/osu.Game/Screens/Play/GameplayClockContainer.cs +++ b/osu.Game/Screens/Play/GameplayClockContainer.cs @@ -2,15 +2,13 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; -using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Framework.Timing; -using osu.Framework.Utils; using osu.Game.Beatmaps; namespace osu.Game.Screens.Play @@ -46,7 +44,7 @@ namespace osu.Game.Screens.Play /// public double StartTime { get; protected set; } - public virtual IEnumerable NonGameplayAdjustments => Enumerable.Empty(); + public IAdjustableAudioComponent AdjustmentsFromMods { get; } = new AudioAdjustments(); private readonly BindableBool isPaused = new BindableBool(true); @@ -196,7 +194,9 @@ namespace osu.Game.Screens.Play void IAdjustableClock.Reset() => Reset(); - public void ResetSpeedAdjustments() => throw new NotImplementedException(); + public virtual void ResetSpeedAdjustments() + { + } double IAdjustableClock.Rate { @@ -222,23 +222,5 @@ namespace osu.Game.Screens.Play public double FramesPerSecond => GameplayClock.FramesPerSecond; public FrameTimeInfo TimeInfo => GameplayClock.TimeInfo; - - public double TrueGameplayRate - { - get - { - double baseRate = Rate; - - foreach (double adjustment in NonGameplayAdjustments) - { - if (Precision.AlmostEquals(adjustment, 0)) - return 0; - - baseRate /= adjustment; - } - - return baseRate; - } - } } } diff --git a/osu.Game/Screens/Play/GameplayClockExtensions.cs b/osu.Game/Screens/Play/GameplayClockExtensions.cs new file mode 100644 index 0000000000..5e88b41080 --- /dev/null +++ b/osu.Game/Screens/Play/GameplayClockExtensions.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +namespace osu.Game.Screens.Play +{ + public static class GameplayClockExtensions + { + /// + /// The rate of gameplay when playback is at 100%. + /// This excludes any seeking / user adjustments. + /// + public static double GetTrueGameplayRate(this IGameplayClock clock) + { + // To handle rewind, we still want to maintain the same direction as the underlying clock. + double rate = clock.Rate == 0 ? 1 : Math.Sign(clock.Rate); + + return rate + * clock.AdjustmentsFromMods.AggregateFrequency.Value + * clock.AdjustmentsFromMods.AggregateTempo.Value; + } + } +} diff --git a/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondCalculator.cs b/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondCalculator.cs new file mode 100644 index 0000000000..04774b974f --- /dev/null +++ b/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondCalculator.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 System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Rulesets.UI; + +namespace osu.Game.Screens.Play.HUD.ClicksPerSecond +{ + public class ClicksPerSecondCalculator : Component + { + private readonly List timestamps = new List(); + + [Resolved] + private IGameplayClock gameplayClock { get; set; } = null!; + + [Resolved(canBeNull: true)] + private DrawableRuleset? drawableRuleset { get; set; } + + public int Value { get; private set; } + + // Even though `FrameStabilityContainer` caches as a `GameplayClock`, we need to check it directly via `drawableRuleset` + // as this calculator is not contained within the `FrameStabilityContainer` and won't see the dependency. + private IGameplayClock clock => drawableRuleset?.FrameStableClock ?? gameplayClock; + + public ClicksPerSecondCalculator() + { + RelativeSizeAxes = Axes.Both; + } + + public void AddInputTimestamp() => timestamps.Add(clock.CurrentTime); + + protected override void Update() + { + base.Update(); + + double latestValidTime = clock.CurrentTime; + double earliestTimeValid = latestValidTime - 1000 * gameplayClock.GetTrueGameplayRate(); + + int count = 0; + + for (int i = timestamps.Count - 1; i >= 0; i--) + { + // handle rewinding by removing future timestamps as we go + if (timestamps[i] > latestValidTime) + { + timestamps.RemoveAt(i); + continue; + } + + if (timestamps[i] >= earliestTimeValid) + count++; + } + + Value = count; + } + } +} diff --git a/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondCounter.cs b/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondCounter.cs new file mode 100644 index 0000000000..243d8ed1e8 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/ClicksPerSecond/ClicksPerSecondCounter.cs @@ -0,0 +1,102 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Screens.Play.HUD.ClicksPerSecond +{ + public class ClicksPerSecondCounter : RollingCounter, ISkinnableDrawable + { + [Resolved] + private ClicksPerSecondCalculator calculator { get; set; } = null!; + + protected override double RollingDuration => 350; + + public bool UsesFixedAnchor { get; set; } + + public ClicksPerSecondCounter() + { + Current.Value = 0; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Colour = colours.BlueLighter; + } + + protected override void Update() + { + base.Update(); + + Current.Value = calculator.Value; + } + + protected override IHasText CreateText() => new TextComponent(); + + private class TextComponent : CompositeDrawable, IHasText + { + public LocalisableString Text + { + get => text.Text; + set => text.Text = value; + } + + private readonly OsuSpriteText text; + + public TextComponent() + { + AutoSizeAxes = Axes.Both; + + InternalChild = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(2), + Children = new Drawable[] + { + text = new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Font = OsuFont.Numeric.With(size: 16, fixedWidth: true) + }, + new FillFlowContainer + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Font = OsuFont.Numeric.With(size: 6, fixedWidth: false), + Text = @"clicks", + }, + new OsuSpriteText + { + Anchor = Anchor.TopLeft, + Origin = Anchor.TopLeft, + Font = OsuFont.Numeric.With(size: 6, fixedWidth: false), + Text = @"/sec", + Padding = new MarginPadding { Bottom = 3f }, // align baseline better + } + } + } + } + }; + } + } + } +} diff --git a/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs b/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs index 659984682e..30b420441c 100644 --- a/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs +++ b/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs @@ -16,7 +16,6 @@ namespace osu.Game.Screens.Play.HUD { public class DefaultSongProgress : SongProgress { - private const float info_height = 20; private const float bottom_bar_height = 5; private const float graph_height = SquareGraph.Column.WIDTH * 6; private const float handle_height = 18; @@ -65,7 +64,6 @@ namespace osu.Game.Screens.Play.HUD Origin = Anchor.BottomLeft, Anchor = Anchor.BottomLeft, RelativeSizeAxes = Axes.X, - Height = info_height, }, graph = new SongProgressGraph { @@ -178,7 +176,7 @@ namespace osu.Game.Screens.Play.HUD protected override void Update() { base.Update(); - Height = bottom_bar_height + graph_height + handle_size.Y + info_height - graph.Y; + Height = bottom_bar_height + graph_height + handle_size.Y + info.Height - graph.Y; } private void updateBarVisibility() diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs index 26befd659c..dda17c25e6 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/HitErrorMeter.cs @@ -59,30 +59,7 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters protected Color4 GetColourForHitResult(HitResult result) { - switch (result) - { - case HitResult.SmallTickMiss: - case HitResult.LargeTickMiss: - case HitResult.Miss: - return colours.Red; - - case HitResult.Meh: - return colours.Yellow; - - case HitResult.Ok: - return colours.Green; - - case HitResult.Good: - return colours.GreenLight; - - case HitResult.SmallTickHit: - case HitResult.LargeTickHit: - case HitResult.Great: - return colours.Blue; - - default: - return colours.BlueLight; - } + return colours.ForHitResult(result); } /// diff --git a/osu.Game/Screens/Play/HUD/SongProgressInfo.cs b/osu.Game/Screens/Play/HUD/SongProgressInfo.cs index 96a4c5f2bc..d0eb8f8ca1 100644 --- a/osu.Game/Screens/Play/HUD/SongProgressInfo.cs +++ b/osu.Game/Screens/Play/HUD/SongProgressInfo.cs @@ -7,6 +7,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using System; @@ -14,9 +15,9 @@ namespace osu.Game.Screens.Play.HUD { public class SongProgressInfo : Container { - private OsuSpriteText timeCurrent; - private OsuSpriteText timeLeft; - private OsuSpriteText progress; + private SizePreservingSpriteText timeCurrent; + private SizePreservingSpriteText timeLeft; + private SizePreservingSpriteText progress; private double startTime; private double endTime; @@ -46,36 +47,71 @@ namespace osu.Game.Screens.Play.HUD if (clock != null) gameplayClock = clock; + AutoSizeAxes = Axes.Y; Children = new Drawable[] { - timeCurrent = new OsuSpriteText + new Container { - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - Colour = colours.BlueLighter, - Font = OsuFont.Numeric, - Margin = new MarginPadding + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Child = new UprightAspectMaintainingContainer { - Left = margin, - }, + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Scaling = ScaleMode.Vertical, + ScalingFactor = 0.5f, + Child = timeCurrent = new SizePreservingSpriteText + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Colour = colours.BlueLighter, + Font = OsuFont.Numeric, + } + } }, - progress = new OsuSpriteText + new Container { - Origin = Anchor.BottomCentre, - Anchor = Anchor.BottomCentre, - Colour = colours.BlueLighter, - Font = OsuFont.Numeric, - }, - timeLeft = new OsuSpriteText - { - Origin = Anchor.BottomRight, - Anchor = Anchor.BottomRight, - Colour = colours.BlueLighter, - Font = OsuFont.Numeric, - Margin = new MarginPadding + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Child = new UprightAspectMaintainingContainer { - Right = margin, - }, + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Scaling = ScaleMode.Vertical, + ScalingFactor = 0.5f, + Child = progress = new SizePreservingSpriteText + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Colour = colours.BlueLighter, + Font = OsuFont.Numeric, + } + } + }, + new Container + { + Origin = Anchor.CentreRight, + Anchor = Anchor.CentreRight, + AutoSizeAxes = Axes.Both, + Child = new UprightAspectMaintainingContainer + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + AutoSizeAxes = Axes.Both, + Scaling = ScaleMode.Vertical, + ScalingFactor = 0.5f, + Child = timeLeft = new SizePreservingSpriteText + { + Origin = Anchor.Centre, + Anchor = Anchor.Centre, + Colour = colours.BlueLighter, + Font = OsuFont.Numeric, + } + } } }; } diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index 8f80644d52..f9f3693385 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -22,6 +22,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Play.HUD.ClicksPerSecond; using osu.Game.Skinning; using osuTK; @@ -49,6 +50,9 @@ namespace osu.Game.Screens.Play public readonly HoldForMenuButton HoldToQuit; public readonly PlayerSettingsOverlay PlayerSettingsOverlay; + [Cached] + private readonly ClicksPerSecondCalculator clicksPerSecondCalculator; + public Bindable ShowHealthBar = new Bindable(true); private readonly DrawableRuleset drawableRuleset; @@ -122,7 +126,8 @@ namespace osu.Game.Screens.Play KeyCounter = CreateKeyCounter(), HoldToQuit = CreateHoldForMenuButton(), } - } + }, + clicksPerSecondCalculator = new ClicksPerSecondCalculator() }; } @@ -259,7 +264,11 @@ namespace osu.Game.Screens.Play protected virtual void BindDrawableRuleset(DrawableRuleset drawableRuleset) { - (drawableRuleset as ICanAttachKeyCounter)?.Attach(KeyCounter); + if (drawableRuleset is ICanAttachHUDPieces attachTarget) + { + attachTarget.Attach(KeyCounter); + attachTarget.Attach(clicksPerSecondCalculator); + } replayLoaded.BindTo(drawableRuleset.HasReplayLoaded); } diff --git a/osu.Game/Screens/Play/IGameplayClock.cs b/osu.Game/Screens/Play/IGameplayClock.cs index ea567090ad..83ba5f3474 100644 --- a/osu.Game/Screens/Play/IGameplayClock.cs +++ b/osu.Game/Screens/Play/IGameplayClock.cs @@ -1,7 +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.Collections.Generic; +using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Timing; @@ -9,12 +9,6 @@ namespace osu.Game.Screens.Play { public interface IGameplayClock : IFrameBasedClock { - /// - /// The rate of gameplay when playback is at 100%. - /// This excludes any seeking / user adjustments. - /// - double TrueGameplayRate { get; } - /// /// The time from which the clock should start. Will be seeked to on calling . /// @@ -25,9 +19,9 @@ namespace osu.Game.Screens.Play double StartTime { get; } /// - /// All adjustments applied to this clock which don't come from gameplay or mods. + /// All adjustments applied to this clock which come from mods. /// - IEnumerable NonGameplayAdjustments { get; } + IAdjustableAudioComponent AdjustmentsFromMods { get; } IBindable IsPaused { get; } } diff --git a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs index 2f1ffa126f..047f25a111 100644 --- a/osu.Game/Screens/Play/MasterGameplayClockContainer.cs +++ b/osu.Game/Screens/Play/MasterGameplayClockContainer.cs @@ -2,8 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Linq; +using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Bindables; @@ -11,6 +11,7 @@ using osu.Framework.Graphics; using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Overlays; namespace osu.Game.Screens.Play { @@ -41,9 +42,9 @@ namespace osu.Game.Screens.Play private readonly WorkingBeatmap beatmap; - private readonly double skipTargetTime; + private readonly Track track; - private readonly List> nonGameplayAdjustments = new List>(); + private readonly double skipTargetTime; /// /// Stores the time at which the last call was triggered. @@ -56,7 +57,8 @@ namespace osu.Game.Screens.Play /// private double? actualStopTime; - public override IEnumerable NonGameplayAdjustments => nonGameplayAdjustments.Select(b => b.Value); + [Resolved] + private MusicController musicController { get; set; } = null!; /// /// Create a new master gameplay clock container. @@ -69,6 +71,8 @@ namespace osu.Game.Screens.Play this.beatmap = beatmap; this.skipTargetTime = skipTargetTime; + track = beatmap.Track; + StartTime = findEarliestStartTime(); } @@ -195,15 +199,12 @@ namespace osu.Game.Screens.Play if (speedAdjustmentsApplied) return; - if (SourceClock is not Track track) - return; + musicController.ResetTrackAdjustments(); + track.BindAdjustments(AdjustmentsFromMods); track.AddAdjustment(AdjustableProperty.Frequency, GameplayClock.ExternalPauseFrequencyAdjust); track.AddAdjustment(AdjustableProperty.Tempo, UserPlaybackRate); - nonGameplayAdjustments.Add(GameplayClock.ExternalPauseFrequencyAdjust); - nonGameplayAdjustments.Add(UserPlaybackRate); - speedAdjustmentsApplied = true; } @@ -212,15 +213,10 @@ namespace osu.Game.Screens.Play if (!speedAdjustmentsApplied) return; - if (SourceClock is not Track track) - return; - + track.UnbindAdjustments(AdjustmentsFromMods); track.RemoveAdjustment(AdjustableProperty.Frequency, GameplayClock.ExternalPauseFrequencyAdjust); track.RemoveAdjustment(AdjustableProperty.Tempo, UserPlaybackRate); - nonGameplayAdjustments.Remove(GameplayClock.ExternalPauseFrequencyAdjust); - nonGameplayAdjustments.Remove(UserPlaybackRate); - speedAdjustmentsApplied = false; } diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 4afd04c335..91e9c3b58f 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -996,12 +996,8 @@ namespace osu.Game.Screens.Play foreach (var mod in GameplayState.Mods.OfType()) mod.ApplyToHUD(HUDOverlay); - // Our mods are local copies of the global mods so they need to be re-applied to the track. - // This is done through the music controller (for now), because resetting speed adjustments on the beatmap track also removes adjustments provided by DrawableTrack. - // Todo: In the future, player will receive in a track and will probably not have to worry about this... - musicController.ResetTrackAdjustments(); foreach (var mod in GameplayState.Mods.OfType()) - mod.ApplyToTrack(musicController.CurrentTrack); + mod.ApplyToTrack(GameplayClockContainer.AdjustmentsFromMods); updateGameplayState(); @@ -1053,6 +1049,7 @@ namespace osu.Game.Screens.Play musicController.ResetTrackAdjustments(); fadeOut(); + return base.OnExiting(e); } diff --git a/osu.Game/Screens/Play/SaveFailedScoreButton.cs b/osu.Game/Screens/Play/SaveFailedScoreButton.cs index 3f6e741dff..7358ff3de4 100644 --- a/osu.Game/Screens/Play/SaveFailedScoreButton.cs +++ b/osu.Game/Screens/Play/SaveFailedScoreButton.cs @@ -63,8 +63,7 @@ namespace osu.Game.Screens.Play if (player != null) { importedScore = realm.Run(r => r.Find(player.Score.ScoreInfo.ID)?.Detach()); - if (importedScore != null) - state.Value = DownloadState.LocallyAvailable; + state.Value = importedScore != null ? DownloadState.LocallyAvailable : DownloadState.NotDownloaded; } state.BindValueChanged(state => diff --git a/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs b/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs index db69e270f6..5335d77243 100644 --- a/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs +++ b/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs @@ -7,7 +7,6 @@ using System; using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -57,7 +56,7 @@ namespace osu.Game.Screens.Ranking.Statistics this.hitEvents = hitEvents.Where(e => !(e.HitObject.HitWindows is HitWindows.EmptyHitWindows) && e.Result.IsHit()).ToList(); } - private int[] bins; + private IDictionary[] bins; private double binSize; private double hitOffset; @@ -69,7 +68,7 @@ namespace osu.Game.Screens.Ranking.Statistics if (hitEvents == null || hitEvents.Count == 0) return; - bins = new int[total_timing_distribution_bins]; + bins = Enumerable.Range(0, total_timing_distribution_bins).Select(_ => new Dictionary()).ToArray>(); binSize = Math.Ceiling(hitEvents.Max(e => Math.Abs(e.TimeOffset)) / timing_distribution_bins); @@ -89,7 +88,8 @@ namespace osu.Game.Screens.Ranking.Statistics { bool roundUp = true; - Array.Clear(bins, 0, bins.Length); + foreach (var bin in bins) + bin.Clear(); foreach (var e in hitEvents) { @@ -110,23 +110,23 @@ namespace osu.Game.Screens.Ranking.Statistics // may be out of range when applying an offset. for such cases we can just drop the results. if (index >= 0 && index < bins.Length) - bins[index]++; + { + bins[index].TryGetValue(e.Result, out int value); + bins[index][e.Result] = ++value; + } } if (barDrawables != null) { for (int i = 0; i < barDrawables.Length; i++) { - barDrawables[i].UpdateOffset(bins[i]); + barDrawables[i].UpdateOffset(bins[i].Sum(b => b.Value)); } } else { - int maxCount = bins.Max(); - barDrawables = new Bar[total_timing_distribution_bins]; - - for (int i = 0; i < barDrawables.Length; i++) - barDrawables[i] = new Bar(bins[i], maxCount, i == timing_distribution_centre_bin_index); + int maxCount = bins.Max(b => b.Values.Sum()); + barDrawables = bins.Select((bin, i) => new Bar(bins[i], maxCount, i == timing_distribution_centre_bin_index)).ToArray(); Container axisFlow; @@ -209,50 +209,97 @@ namespace osu.Game.Screens.Ranking.Statistics private class Bar : CompositeDrawable { - private readonly float value; - private readonly float maxValue; + private float totalValue => values.Sum(v => v.Value); + private float basalHeight => BoundingBox.Width / BoundingBox.Height; + private float availableHeight => 1 - basalHeight; - private readonly Circle boxOriginal; + private readonly IReadOnlyList> values; + private readonly float maxValue; + private readonly bool isCentre; + + private Circle[] boxOriginals; private Circle boxAdjustment; - private const float minimum_height = 0.05f; + [Resolved] + private OsuColour colours { get; set; } - public Bar(float value, float maxValue, bool isCentre) + public Bar(IDictionary values, float maxValue, bool isCentre) { - this.value = value; + this.values = values.OrderBy(v => v.Key.GetIndexForOrderedDisplay()).ToList(); this.maxValue = maxValue; + this.isCentre = isCentre; RelativeSizeAxes = Axes.Both; Masking = true; + } - InternalChildren = new Drawable[] + [BackgroundDependencyLoader] + private void load() + { + if (values.Any()) { - boxOriginal = new Circle + boxOriginals = values.Select((v, i) => new Circle { RelativeSizeAxes = Axes.Both, Anchor = Anchor.BottomCentre, Origin = Anchor.BottomCentre, - Colour = isCentre ? Color4.White : Color4Extensions.FromHex("#66FFCC"), - Height = minimum_height, - }, - }; + Colour = isCentre && i == 0 ? Color4.White : colours.ForHitResult(v.Key), + Height = 0, + }).ToArray(); + // The bars of the stacked bar graph will be processed (stacked) from the bottom, which is the base position, + // to the top, and the bottom bar should be drawn more toward the front by design, + // while the drawing order is from the back to the front, so the order passed to `InternalChildren` is the opposite. + InternalChildren = boxOriginals.Reverse().ToArray(); + } + else + { + // A bin with no value draws a grey dot instead. + InternalChildren = boxOriginals = new[] + { + new Circle + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Colour = isCentre ? Color4.White : Color4.Gray, + Height = 0, + }, + }; + } } private const double duration = 300; + private float offsetForValue(float value) + { + return availableHeight * value / maxValue; + } + + private float heightForValue(float value) + { + return basalHeight + offsetForValue(value); + } + protected override void LoadComplete() { base.LoadComplete(); - float height = Math.Clamp(value / maxValue, minimum_height, 1); + foreach (var boxOriginal in boxOriginals) + boxOriginal.Height = basalHeight; - if (height > minimum_height) - boxOriginal.ResizeHeightTo(height, duration, Easing.OutQuint); + float offsetValue = 0; + + for (int i = 0; i < values.Count; i++) + { + boxOriginals[i].MoveToY(offsetForValue(offsetValue) * BoundingBox.Height, duration, Easing.OutQuint); + boxOriginals[i].ResizeHeightTo(heightForValue(values[i].Value), duration, Easing.OutQuint); + offsetValue -= values[i].Value; + } } public void UpdateOffset(float adjustment) { - bool hasAdjustment = adjustment != value && adjustment / maxValue >= minimum_height; + bool hasAdjustment = adjustment != totalValue; if (boxAdjustment == null) { @@ -271,7 +318,7 @@ namespace osu.Game.Screens.Ranking.Statistics }); } - boxAdjustment.ResizeHeightTo(Math.Clamp(adjustment / maxValue, minimum_height, 1), duration, Easing.OutQuint); + boxAdjustment.ResizeHeightTo(heightForValue(adjustment), duration, Easing.OutQuint); boxAdjustment.FadeTo(!hasAdjustment ? 0 : 1, duration, Easing.OutQuint); } } diff --git a/osu.Game/Screens/Select/DifficultyRangeFilterControl.cs b/osu.Game/Screens/Select/DifficultyRangeFilterControl.cs index a82c969805..45e7ff4caa 100644 --- a/osu.Game/Screens/Select/DifficultyRangeFilterControl.cs +++ b/osu.Game/Screens/Select/DifficultyRangeFilterControl.cs @@ -65,6 +65,13 @@ namespace osu.Game.Screens.Select private class MinimumStarsSlider : StarsSlider { + public MinimumStarsSlider() + : base("0") + { + } + + public override LocalisableString TooltipText => Current.Value.ToString(@"0.## stars"); + protected override void LoadComplete() { base.LoadComplete(); @@ -82,6 +89,11 @@ namespace osu.Game.Screens.Select private class MaximumStarsSlider : StarsSlider { + public MaximumStarsSlider() + : base("∞") + { + } + protected override void LoadComplete() { base.LoadComplete(); @@ -96,10 +108,17 @@ namespace osu.Game.Screens.Select private class StarsSlider : OsuSliderBar { + private readonly string defaultString; + public override LocalisableString TooltipText => Current.IsDefault ? UserInterfaceStrings.NoLimit : Current.Value.ToString(@"0.## stars"); + protected StarsSlider(string defaultString) + { + this.defaultString = defaultString; + } + protected override bool OnHover(HoverEvent e) { base.OnHover(e); @@ -125,7 +144,7 @@ namespace osu.Game.Screens.Select Current.BindValueChanged(current => { - currentDisplay.Text = current.NewValue != Current.Default ? current.NewValue.ToString("N1") : "∞"; + currentDisplay.Text = current.NewValue != Current.Default ? current.NewValue.ToString("N1") : defaultString; }, true); } } diff --git a/osu.Game/Screens/Select/NoResultsPlaceholder.cs b/osu.Game/Screens/Select/NoResultsPlaceholder.cs index f3c3fb4d87..73b53defe0 100644 --- a/osu.Game/Screens/Select/NoResultsPlaceholder.cs +++ b/osu.Game/Screens/Select/NoResultsPlaceholder.cs @@ -127,10 +127,10 @@ namespace osu.Game.Screens.Select config.SetValue(OsuSetting.DisplayStarsMaximum, 10.1); }); - string lowerStar = filter.UserStarDifficulty.Min == null ? "∞" : $"{filter.UserStarDifficulty.Min:N1}"; + string lowerStar = $"{filter.UserStarDifficulty.Min ?? 0:N1}"; string upperStar = filter.UserStarDifficulty.Max == null ? "∞" : $"{filter.UserStarDifficulty.Max:N1}"; - textFlow.AddText($" the {lowerStar}-{upperStar} star difficulty filter."); + textFlow.AddText($" the {lowerStar} - {upperStar} star difficulty filter."); } // TODO: Add realm queries to hint at which ruleset results are available in (and allow clicking to switch). diff --git a/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs b/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs index 46f5c1e67f..5a1ef34151 100644 --- a/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs +++ b/osu.Game/Skinning/Editor/SkinBlueprintContainer.cs @@ -11,9 +11,12 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Screens; using osu.Framework.Testing; +using osu.Framework.Input.Events; using osu.Game.Rulesets.Edit; using osu.Game.Screens; using osu.Game.Screens.Edit.Compose.Components; +using osuTK; +using osuTK.Input; namespace osu.Game.Skinning.Editor { @@ -90,6 +93,47 @@ namespace osu.Game.Skinning.Editor base.AddBlueprintFor(item); } + protected override bool OnKeyDown(KeyDownEvent e) + { + switch (e.Key) + { + case Key.Left: + moveSelection(new Vector2(-1, 0)); + return true; + + case Key.Right: + moveSelection(new Vector2(1, 0)); + return true; + + case Key.Up: + moveSelection(new Vector2(0, -1)); + return true; + + case Key.Down: + moveSelection(new Vector2(0, 1)); + return true; + } + + return false; + } + + /// + /// Move the current selection spatially by the specified delta, in screen coordinates (ie. the same coordinates as the blueprints). + /// + /// + private void moveSelection(Vector2 delta) + { + var firstBlueprint = SelectionHandler.SelectedBlueprints.FirstOrDefault(); + + if (firstBlueprint == null) + return; + + // convert to game space coordinates + delta = firstBlueprint.ToScreenSpace(delta) - firstBlueprint.ToScreenSpace(Vector2.Zero); + + SelectionHandler.HandleMovement(new MoveSelectionEvent(firstBlueprint, delta)); + } + protected override SelectionHandler CreateSelectionHandler() => new SkinSelectionHandler(); protected override SelectionBlueprint CreateBlueprintFor(ISkinnableDrawable component) diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 19b887eea5..84737bce3f 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -81,13 +81,14 @@ namespace osu.Game.Tests.Visual.Multiplayer public void Disconnect() => isConnected.Value = false; public MultiplayerRoomUser AddUser(APIUser user, bool markAsPlaying = false) - { - var roomUser = new MultiplayerRoomUser(user.Id) { User = user }; + => AddUser(new MultiplayerRoomUser(user.Id) { User = user }, markAsPlaying); + public MultiplayerRoomUser AddUser(MultiplayerRoomUser roomUser, bool markAsPlaying = false) + { addUser(roomUser); if (markAsPlaying) - PlayingUserIds.Add(user.Id); + PlayingUserIds.Add(roomUser.UserID); return roomUser; } diff --git a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs index 2531f3c485..e6d8e473bb 100644 --- a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs +++ b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -37,6 +38,7 @@ namespace osu.Game.Tests.Visual.Spectator private readonly Dictionary lastReceivedUserFrames = new Dictionary(); private readonly Dictionary userBeatmapDictionary = new Dictionary(); + private readonly Dictionary userModsDictionary = new Dictionary(); private readonly Dictionary userNextFrameDictionary = new Dictionary(); [Resolved] @@ -52,9 +54,11 @@ namespace osu.Game.Tests.Visual.Spectator /// /// The user to start play for. /// The playing beatmap id. - public void SendStartPlay(int userId, int beatmapId) + /// The mods the user has applied. + public void SendStartPlay(int userId, int beatmapId, APIMod[]? mods = null) { userBeatmapDictionary[userId] = beatmapId; + userModsDictionary[userId] = mods ?? Array.Empty(); userNextFrameDictionary[userId] = 0; sendPlayingState(userId); } @@ -73,10 +77,12 @@ namespace osu.Game.Tests.Visual.Spectator { BeatmapID = userBeatmapDictionary[userId], RulesetID = 0, + Mods = userModsDictionary[userId], State = state }); userBeatmapDictionary.Remove(userId); + userModsDictionary.Remove(userId); } /// @@ -125,6 +131,7 @@ namespace osu.Game.Tests.Visual.Spectator // Track the local user's playing beatmap ID. Debug.Assert(state.BeatmapID != null); userBeatmapDictionary[api.LocalUser.Value.Id] = state.BeatmapID.Value; + userModsDictionary[api.LocalUser.Value.Id] = state.Mods.ToArray(); return ((ISpectatorClient)this).UserBeganPlaying(api.LocalUser.Value.Id, state); } @@ -158,6 +165,7 @@ namespace osu.Game.Tests.Visual.Spectator { BeatmapID = userBeatmapDictionary[userId], RulesetID = 0, + Mods = userModsDictionary[userId], State = SpectatedUserState.Playing }); } diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 43e3076f5c..fabef87c28 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -36,7 +36,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/osu.iOS.props b/osu.iOS.props index 0f0bf2848c..89166f924c 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -61,7 +61,7 @@ - + @@ -84,7 +84,7 @@ - +