diff --git a/.idea/.idea.osu.Desktop/.idea/modules.xml b/.idea/.idea.osu.Desktop/.idea/modules.xml index fe63f5faf3..680312ad27 100644 --- a/.idea/.idea.osu.Desktop/.idea/modules.xml +++ b/.idea/.idea.osu.Desktop/.idea/modules.xml @@ -2,7 +2,7 @@ - + \ No newline at end of file diff --git a/osu.Android.props b/osu.Android.props index d7817cf4cf..2d531cf01e 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,7 +51,7 @@ - - + + diff --git a/osu.Android/GameplayScreenRotationLocker.cs b/osu.Android/GameplayScreenRotationLocker.cs new file mode 100644 index 0000000000..25bd659a5d --- /dev/null +++ b/osu.Android/GameplayScreenRotationLocker.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Android.Content.PM; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game; + +namespace osu.Android +{ + public class GameplayScreenRotationLocker : Component + { + private Bindable localUserPlaying; + + [Resolved] + private OsuGameActivity gameActivity { get; set; } + + [BackgroundDependencyLoader] + private void load(OsuGame game) + { + localUserPlaying = game.LocalUserPlaying.GetBoundCopy(); + localUserPlaying.BindValueChanged(updateLock, true); + } + + private void updateLock(ValueChangedEvent userPlaying) + { + gameActivity.RunOnUiThread(() => + { + gameActivity.RequestedOrientation = userPlaying.NewValue ? ScreenOrientation.Locked : ScreenOrientation.FullUser; + }); + } + } +} diff --git a/osu.Android/OsuGameActivity.cs b/osu.Android/OsuGameActivity.cs index db73bb7e7f..7e250dce0e 100644 --- a/osu.Android/OsuGameActivity.cs +++ b/osu.Android/OsuGameActivity.cs @@ -12,7 +12,7 @@ namespace osu.Android [Activity(Theme = "@android:style/Theme.NoTitleBar", MainLauncher = true, ScreenOrientation = ScreenOrientation.FullUser, SupportsPictureInPicture = false, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize, HardwareAccelerated = false)] public class OsuGameActivity : AndroidGameActivity { - protected override Framework.Game CreateGame() => new OsuGameAndroid(); + protected override Framework.Game CreateGame() => new OsuGameAndroid(this); protected override void OnCreate(Bundle savedInstanceState) { diff --git a/osu.Android/OsuGameAndroid.cs b/osu.Android/OsuGameAndroid.cs index 7542a2b997..21d6336b2c 100644 --- a/osu.Android/OsuGameAndroid.cs +++ b/osu.Android/OsuGameAndroid.cs @@ -4,6 +4,7 @@ using System; using Android.App; using Android.OS; +using osu.Framework.Allocation; using osu.Game; using osu.Game.Updater; @@ -11,6 +12,15 @@ namespace osu.Android { public class OsuGameAndroid : OsuGame { + [Cached] + private readonly OsuGameActivity gameActivity; + + public OsuGameAndroid(OsuGameActivity activity) + : base(null) + { + gameActivity = activity; + } + public override Version AssemblyVersion { get @@ -55,6 +65,12 @@ namespace osu.Android } } + protected override void LoadComplete() + { + base.LoadComplete(); + LoadComponentAsync(new GameplayScreenRotationLocker(), Add); + } + protected override UpdateManager CreateUpdateManager() => new SimpleUpdateManager(); } } diff --git a/osu.Android/osu.Android.csproj b/osu.Android/osu.Android.csproj index 0598a50530..a2638e95c8 100644 --- a/osu.Android/osu.Android.csproj +++ b/osu.Android/osu.Android.csproj @@ -21,6 +21,7 @@ r8 + @@ -53,4 +54,4 @@ - + \ No newline at end of file diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index 2079f136d2..836b968a67 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -125,12 +125,14 @@ namespace osu.Desktop { base.SetHost(host); + var iconStream = Assembly.GetExecutingAssembly().GetManifestResourceStream(GetType(), "lazer.ico"); + switch (host.Window) { // Legacy osuTK DesktopGameWindow case DesktopGameWindow desktopGameWindow: desktopGameWindow.CursorState |= CursorState.Hidden; - desktopGameWindow.SetIconFromStream(Assembly.GetExecutingAssembly().GetManifestResourceStream(GetType(), "lazer.ico")); + desktopGameWindow.SetIconFromStream(iconStream); desktopGameWindow.Title = Name; desktopGameWindow.FileDrop += (_, e) => fileDrop(e.FileNames); break; @@ -138,6 +140,7 @@ namespace osu.Desktop // SDL2 DesktopWindow case DesktopWindow desktopWindow: desktopWindow.CursorState.Value |= CursorState.Hidden; + desktopWindow.SetIconFromStream(iconStream); desktopWindow.Title = Name; desktopWindow.DragDrop += f => fileDrop(new[] { f }); break; diff --git a/osu.Desktop/Updater/SquirrelUpdateManager.cs b/osu.Desktop/Updater/SquirrelUpdateManager.cs index 05c8e835ac..71f9fafe57 100644 --- a/osu.Desktop/Updater/SquirrelUpdateManager.cs +++ b/osu.Desktop/Updater/SquirrelUpdateManager.cs @@ -29,6 +29,11 @@ namespace osu.Desktop.Updater private static readonly Logger logger = Logger.GetLogger("updater"); + /// + /// Whether an update has been downloaded but not yet applied. + /// + private bool updatePending; + [BackgroundDependencyLoader] private void load(NotificationOverlay notification) { @@ -37,9 +42,9 @@ namespace osu.Desktop.Updater Splat.Locator.CurrentMutable.Register(() => new SquirrelLogger(), typeof(Splat.ILogger)); } - protected override async Task PerformUpdateCheck() => await checkForUpdateAsync(); + protected override async Task PerformUpdateCheck() => await checkForUpdateAsync(); - private async Task checkForUpdateAsync(bool useDeltaPatching = true, UpdateProgressNotification notification = null) + private async Task checkForUpdateAsync(bool useDeltaPatching = true, UpdateProgressNotification notification = null) { // should we schedule a retry on completion of this check? bool scheduleRecheck = true; @@ -49,9 +54,19 @@ namespace osu.Desktop.Updater updateManager ??= await UpdateManager.GitHubUpdateManager(@"https://github.com/ppy/osu", @"osulazer", null, null, true); var info = await updateManager.CheckForUpdate(!useDeltaPatching); + if (info.ReleasesToApply.Count == 0) + { + if (updatePending) + { + // the user may have dismissed the completion notice, so show it again. + notificationOverlay.Post(new UpdateCompleteNotification(this)); + return true; + } + // no updates available. bail and retry later. - return; + return false; + } if (notification == null) { @@ -72,6 +87,7 @@ namespace osu.Desktop.Updater await updateManager.ApplyReleases(info, p => notification.Progress = p / 100f); notification.State = ProgressNotificationState.Completed; + updatePending = true; } catch (Exception e) { @@ -103,6 +119,8 @@ namespace osu.Desktop.Updater Scheduler.AddDelayed(async () => await checkForUpdateAsync(), 60000 * 30); } } + + return true; } protected override void Dispose(bool isDisposing) @@ -111,10 +129,27 @@ namespace osu.Desktop.Updater updateManager?.Dispose(); } + private class UpdateCompleteNotification : ProgressCompletionNotification + { + [Resolved] + private OsuGame game { get; set; } + + public UpdateCompleteNotification(SquirrelUpdateManager updateManager) + { + Text = @"Update ready to install. Click to restart!"; + + Activated = () => + { + updateManager.PrepareUpdateAsync() + .ContinueWith(_ => updateManager.Schedule(() => game.GracefullyExit())); + return true; + }; + } + } + private class UpdateProgressNotification : ProgressNotification { private readonly SquirrelUpdateManager updateManager; - private OsuGame game; public UpdateProgressNotification(SquirrelUpdateManager updateManager) { @@ -123,23 +158,12 @@ namespace osu.Desktop.Updater protected override Notification CreateCompletionNotification() { - return new ProgressCompletionNotification - { - Text = @"Update ready to install. Click to restart!", - Activated = () => - { - updateManager.PrepareUpdateAsync() - .ContinueWith(_ => updateManager.Schedule(() => game.GracefullyExit())); - return true; - } - }; + return new UpdateCompleteNotification(updateManager); } [BackgroundDependencyLoader] - private void load(OsuColour colours, OsuGame game) + private void load(OsuColour colours) { - this.game = game; - IconContent.AddRange(new Drawable[] { new Box diff --git a/osu.Desktop/Windows/GameplayWinKeyBlocker.cs b/osu.Desktop/Windows/GameplayWinKeyBlocker.cs index 86174ceb90..efc3f21149 100644 --- a/osu.Desktop/Windows/GameplayWinKeyBlocker.cs +++ b/osu.Desktop/Windows/GameplayWinKeyBlocker.cs @@ -5,24 +5,24 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Platform; +using osu.Game; using osu.Game.Configuration; namespace osu.Desktop.Windows { public class GameplayWinKeyBlocker : Component { - private Bindable allowScreenSuspension; private Bindable disableWinKey; + private Bindable localUserPlaying; - private GameHost host; + [Resolved] + private GameHost host { get; set; } [BackgroundDependencyLoader] - private void load(GameHost host, OsuConfigManager config) + private void load(OsuGame game, OsuConfigManager config) { - this.host = host; - - allowScreenSuspension = host.AllowScreenSuspension.GetBoundCopy(); - allowScreenSuspension.BindValueChanged(_ => updateBlocking()); + localUserPlaying = game.LocalUserPlaying.GetBoundCopy(); + localUserPlaying.BindValueChanged(_ => updateBlocking()); disableWinKey = config.GetBindable(OsuSetting.GameplayDisableWinKey); disableWinKey.BindValueChanged(_ => updateBlocking(), true); @@ -30,7 +30,7 @@ namespace osu.Desktop.Windows private void updateBlocking() { - bool shouldDisable = disableWinKey.Value && !allowScreenSuspension.Value; + bool shouldDisable = disableWinKey.Value && localUserPlaying.Value; if (shouldDisable) host.InputThread.Scheduler.Add(WindowsKey.Disable); diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs index 1e708cce4b..1b8368794c 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs @@ -123,7 +123,10 @@ namespace osu.Game.Rulesets.Catch.Tests Origin = Anchor.Centre, Scale = new Vector2(4f), }, skin); + }); + AddStep("get trails container", () => + { trails = catcherArea.OfType().Single(); catcherArea.MovableCatcher.SetHyperDashState(2); }); diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs index 1f27de3352..ad584d3f48 100644 --- a/osu.Game.Rulesets.Catch/CatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs @@ -141,6 +141,35 @@ namespace osu.Game.Rulesets.Catch public override Drawable CreateIcon() => new SpriteIcon { Icon = OsuIcon.RulesetCatch }; + protected override IEnumerable GetValidHitResults() + { + return new[] + { + HitResult.Great, + + HitResult.LargeTickHit, + HitResult.SmallTickHit, + HitResult.LargeBonus, + }; + } + + public override string GetDisplayNameForHitResult(HitResult result) + { + switch (result) + { + case HitResult.LargeTickHit: + return "large droplet"; + + case HitResult.SmallTickHit: + return "small droplet"; + + case HitResult.LargeBonus: + return "banana"; + } + + return base.GetDisplayNameForHitResult(result); + } + public override DifficultyCalculator CreateDifficultyCalculator(WorkingBeatmap beatmap) => new CatchDifficultyCalculator(this, beatmap); public override ISkin CreateLegacySkinProvider(ISkinSource source, IBeatmap beatmap) => new CatchLegacySkinTransformer(source); diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs index 6b8b70ed54..e209d012fa 100644 --- a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs +++ b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -56,6 +57,7 @@ namespace osu.Game.Rulesets.Catch.Objects Volume = s.Volume }).ToList(); + int nodeIndex = 0; SliderEventDescriptor? lastEvent = null; foreach (var e in SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset, cancellationToken)) @@ -105,7 +107,7 @@ namespace osu.Game.Rulesets.Catch.Objects case SliderEventType.Repeat: AddNested(new Fruit { - Samples = Samples, + Samples = this.GetNodeSamples(nodeIndex++), StartTime = e.Time, X = X + Path.PositionAt(e.PathProgress).X, }); @@ -119,7 +121,7 @@ namespace osu.Game.Rulesets.Catch.Objects public double Duration { get => this.SpanCount() * Path.Distance / Velocity; - set => throw new System.NotSupportedException($"Adjust via {nameof(RepeatCount)} instead"); // can be implemented if/when needed. + set => throw new NotSupportedException($"Adjust via {nameof(RepeatCount)} instead"); // can be implemented if/when needed. } public double EndTime => StartTime + Duration; diff --git a/osu.Game.Rulesets.Catch/Skinning/CatchLegacySkinTransformer.cs b/osu.Game.Rulesets.Catch/Skinning/CatchLegacySkinTransformer.cs index 47224bd195..22db147e32 100644 --- a/osu.Game.Rulesets.Catch/Skinning/CatchLegacySkinTransformer.cs +++ b/osu.Game.Rulesets.Catch/Skinning/CatchLegacySkinTransformer.cs @@ -13,6 +13,11 @@ namespace osu.Game.Rulesets.Catch.Skinning { public class CatchLegacySkinTransformer : LegacySkinTransformer { + /// + /// For simplicity, let's use legacy combo font texture existence as a way to identify legacy skins from default. + /// + private bool providesComboCounter => this.HasFont(GetConfig(LegacySetting.ComboPrefix)?.Value ?? "score"); + public CatchLegacySkinTransformer(ISkinSource source) : base(source) { @@ -20,6 +25,16 @@ namespace osu.Game.Rulesets.Catch.Skinning public override Drawable GetDrawableComponent(ISkinComponent component) { + if (component is HUDSkinComponent hudComponent) + { + switch (hudComponent.Component) + { + case HUDSkinComponents.ComboCounter: + // catch may provide its own combo counter; hide the default. + return providesComboCounter ? Drawable.Empty() : null; + } + } + if (!(component is CatchSkinComponent catchSkinComponent)) return null; @@ -55,11 +70,9 @@ namespace osu.Game.Rulesets.Catch.Skinning this.GetAnimation("fruit-ryuuta", true, true, true); case CatchSkinComponents.CatchComboCounter: - var comboFont = GetConfig(LegacySetting.ComboPrefix)?.Value ?? "score"; - // For simplicity, let's use legacy combo font texture existence as a way to identify legacy skins from default. - if (this.HasFont(comboFont)) - return new LegacyComboCounter(Source); + if (providesComboCounter) + return new LegacyCatchComboCounter(Source); break; } diff --git a/osu.Game.Rulesets.Catch/Skinning/LegacyComboCounter.cs b/osu.Game.Rulesets.Catch/Skinning/LegacyCatchComboCounter.cs similarity index 96% rename from osu.Game.Rulesets.Catch/Skinning/LegacyComboCounter.cs rename to osu.Game.Rulesets.Catch/Skinning/LegacyCatchComboCounter.cs index c8abc9e832..34608b07ff 100644 --- a/osu.Game.Rulesets.Catch/Skinning/LegacyComboCounter.cs +++ b/osu.Game.Rulesets.Catch/Skinning/LegacyCatchComboCounter.cs @@ -14,13 +14,13 @@ namespace osu.Game.Rulesets.Catch.Skinning /// /// A combo counter implementation that visually behaves almost similar to stable's osu!catch combo counter. /// - public class LegacyComboCounter : CompositeDrawable, ICatchComboCounter + public class LegacyCatchComboCounter : CompositeDrawable, ICatchComboCounter { private readonly LegacyRollingCounter counter; private readonly LegacyRollingCounter explosion; - public LegacyComboCounter(ISkin skin) + public LegacyCatchComboCounter(ISkin skin) { var fontName = skin.GetConfig(LegacySetting.ComboPrefix)?.Value ?? "score"; var fontOverlap = skin.GetConfig(LegacySetting.ComboOverlap)?.Value ?? -2f; diff --git a/osu.Game.Rulesets.Catch/UI/Catcher.cs b/osu.Game.Rulesets.Catch/UI/Catcher.cs index 9289a6162c..a221ca7966 100644 --- a/osu.Game.Rulesets.Catch/UI/Catcher.cs +++ b/osu.Game.Rulesets.Catch/UI/Catcher.cs @@ -145,11 +145,19 @@ namespace osu.Game.Rulesets.Catch.UI } }; - trailsTarget.Add(trails = new CatcherTrailDisplay(this)); + trails = new CatcherTrailDisplay(this); updateCatcher(); } + protected override void LoadComplete() + { + base.LoadComplete(); + + // don't add in above load as we may potentially modify a parent in an unsafe manner. + trailsTarget.Add(trails); + } + /// /// Creates proxied content to be displayed beneath hitobjects. /// diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapConversionTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapConversionTest.cs index 0c57267970..3d4bc4748b 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapConversionTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaBeatmapConversionTest.cs @@ -83,11 +83,17 @@ namespace osu.Game.Rulesets.Mania.Tests RandomZ = snapshot.RandomZ; } + public override void PostProcess() + { + base.PostProcess(); + Objects.Sort(); + } + public bool Equals(ManiaConvertMapping other) => other != null && RandomW == other.RandomW && RandomX == other.RandomX && RandomY == other.RandomY && RandomZ == other.RandomZ; public override bool Equals(ConvertMapping other) => base.Equals(other) && Equals(other as ManiaConvertMapping); } - public struct ConvertValue : IEquatable + public struct ConvertValue : IEquatable, IComparable { /// /// A sane value to account for osu!stable using ints everwhere. @@ -102,5 +108,15 @@ namespace osu.Game.Rulesets.Mania.Tests => Precision.AlmostEquals(StartTime, other.StartTime, conversion_lenience) && Precision.AlmostEquals(EndTime, other.EndTime, conversion_lenience) && Column == other.Column; + + public int CompareTo(ConvertValue other) + { + var result = StartTime.CompareTo(other.StartTime); + + if (result != 0) + return result; + + return Column.CompareTo(other.Column); + } } } diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs index 2c36e81190..a25551f854 100644 --- a/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Mania.Tests/ManiaDifficultyCalculatorTest.cs @@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Mania.Tests { protected override string ResourceAssembly => "osu.Game.Rulesets.Mania"; - [TestCase(2.3683365342338796d, "diffcalc-test")] + [TestCase(2.3449735700206298d, "diffcalc-test")] public void Test(double expected, string name) => base.Test(expected, name); diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs index d1d5adea75..93a9ce3dbd 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmap.cs @@ -21,13 +21,20 @@ namespace osu.Game.Rulesets.Mania.Beatmaps /// public int TotalColumns => Stages.Sum(g => g.Columns); + /// + /// The total number of columns that were present in this before any user adjustments. + /// + public readonly int OriginalTotalColumns; + /// /// Creates a new . /// /// The initial stages. - public ManiaBeatmap(StageDefinition defaultStage) + /// The total number of columns present before any user adjustments. Defaults to the total columns in . + public ManiaBeatmap(StageDefinition defaultStage, int? originalTotalColumns = null) { Stages.Add(defaultStage); + OriginalTotalColumns = originalTotalColumns ?? defaultStage.Columns; } public override IEnumerable GetStatistics() diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs index 524ea27efa..7a0e3b2b76 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs @@ -28,6 +28,8 @@ namespace osu.Game.Rulesets.Mania.Beatmaps public bool Dual; public readonly bool IsForCurrentRuleset; + private readonly int originalTargetColumns; + // Internal for testing purposes internal FastRandom Random { get; private set; } @@ -65,6 +67,8 @@ namespace osu.Game.Rulesets.Mania.Beatmaps else TargetColumns = Math.Max(4, Math.Min((int)roundedOverallDifficulty + 1, 7)); } + + originalTargetColumns = TargetColumns; } public override bool CanConvert() => Beatmap.HitObjects.All(h => h is IHasXPosition); @@ -81,7 +85,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps protected override Beatmap CreateBeatmap() { - beatmap = new ManiaBeatmap(new StageDefinition { Columns = TargetColumns }); + beatmap = new ManiaBeatmap(new StageDefinition { Columns = TargetColumns }, originalTargetColumns); if (Dual) beatmap.Stages.Add(new StageDefinition { Columns = TargetColumns }); @@ -116,7 +120,8 @@ namespace osu.Game.Rulesets.Mania.Beatmaps prevNoteTimes.RemoveAt(0); prevNoteTimes.Add(newNoteTime); - density = (prevNoteTimes[^1] - prevNoteTimes[0]) / prevNoteTimes.Count; + if (prevNoteTimes.Count >= 2) + density = (prevNoteTimes[^1] - prevNoteTimes[0]) / prevNoteTimes.Count; } private double lastTime; @@ -180,7 +185,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps case IHasDuration endTimeData: { - conversion = new EndTimeObjectPatternGenerator(Random, original, beatmap, originalBeatmap); + conversion = new EndTimeObjectPatternGenerator(Random, original, beatmap, lastPattern, originalBeatmap); recordNote(endTimeData.EndTime, new Vector2(256, 192)); computeDensity(endTimeData.EndTime); diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs index fe146c5324..30d33de06e 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs @@ -3,8 +3,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; -using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.MathUtils; @@ -12,6 +12,7 @@ using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Formats; namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy { @@ -25,8 +26,9 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// private const float osu_base_scoring_distance = 100; - public readonly double EndTime; - public readonly double SegmentDuration; + public readonly int StartTime; + public readonly int EndTime; + public readonly int SegmentDuration; public readonly int SpanCount; private PatternType convertType; @@ -41,20 +43,26 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy var distanceData = hitObject as IHasDistance; var repeatsData = hitObject as IHasRepeats; - SpanCount = repeatsData?.SpanCount() ?? 1; + Debug.Assert(distanceData != null); TimingControlPoint timingPoint = beatmap.ControlPointInfo.TimingPointAt(hitObject.StartTime); DifficultyControlPoint difficultyPoint = beatmap.ControlPointInfo.DifficultyPointAt(hitObject.StartTime); - // The true distance, accounting for any repeats - double distance = (distanceData?.Distance ?? 0) * SpanCount; - // The velocity of the osu! hit object - calculated as the velocity of a slider - double osuVelocity = osu_base_scoring_distance * beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier * difficultyPoint.SpeedMultiplier / timingPoint.BeatLength; - // The duration of the osu! hit object - double osuDuration = distance / osuVelocity; + double beatLength; +#pragma warning disable 618 + if (difficultyPoint is LegacyBeatmapDecoder.LegacyDifficultyControlPoint legacyDifficultyPoint) +#pragma warning restore 618 + beatLength = timingPoint.BeatLength * legacyDifficultyPoint.BpmMultiplier; + else + beatLength = timingPoint.BeatLength / difficultyPoint.SpeedMultiplier; - EndTime = hitObject.StartTime + osuDuration; - SegmentDuration = (EndTime - HitObject.StartTime) / SpanCount; + SpanCount = repeatsData?.SpanCount() ?? 1; + StartTime = (int)Math.Round(hitObject.StartTime); + + // This matches stable's calculation. + EndTime = (int)Math.Floor(StartTime + distanceData.Distance * beatLength * SpanCount * 0.01 / beatmap.BeatmapInfo.BaseDifficulty.SliderMultiplier); + + SegmentDuration = (EndTime - StartTime) / SpanCount; } public override IEnumerable Generate() @@ -76,7 +84,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy foreach (var obj in originalPattern.HitObjects) { - if (!Precision.AlmostEquals(EndTime, obj.GetEndTime())) + if (EndTime != (int)Math.Round(obj.GetEndTime())) intermediatePattern.Add(obj); else endTimePattern.Add(obj); @@ -91,35 +99,35 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy if (TotalColumns == 1) { var pattern = new Pattern(); - addToPattern(pattern, 0, HitObject.StartTime, EndTime); + addToPattern(pattern, 0, StartTime, EndTime); return pattern; } if (SpanCount > 1) { if (SegmentDuration <= 90) - return generateRandomHoldNotes(HitObject.StartTime, 1); + return generateRandomHoldNotes(StartTime, 1); if (SegmentDuration <= 120) { convertType |= PatternType.ForceNotStack; - return generateRandomNotes(HitObject.StartTime, SpanCount + 1); + return generateRandomNotes(StartTime, SpanCount + 1); } if (SegmentDuration <= 160) - return generateStair(HitObject.StartTime); + return generateStair(StartTime); if (SegmentDuration <= 200 && ConversionDifficulty > 3) - return generateRandomMultipleNotes(HitObject.StartTime); + return generateRandomMultipleNotes(StartTime); - double duration = EndTime - HitObject.StartTime; + double duration = EndTime - StartTime; if (duration >= 4000) - return generateNRandomNotes(HitObject.StartTime, 0.23, 0, 0); + return generateNRandomNotes(StartTime, 0.23, 0, 0); if (SegmentDuration > 400 && SpanCount < TotalColumns - 1 - RandomStart) - return generateTiledHoldNotes(HitObject.StartTime); + return generateTiledHoldNotes(StartTime); - return generateHoldAndNormalNotes(HitObject.StartTime); + return generateHoldAndNormalNotes(StartTime); } if (SegmentDuration <= 110) @@ -128,37 +136,37 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy convertType |= PatternType.ForceNotStack; else convertType &= ~PatternType.ForceNotStack; - return generateRandomNotes(HitObject.StartTime, SegmentDuration < 80 ? 1 : 2); + return generateRandomNotes(StartTime, SegmentDuration < 80 ? 1 : 2); } if (ConversionDifficulty > 6.5) { if (convertType.HasFlag(PatternType.LowProbability)) - return generateNRandomNotes(HitObject.StartTime, 0.78, 0.3, 0); + return generateNRandomNotes(StartTime, 0.78, 0.3, 0); - return generateNRandomNotes(HitObject.StartTime, 0.85, 0.36, 0.03); + return generateNRandomNotes(StartTime, 0.85, 0.36, 0.03); } if (ConversionDifficulty > 4) { if (convertType.HasFlag(PatternType.LowProbability)) - return generateNRandomNotes(HitObject.StartTime, 0.43, 0.08, 0); + return generateNRandomNotes(StartTime, 0.43, 0.08, 0); - return generateNRandomNotes(HitObject.StartTime, 0.56, 0.18, 0); + return generateNRandomNotes(StartTime, 0.56, 0.18, 0); } if (ConversionDifficulty > 2.5) { if (convertType.HasFlag(PatternType.LowProbability)) - return generateNRandomNotes(HitObject.StartTime, 0.3, 0, 0); + return generateNRandomNotes(StartTime, 0.3, 0, 0); - return generateNRandomNotes(HitObject.StartTime, 0.37, 0.08, 0); + return generateNRandomNotes(StartTime, 0.37, 0.08, 0); } if (convertType.HasFlag(PatternType.LowProbability)) - return generateNRandomNotes(HitObject.StartTime, 0.17, 0, 0); + return generateNRandomNotes(StartTime, 0.17, 0, 0); - return generateNRandomNotes(HitObject.StartTime, 0.27, 0, 0); + return generateNRandomNotes(StartTime, 0.27, 0, 0); } /// @@ -167,7 +175,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// Start time of each hold note. /// Number of hold notes. /// The containing the hit objects. - private Pattern generateRandomHoldNotes(double startTime, int noteCount) + private Pattern generateRandomHoldNotes(int startTime, int noteCount) { // - - - - // ■ - ■ ■ @@ -202,7 +210,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// The start time. /// The number of notes. /// The containing the hit objects. - private Pattern generateRandomNotes(double startTime, int noteCount) + private Pattern generateRandomNotes(int startTime, int noteCount) { // - - - - // x - - - @@ -234,7 +242,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// /// The start time. /// The containing the hit objects. - private Pattern generateStair(double startTime) + private Pattern generateStair(int startTime) { // - - - - // x - - - @@ -286,7 +294,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// /// The start time. /// The containing the hit objects. - private Pattern generateRandomMultipleNotes(double startTime) + private Pattern generateRandomMultipleNotes(int startTime) { // - - - - // x - - - @@ -329,7 +337,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// The probability required for 3 hold notes to be generated. /// The probability required for 4 hold notes to be generated. /// The containing the hit objects. - private Pattern generateNRandomNotes(double startTime, double p2, double p3, double p4) + private Pattern generateNRandomNotes(int startTime, double p2, double p3, double p4) { // - - - - // ■ - ■ ■ @@ -366,7 +374,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy static bool isDoubleSample(HitSampleInfo sample) => sample.Name == HitSampleInfo.HIT_CLAP || sample.Name == HitSampleInfo.HIT_FINISH; bool canGenerateTwoNotes = !convertType.HasFlag(PatternType.LowProbability); - canGenerateTwoNotes &= HitObject.Samples.Any(isDoubleSample) || sampleInfoListAt(HitObject.StartTime).Any(isDoubleSample); + canGenerateTwoNotes &= HitObject.Samples.Any(isDoubleSample) || sampleInfoListAt(StartTime).Any(isDoubleSample); if (canGenerateTwoNotes) p2 = 1; @@ -379,7 +387,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// /// The first hold note start time. /// The containing the hit objects. - private Pattern generateTiledHoldNotes(double startTime) + private Pattern generateTiledHoldNotes(int startTime) { // - - - - // ■ ■ ■ ■ @@ -394,6 +402,9 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy int columnRepeat = Math.Min(SpanCount, TotalColumns); + // Due to integer rounding, this is not guaranteed to be the same as EndTime (the class-level variable). + int endTime = startTime + SegmentDuration * SpanCount; + int nextColumn = GetColumn((HitObject as IHasXPosition)?.X ?? 0, true); if (convertType.HasFlag(PatternType.ForceNotStack) && PreviousPattern.ColumnWithObjects < TotalColumns) nextColumn = FindAvailableColumn(nextColumn, PreviousPattern); @@ -401,7 +412,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy for (int i = 0; i < columnRepeat; i++) { nextColumn = FindAvailableColumn(nextColumn, pattern); - addToPattern(pattern, nextColumn, startTime, EndTime); + addToPattern(pattern, nextColumn, startTime, endTime); startTime += SegmentDuration; } @@ -413,7 +424,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// /// The start time of notes. /// The containing the hit objects. - private Pattern generateHoldAndNormalNotes(double startTime) + private Pattern generateHoldAndNormalNotes(int startTime) { // - - - - // ■ x x - @@ -448,7 +459,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy for (int i = 0; i <= SpanCount; i++) { - if (!(ignoreHead && startTime == HitObject.StartTime)) + if (!(ignoreHead && startTime == StartTime)) { for (int j = 0; j < noteCount; j++) { @@ -471,19 +482,18 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// /// The time to retrieve the sample info list from. /// - private IList sampleInfoListAt(double time) => nodeSamplesAt(time)?.First() ?? HitObject.Samples; + private IList sampleInfoListAt(int time) => nodeSamplesAt(time)?.First() ?? HitObject.Samples; /// /// Retrieves the list of node samples that occur at time greater than or equal to . /// /// The time to retrieve node samples at. - private List> nodeSamplesAt(double time) + private List> nodeSamplesAt(int time) { if (!(HitObject is IHasPathWithRepeats curveData)) return null; - // mathematically speaking this should be a whole number always, but floating-point arithmetic is not so kind - var index = (int)Math.Round(SegmentDuration == 0 ? 0 : (time - HitObject.StartTime) / SegmentDuration, MidpointRounding.AwayFromZero); + var index = SegmentDuration == 0 ? 0 : (time - StartTime) / SegmentDuration; // avoid slicing the list & creating copies, if at all possible. return index == 0 ? curveData.NodeSamples : curveData.NodeSamples.Skip(index).ToList(); @@ -496,7 +506,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy /// The column to add the note to. /// The start time of the note. /// The end time of the note (set to for a non-hold note). - private void addToPattern(Pattern pattern, int column, double startTime, double endTime) + private void addToPattern(Pattern pattern, int column, int startTime, int endTime) { ManiaHitObject newObject; diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/EndTimeObjectPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/EndTimeObjectPatternGenerator.cs index d5286a3779..f816a70ab3 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/EndTimeObjectPatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/EndTimeObjectPatternGenerator.cs @@ -14,12 +14,17 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy { internal class EndTimeObjectPatternGenerator : PatternGenerator { - private readonly double endTime; + private readonly int endTime; + private readonly PatternType convertType; - public EndTimeObjectPatternGenerator(FastRandom random, HitObject hitObject, ManiaBeatmap beatmap, IBeatmap originalBeatmap) - : base(random, hitObject, beatmap, new Pattern(), originalBeatmap) + public EndTimeObjectPatternGenerator(FastRandom random, HitObject hitObject, ManiaBeatmap beatmap, Pattern previousPattern, IBeatmap originalBeatmap) + : base(random, hitObject, beatmap, previousPattern, originalBeatmap) { - endTime = (HitObject as IHasDuration)?.EndTime ?? 0; + endTime = (int)((HitObject as IHasDuration)?.EndTime ?? 0); + + convertType = PreviousPattern.ColumnWithObjects == TotalColumns + ? PatternType.None + : PatternType.ForceNotStack; } public override IEnumerable Generate() @@ -40,18 +45,25 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy break; case 8: - addToPattern(pattern, FindAvailableColumn(GetRandomColumn(), PreviousPattern), generateHold); + addToPattern(pattern, getRandomColumn(), generateHold); break; default: - if (TotalColumns > 0) - addToPattern(pattern, GetRandomColumn(), generateHold); + addToPattern(pattern, getRandomColumn(0), generateHold); break; } return pattern; } + private int getRandomColumn(int? lowerBound = null) + { + if ((convertType & PatternType.ForceNotStack) > 0) + return FindAvailableColumn(GetRandomColumn(lowerBound), lowerBound, patterns: PreviousPattern); + + return FindAvailableColumn(GetRandomColumn(lowerBound), lowerBound); + } + /// /// Constructs and adds a note to a pattern. /// diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/HitObjectPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/HitObjectPatternGenerator.cs index 84f950997d..bc4ab55767 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/HitObjectPatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/HitObjectPatternGenerator.cs @@ -397,7 +397,11 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy case 4: centreProbability = 0; - p2 = Math.Min(p2 * 2, 0.2); + + // Stable requires rngValue > x, which is an inverse-probability. Lazer uses true probability (1 - x). + // But multiplying this value by 2 (stable) is not the same operation as dividing it by 2 (lazer), + // so it needs to be converted to from a probability and then back after the multiplication. + p2 = 1 - Math.Max((1 - p2) * 2, 0.8); p3 = 0; break; @@ -408,11 +412,20 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy case 6: centreProbability = 0; - p2 = Math.Min(p2 * 2, 0.5); - p3 = Math.Min(p3 * 2, 0.15); + + // Stable requires rngValue > x, which is an inverse-probability. Lazer uses true probability (1 - x). + // But multiplying this value by 2 (stable) is not the same operation as dividing it by 2 (lazer), + // so it needs to be converted to from a probability and then back after the multiplication. + p2 = 1 - Math.Max((1 - p2) * 2, 0.5); + p3 = 1 - Math.Max((1 - p3) * 2, 0.85); break; } + // The stable values were allowed to exceed 1, which indicate <0% probability. + // These values needs to be clamped otherwise GetRandomNoteCount() will throw an exception. + p2 = Math.Clamp(p2, 0, 1); + p3 = Math.Clamp(p3, 0, 1); + double centreVal = Random.NextDouble(); int noteCount = GetRandomNoteCount(p2, p3); diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs index 3ff665d2c8..0b58d1efc6 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs @@ -8,5 +8,6 @@ namespace osu.Game.Rulesets.Mania.Difficulty public class ManiaDifficultyAttributes : DifficultyAttributes { public double GreatHitWindow; + public double ScoreMultiplier; } } diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs index b08c520c54..ade830764d 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Linq; using osu.Game.Beatmaps; @@ -10,10 +11,12 @@ using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Difficulty.Preprocessing; using osu.Game.Rulesets.Mania.Difficulty.Skills; +using osu.Game.Rulesets.Mania.MathUtils; using osu.Game.Rulesets.Mania.Mods; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Scoring; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.Difficulty @@ -23,11 +26,13 @@ namespace osu.Game.Rulesets.Mania.Difficulty private const double star_scaling_factor = 0.018; private readonly bool isForCurrentRuleset; + private readonly double originalOverallDifficulty; public ManiaDifficultyCalculator(Ruleset ruleset, WorkingBeatmap beatmap) : base(ruleset, beatmap) { isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.Equals(ruleset.RulesetInfo); + originalOverallDifficulty = beatmap.BeatmapInfo.BaseDifficulty.OverallDifficulty; } protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) @@ -40,64 +45,33 @@ namespace osu.Game.Rulesets.Mania.Difficulty return new ManiaDifficultyAttributes { - StarRating = difficultyValue(skills) * star_scaling_factor, + StarRating = skills[0].DifficultyValue() * star_scaling_factor, Mods = mods, // Todo: This int cast is temporary to achieve 1:1 results with osu!stable, and should be removed in the future - GreatHitWindow = (int)(hitWindows.WindowFor(HitResult.Great)) / clockRate, + GreatHitWindow = (int)Math.Ceiling(getHitWindow300(mods) / clockRate), + ScoreMultiplier = getScoreMultiplier(beatmap, mods), MaxCombo = beatmap.HitObjects.Sum(h => h is HoldNote ? 2 : 1), Skills = skills }; } - private double difficultyValue(Skill[] skills) - { - // Preprocess the strains to find the maximum overall + individual (aggregate) strain from each section - var overall = skills.OfType().Single(); - var aggregatePeaks = new List(Enumerable.Repeat(0.0, overall.StrainPeaks.Count)); - - foreach (var individual in skills.OfType()) - { - for (int i = 0; i < individual.StrainPeaks.Count; i++) - { - double aggregate = individual.StrainPeaks[i] + overall.StrainPeaks[i]; - - if (aggregate > aggregatePeaks[i]) - aggregatePeaks[i] = aggregate; - } - } - - aggregatePeaks.Sort((a, b) => b.CompareTo(a)); // Sort from highest to lowest strain. - - double difficulty = 0; - double weight = 1; - - // Difficulty is the weighted sum of the highest strains from every section. - foreach (double strain in aggregatePeaks) - { - difficulty += strain * weight; - weight *= 0.9; - } - - return difficulty; - } - protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) { - for (int i = 1; i < beatmap.HitObjects.Count; i++) - yield return new ManiaDifficultyHitObject(beatmap.HitObjects[i], beatmap.HitObjects[i - 1], clockRate); + var sortedObjects = beatmap.HitObjects.ToArray(); + + LegacySortHelper.Sort(sortedObjects, Comparer.Create((a, b) => (int)Math.Round(a.StartTime) - (int)Math.Round(b.StartTime))); + + for (int i = 1; i < sortedObjects.Length; i++) + yield return new ManiaDifficultyHitObject(sortedObjects[i], sortedObjects[i - 1], clockRate); } - protected override Skill[] CreateSkills(IBeatmap beatmap) + // Sorting is done in CreateDifficultyHitObjects, since the full list of hitobjects is required. + protected override IEnumerable SortObjects(IEnumerable input) => input; + + protected override Skill[] CreateSkills(IBeatmap beatmap) => new Skill[] { - int columnCount = ((ManiaBeatmap)beatmap).TotalColumns; - - var skills = new List { new Overall(columnCount) }; - - for (int i = 0; i < columnCount; i++) - skills.Add(new Individual(i, columnCount)); - - return skills.ToArray(); - } + new Strain(((ManiaBeatmap)beatmap).TotalColumns) + }; protected override Mod[] DifficultyAdjustmentMods { @@ -122,12 +96,73 @@ namespace osu.Game.Rulesets.Mania.Difficulty new ManiaModKey3(), new ManiaModKey4(), new ManiaModKey5(), + new MultiMod(new ManiaModKey5(), new ManiaModDualStages()), new ManiaModKey6(), + new MultiMod(new ManiaModKey6(), new ManiaModDualStages()), new ManiaModKey7(), + new MultiMod(new ManiaModKey7(), new ManiaModDualStages()), new ManiaModKey8(), + new MultiMod(new ManiaModKey8(), new ManiaModDualStages()), new ManiaModKey9(), + new MultiMod(new ManiaModKey9(), new ManiaModDualStages()), }).ToArray(); } } + + private int getHitWindow300(Mod[] mods) + { + if (isForCurrentRuleset) + { + double od = Math.Min(10.0, Math.Max(0, 10.0 - originalOverallDifficulty)); + return applyModAdjustments(34 + 3 * od, mods); + } + + if (Math.Round(originalOverallDifficulty) > 4) + return applyModAdjustments(34, mods); + + return applyModAdjustments(47, mods); + + static int applyModAdjustments(double value, Mod[] mods) + { + if (mods.Any(m => m is ManiaModHardRock)) + value /= 1.4; + else if (mods.Any(m => m is ManiaModEasy)) + value *= 1.4; + + if (mods.Any(m => m is ManiaModDoubleTime)) + value *= 1.5; + else if (mods.Any(m => m is ManiaModHalfTime)) + value *= 0.75; + + return (int)value; + } + } + + private double getScoreMultiplier(IBeatmap beatmap, Mod[] mods) + { + double scoreMultiplier = 1; + + foreach (var m in mods) + { + switch (m) + { + case ManiaModNoFail _: + case ManiaModEasy _: + case ManiaModHalfTime _: + scoreMultiplier *= 0.5; + break; + } + } + + var maniaBeatmap = (ManiaBeatmap)beatmap; + int diff = maniaBeatmap.TotalColumns - maniaBeatmap.OriginalTotalColumns; + + if (diff > 0) + scoreMultiplier *= 0.9; + else if (diff < 0) + scoreMultiplier *= 0.9 + 0.04 * diff; + + return scoreMultiplier; + } } } diff --git a/osu.Game.Rulesets.Mania/Difficulty/Skills/Individual.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Individual.cs deleted file mode 100644 index 4f7ab87fad..0000000000 --- a/osu.Game.Rulesets.Mania/Difficulty/Skills/Individual.cs +++ /dev/null @@ -1,47 +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 System.Linq; -using osu.Game.Rulesets.Difficulty.Preprocessing; -using osu.Game.Rulesets.Difficulty.Skills; -using osu.Game.Rulesets.Mania.Difficulty.Preprocessing; -using osu.Game.Rulesets.Objects; - -namespace osu.Game.Rulesets.Mania.Difficulty.Skills -{ - public class Individual : Skill - { - protected override double SkillMultiplier => 1; - protected override double StrainDecayBase => 0.125; - - private readonly double[] holdEndTimes; - - private readonly int column; - - public Individual(int column, int columnCount) - { - this.column = column; - - holdEndTimes = new double[columnCount]; - } - - protected override double StrainValueOf(DifficultyHitObject current) - { - var maniaCurrent = (ManiaDifficultyHitObject)current; - var endTime = maniaCurrent.BaseObject.GetEndTime(); - - try - { - if (maniaCurrent.BaseObject.Column != column) - return 0; - - // We give a slight bonus if something is held meanwhile - return holdEndTimes.Any(t => t > endTime) ? 2.5 : 2; - } - finally - { - holdEndTimes[maniaCurrent.BaseObject.Column] = endTime; - } - } - } -} diff --git a/osu.Game.Rulesets.Mania/Difficulty/Skills/Overall.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Overall.cs deleted file mode 100644 index bbbb93fd8b..0000000000 --- a/osu.Game.Rulesets.Mania/Difficulty/Skills/Overall.cs +++ /dev/null @@ -1,56 +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 osu.Game.Rulesets.Difficulty.Preprocessing; -using osu.Game.Rulesets.Difficulty.Skills; -using osu.Game.Rulesets.Mania.Difficulty.Preprocessing; -using osu.Game.Rulesets.Objects; - -namespace osu.Game.Rulesets.Mania.Difficulty.Skills -{ - public class Overall : Skill - { - protected override double SkillMultiplier => 1; - protected override double StrainDecayBase => 0.3; - - private readonly double[] holdEndTimes; - - private readonly int columnCount; - - public Overall(int columnCount) - { - this.columnCount = columnCount; - - holdEndTimes = new double[columnCount]; - } - - protected override double StrainValueOf(DifficultyHitObject current) - { - var maniaCurrent = (ManiaDifficultyHitObject)current; - var endTime = maniaCurrent.BaseObject.GetEndTime(); - - double holdFactor = 1.0; // Factor in case something else is held - double holdAddition = 0; // Addition to the current note in case it's a hold and has to be released awkwardly - - for (int i = 0; i < columnCount; i++) - { - // If there is at least one other overlapping end or note, then we get an addition, buuuuuut... - if (current.BaseObject.StartTime < holdEndTimes[i] && endTime > holdEndTimes[i]) - holdAddition = 1.0; - - // ... this addition only is valid if there is _no_ other note with the same ending. - // Releasing multiple notes at the same time is just as easy as releasing one - if (endTime == holdEndTimes[i]) - holdAddition = 0; - - // We give a slight bonus if something is held meanwhile - if (holdEndTimes[i] > endTime) - holdFactor = 1.25; - } - - holdEndTimes[maniaCurrent.BaseObject.Column] = endTime; - - return (1 + holdAddition) * holdFactor; - } - } -} diff --git a/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs new file mode 100644 index 0000000000..7ebc1ff752 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Difficulty/Skills/Strain.cs @@ -0,0 +1,80 @@ +// 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.Utils; +using osu.Game.Rulesets.Difficulty.Preprocessing; +using osu.Game.Rulesets.Difficulty.Skills; +using osu.Game.Rulesets.Mania.Difficulty.Preprocessing; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Rulesets.Mania.Difficulty.Skills +{ + public class Strain : Skill + { + private const double individual_decay_base = 0.125; + private const double overall_decay_base = 0.30; + + protected override double SkillMultiplier => 1; + protected override double StrainDecayBase => 1; + + private readonly double[] holdEndTimes; + private readonly double[] individualStrains; + + private double individualStrain; + private double overallStrain; + + public Strain(int totalColumns) + { + holdEndTimes = new double[totalColumns]; + individualStrains = new double[totalColumns]; + overallStrain = 1; + } + + protected override double StrainValueOf(DifficultyHitObject current) + { + var maniaCurrent = (ManiaDifficultyHitObject)current; + var endTime = maniaCurrent.BaseObject.GetEndTime(); + var column = maniaCurrent.BaseObject.Column; + + double holdFactor = 1.0; // Factor to all additional strains in case something else is held + double holdAddition = 0; // Addition to the current note in case it's a hold and has to be released awkwardly + + // Fill up the holdEndTimes array + for (int i = 0; i < holdEndTimes.Length; ++i) + { + // If there is at least one other overlapping end or note, then we get an addition, buuuuuut... + if (Precision.DefinitelyBigger(holdEndTimes[i], maniaCurrent.BaseObject.StartTime, 1) && Precision.DefinitelyBigger(endTime, holdEndTimes[i], 1)) + holdAddition = 1.0; + + // ... this addition only is valid if there is _no_ other note with the same ending. Releasing multiple notes at the same time is just as easy as releasing 1 + if (Precision.AlmostEquals(endTime, holdEndTimes[i], 1)) + holdAddition = 0; + + // We give a slight bonus to everything if something is held meanwhile + if (Precision.DefinitelyBigger(holdEndTimes[i], endTime, 1)) + holdFactor = 1.25; + + // Decay individual strains + individualStrains[i] = applyDecay(individualStrains[i], current.DeltaTime, individual_decay_base); + } + + holdEndTimes[column] = endTime; + + // Increase individual strain in own column + individualStrains[column] += 2.0 * holdFactor; + individualStrain = individualStrains[column]; + + overallStrain = applyDecay(overallStrain, current.DeltaTime, overall_decay_base) + (1 + holdAddition) * holdFactor; + + return individualStrain + overallStrain - CurrentStrain; + } + + protected override double GetPeakStrain(double offset) + => applyDecay(individualStrain, offset - Previous[0].BaseObject.StartTime, individual_decay_base) + + applyDecay(overallStrain, offset - Previous[0].BaseObject.StartTime, overall_decay_base); + + private double applyDecay(double value, double deltaTime, double decayBase) + => value * Math.Pow(decayBase, deltaTime / 1000); + } +} diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs index 65f40d7d0a..50629f41a9 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs @@ -45,7 +45,7 @@ namespace osu.Game.Rulesets.Mania.Edit int minColumn = int.MaxValue; int maxColumn = int.MinValue; - foreach (var obj in SelectedHitObjects.OfType()) + foreach (var obj in EditorBeatmap.SelectedHitObjects.OfType()) { if (obj.Column < minColumn) minColumn = obj.Column; @@ -55,7 +55,7 @@ namespace osu.Game.Rulesets.Mania.Edit columnDelta = Math.Clamp(columnDelta, -minColumn, maniaPlayfield.TotalColumns - 1 - maxColumn); - foreach (var obj in SelectedHitObjects.OfType()) + foreach (var obj in EditorBeatmap.SelectedHitObjects.OfType()) obj.Column += columnDelta; } } diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index ecb09ebe85..b92e042686 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -319,6 +319,31 @@ namespace osu.Game.Rulesets.Mania return (PlayfieldType)Enum.GetValues(typeof(PlayfieldType)).Cast().OrderByDescending(i => i).First(v => variant >= v); } + protected override IEnumerable GetValidHitResults() + { + return new[] + { + HitResult.Perfect, + HitResult.Great, + HitResult.Good, + HitResult.Ok, + HitResult.Meh, + + HitResult.LargeTickHit, + }; + } + + public override string GetDisplayNameForHitResult(HitResult result) + { + switch (result) + { + case HitResult.LargeTickHit: + return "hold tick"; + } + + return base.GetDisplayNameForHitResult(result); + } + public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[] { new StatisticRow diff --git a/osu.Game.Rulesets.Mania/MathUtils/LegacySortHelper.cs b/osu.Game.Rulesets.Mania/MathUtils/LegacySortHelper.cs new file mode 100644 index 0000000000..0f4829028f --- /dev/null +++ b/osu.Game.Rulesets.Mania/MathUtils/LegacySortHelper.cs @@ -0,0 +1,165 @@ +// 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.Diagnostics.Contracts; + +namespace osu.Game.Rulesets.Mania.MathUtils +{ + /// + /// Provides access to .NET4.0 unstable sorting methods. + /// + /// + /// Source: https://referencesource.microsoft.com/#mscorlib/system/collections/generic/arraysorthelper.cs + /// Copyright (c) Microsoft Corporation. All rights reserved. + /// + internal static class LegacySortHelper + { + private const int quick_sort_depth_threshold = 32; + + public static void Sort(T[] keys, IComparer comparer) + { + if (keys == null) + throw new ArgumentNullException(nameof(keys)); + + if (keys.Length == 0) + return; + + comparer ??= Comparer.Default; + depthLimitedQuickSort(keys, 0, keys.Length - 1, comparer, quick_sort_depth_threshold); + } + + private static void depthLimitedQuickSort(T[] keys, int left, int right, IComparer comparer, int depthLimit) + { + do + { + if (depthLimit == 0) + { + heapsort(keys, left, right, comparer); + return; + } + + int i = left; + int j = right; + + // pre-sort the low, middle (pivot), and high values in place. + // this improves performance in the face of already sorted data, or + // data that is made up of multiple sorted runs appended together. + int middle = i + ((j - i) >> 1); + swapIfGreater(keys, comparer, i, middle); // swap the low with the mid point + swapIfGreater(keys, comparer, i, j); // swap the low with the high + swapIfGreater(keys, comparer, middle, j); // swap the middle with the high + + T x = keys[middle]; + + do + { + while (comparer.Compare(keys[i], x) < 0) i++; + while (comparer.Compare(x, keys[j]) < 0) j--; + Contract.Assert(i >= left && j <= right, "(i>=left && j<=right) Sort failed - Is your IComparer bogus?"); + if (i > j) break; + + if (i < j) + { + T key = keys[i]; + keys[i] = keys[j]; + keys[j] = key; + } + + i++; + j--; + } while (i <= j); + + // The next iteration of the while loop is to "recursively" sort the larger half of the array and the + // following calls recrusively sort the smaller half. So we subtrack one from depthLimit here so + // both sorts see the new value. + depthLimit--; + + if (j - left <= right - i) + { + if (left < j) depthLimitedQuickSort(keys, left, j, comparer, depthLimit); + left = i; + } + else + { + if (i < right) depthLimitedQuickSort(keys, i, right, comparer, depthLimit); + right = j; + } + } while (left < right); + } + + private static void heapsort(T[] keys, int lo, int hi, IComparer comparer) + { + Contract.Requires(keys != null); + Contract.Requires(comparer != null); + Contract.Requires(lo >= 0); + Contract.Requires(hi > lo); + Contract.Requires(hi < keys.Length); + + int n = hi - lo + 1; + + for (int i = n / 2; i >= 1; i = i - 1) + { + downHeap(keys, i, n, lo, comparer); + } + + for (int i = n; i > 1; i = i - 1) + { + swap(keys, lo, lo + i - 1); + downHeap(keys, 1, i - 1, lo, comparer); + } + } + + private static void downHeap(T[] keys, int i, int n, int lo, IComparer comparer) + { + Contract.Requires(keys != null); + Contract.Requires(comparer != null); + Contract.Requires(lo >= 0); + Contract.Requires(lo < keys.Length); + + T d = keys[lo + i - 1]; + + while (i <= n / 2) + { + var child = 2 * i; + + if (child < n && comparer.Compare(keys[lo + child - 1], keys[lo + child]) < 0) + { + child++; + } + + if (!(comparer.Compare(d, keys[lo + child - 1]) < 0)) + break; + + keys[lo + i - 1] = keys[lo + child - 1]; + i = child; + } + + keys[lo + i - 1] = d; + } + + private static void swap(T[] a, int i, int j) + { + if (i != j) + { + T t = a[i]; + a[i] = a[j]; + a[j] = t; + } + } + + private static void swapIfGreater(T[] keys, IComparer comparer, int a, int b) + { + if (a != b) + { + if (comparer.Compare(keys[a], keys[b]) > 0) + { + T key = keys[a]; + keys[a] = keys[b]; + keys[b] = key; + } + } + } + } +} diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaKeyMod.cs b/osu.Game.Rulesets.Mania/Mods/ManiaKeyMod.cs index 13fdd74113..8fd5950dfb 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaKeyMod.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaKeyMod.cs @@ -39,6 +39,7 @@ namespace osu.Game.Rulesets.Mania.Mods typeof(ManiaModKey7), typeof(ManiaModKey8), typeof(ManiaModKey9), + typeof(ManiaModKey10), }.Except(new[] { GetType() }).ToArray(); } } diff --git a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples-expected-conversion.json b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples-expected-conversion.json index fec1360b26..d49ffa01c5 100644 --- a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples-expected-conversion.json +++ b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/convert-samples-expected-conversion.json @@ -10,7 +10,7 @@ ["soft-hitnormal"], ["drum-hitnormal"] ], - "Samples": ["drum-hitnormal"] + "Samples": ["-hitnormal"] }, { "StartTime": 1875.0, "EndTime": 2750.0, @@ -19,7 +19,7 @@ ["soft-hitnormal"], ["drum-hitnormal"] ], - "Samples": ["drum-hitnormal"] + "Samples": ["-hitnormal"] }] }, { "StartTime": 3750.0, diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs index 3dbbdcc5d0..e14d6647d2 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/HitCircles/HitCirclePlacementBlueprint.cs @@ -21,6 +21,12 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles InternalChild = circlePiece = new HitCirclePiece(); } + protected override void LoadComplete() + { + base.LoadComplete(); + BeginPlacement(); + } + protected override void Update() { base.Update(); diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderBodyPiece.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderBodyPiece.cs index 9349ef7a18..5581ce4bfd 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderBodyPiece.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/SliderBodyPiece.cs @@ -49,6 +49,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components OriginPosition = body.PathOffset; } + public void RecyclePath() => body.RecyclePath(); + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => body.ReceivePositionalInputAt(screenSpacePos); } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs index 94862eb205..d3fb5defae 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs @@ -24,10 +24,12 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { public class SliderSelectionBlueprint : OsuSelectionBlueprint { - protected readonly SliderBodyPiece BodyPiece; - protected readonly SliderCircleSelectionBlueprint HeadBlueprint; - protected readonly SliderCircleSelectionBlueprint TailBlueprint; - protected readonly PathControlPointVisualiser ControlPointVisualiser; + protected SliderBodyPiece BodyPiece { get; private set; } + protected SliderCircleSelectionBlueprint HeadBlueprint { get; private set; } + protected SliderCircleSelectionBlueprint TailBlueprint { get; private set; } + protected PathControlPointVisualiser ControlPointVisualiser { get; private set; } + + private readonly DrawableSlider slider; [Resolved(CanBeNull = true)] private HitObjectComposer composer { get; set; } @@ -44,17 +46,17 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders public SliderSelectionBlueprint(DrawableSlider slider) : base(slider) { - var sliderObject = (Slider)slider.HitObject; + this.slider = slider; + } + [BackgroundDependencyLoader] + private void load() + { InternalChildren = new Drawable[] { BodyPiece = new SliderBodyPiece(), HeadBlueprint = CreateCircleSelectionBlueprint(slider, SliderPosition.Start), TailBlueprint = CreateCircleSelectionBlueprint(slider, SliderPosition.End), - ControlPointVisualiser = new PathControlPointVisualiser(sliderObject, true) - { - RemoveControlPointsRequested = removeControlPoints - } }; } @@ -66,13 +68,35 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders pathVersion = HitObject.Path.Version.GetBoundCopy(); pathVersion.BindValueChanged(_ => updatePath()); + + BodyPiece.UpdateFrom(HitObject); } protected override void Update() { base.Update(); - BodyPiece.UpdateFrom(HitObject); + if (IsSelected) + BodyPiece.UpdateFrom(HitObject); + } + + protected override void OnSelected() + { + AddInternal(ControlPointVisualiser = new PathControlPointVisualiser((Slider)slider.HitObject, true) + { + RemoveControlPointsRequested = removeControlPoints + }); + + base.OnSelected(); + } + + protected override void OnDeselected() + { + base.OnDeselected(); + + // throw away frame buffers on deselection. + ControlPointVisualiser?.Expire(); + BodyPiece.RecyclePath(); } private Vector2 rightClickPosition; @@ -182,7 +206,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders private void updatePath() { HitObject.Path.ExpectedDistance.Value = composer?.GetSnappedDistanceFromDistance(HitObject.StartTime, (float)HitObject.Path.CalculatedDistance) ?? (float)HitObject.Path.CalculatedDistance; - editorBeatmap?.UpdateHitObject(HitObject); + editorBeatmap?.Update(HitObject); } public override MenuItem[] ContextMenuItems => new MenuItem[] diff --git a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs index 7ae0730e39..a72dcff1e9 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuSelectionHandler.cs @@ -7,6 +7,7 @@ using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; using osu.Framework.Utils; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.Edit.Compose.Components; @@ -20,11 +21,12 @@ namespace osu.Game.Rulesets.Osu.Edit { base.OnSelectionChanged(); - bool canOperate = SelectedHitObjects.Count() > 1 || SelectedHitObjects.Any(s => s is Slider); + bool canOperate = EditorBeatmap.SelectedHitObjects.Count > 1 || EditorBeatmap.SelectedHitObjects.Any(s => s is Slider); SelectionBox.CanRotate = canOperate; SelectionBox.CanScaleX = canOperate; SelectionBox.CanScaleY = canOperate; + SelectionBox.CanReverse = canOperate; } protected override void OnOperationEnded() @@ -41,6 +43,54 @@ namespace osu.Game.Rulesets.Osu.Edit /// private Vector2? referenceOrigin; + public override bool HandleReverse() + { + var hitObjects = selectedMovableObjects; + + double endTime = hitObjects.Max(h => h.GetEndTime()); + double startTime = hitObjects.Min(h => h.StartTime); + + bool moreThanOneObject = hitObjects.Length > 1; + + foreach (var h in hitObjects) + { + if (moreThanOneObject) + h.StartTime = endTime - (h.GetEndTime() - startTime); + + if (h is Slider slider) + { + var points = slider.Path.ControlPoints.ToArray(); + Vector2 endPos = points.Last().Position.Value; + + slider.Path.ControlPoints.Clear(); + + slider.Position += endPos; + + PathType? lastType = null; + + for (var i = 0; i < points.Length; i++) + { + var p = points[i]; + p.Position.Value -= endPos; + + // propagate types forwards to last null type + if (i == points.Length - 1) + p.Type.Value = lastType; + else if (p.Type.Value != null) + { + var newType = p.Type.Value; + p.Type.Value = lastType; + lastType = newType; + } + + slider.Path.ControlPoints.Insert(0, p); + } + } + } + + return true; + } + public override bool HandleFlip(Direction direction) { var hitObjects = selectedMovableObjects; @@ -182,7 +232,7 @@ namespace osu.Game.Rulesets.Osu.Edit /// The points to calculate a quad for. private Quad getSurroundingQuad(IEnumerable points) { - if (!SelectedHitObjects.Any()) + if (!EditorBeatmap.SelectedHitObjects.Any()) return new Quad(); Vector2 minPosition = new Vector2(float.MaxValue, float.MaxValue); @@ -203,10 +253,10 @@ namespace osu.Game.Rulesets.Osu.Edit /// /// All osu! hitobjects which can be moved/rotated/scaled. /// - private OsuHitObject[] selectedMovableObjects => SelectedHitObjects - .OfType() - .Where(h => !(h is Spinner)) - .ToArray(); + private OsuHitObject[] selectedMovableObjects => EditorBeatmap.SelectedHitObjects + .OfType() + .Where(h => !(h is Spinner)) + .ToArray(); /// /// Rotate a point around an arbitrary origin. diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs index 80e40af717..f69cacd432 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs @@ -42,7 +42,11 @@ namespace osu.Game.Rulesets.Osu.Mods private double lastSliderHeadFadeOutStartTime; private double lastSliderHeadFadeOutDuration; - protected override void ApplyHiddenState(DrawableHitObject drawable, ArmedState state) + protected override void ApplyFirstObjectIncreaseVisibilityState(DrawableHitObject drawable, ArmedState state) => applyState(drawable, true); + + protected override void ApplyHiddenState(DrawableHitObject drawable, ArmedState state) => applyState(drawable, false); + + private void applyState(DrawableHitObject drawable, bool increaseVisibility) { if (!(drawable is DrawableOsuHitObject d)) return; @@ -86,14 +90,23 @@ namespace osu.Game.Rulesets.Osu.Mods lastSliderHeadFadeOutStartTime = fadeOutStartTime; } - // we don't want to see the approach circle - using (circle.BeginAbsoluteSequence(h.StartTime - h.TimePreempt, true)) - circle.ApproachCircle.Hide(); + Drawable fadeTarget = circle; + + if (increaseVisibility) + { + // only fade the circle piece (not the approach circle) for the increased visibility object. + fadeTarget = circle.CirclePiece; + } + else + { + // we don't want to see the approach circle + using (circle.BeginAbsoluteSequence(h.StartTime - h.TimePreempt, true)) + circle.ApproachCircle.Hide(); + } // fade out immediately after fade in. using (drawable.BeginAbsoluteSequence(fadeOutStartTime, true)) - circle.FadeOut(fadeOutDuration); - + fadeTarget.FadeOut(fadeOutDuration); break; case DrawableSlider slider: diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index 917382eccf..755ce0866a 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -137,6 +137,10 @@ namespace osu.Game.Rulesets.Osu.Objects Velocity = scoringDistance / timingPoint.BeatLength; TickDistance = scoringDistance / difficulty.SliderTickRate * TickDistanceMultiplier; + + // The samples should be attached to the slider tail, however this can only be done after LegacyLastTick is removed otherwise they would play earlier than they're intended to. + // For now, the samples are attached to and played by the slider itself at the correct end time. + Samples = this.GetNodeSamples(repeatCount + 1); } protected override void CreateNestedHitObjects(CancellationToken cancellationToken) @@ -230,15 +234,12 @@ namespace osu.Game.Rulesets.Osu.Objects tick.Samples = sampleList; foreach (var repeat in NestedHitObjects.OfType()) - repeat.Samples = getNodeSamples(repeat.RepeatIndex + 1); + repeat.Samples = this.GetNodeSamples(repeat.RepeatIndex + 1); if (HeadCircle != null) - HeadCircle.Samples = getNodeSamples(0); + HeadCircle.Samples = this.GetNodeSamples(0); } - private IList getNodeSamples(int nodeIndex) => - nodeIndex < NodeSamples.Count ? NodeSamples[nodeIndex] : Samples; - public override Judgement CreateJudgement() => new OsuIgnoreJudgement(); protected override HitWindows CreateHitWindows() => HitWindows.Empty; diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index cc2eebdd36..678fb8aba6 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -191,6 +191,41 @@ namespace osu.Game.Rulesets.Osu public override IRulesetConfigManager CreateConfig(SettingsStore settings) => new OsuRulesetConfigManager(settings, RulesetInfo); + protected override IEnumerable GetValidHitResults() + { + return new[] + { + HitResult.Great, + HitResult.Ok, + HitResult.Meh, + + HitResult.LargeTickHit, + HitResult.SmallTickHit, + HitResult.SmallBonus, + HitResult.LargeBonus, + }; + } + + public override string GetDisplayNameForHitResult(HitResult result) + { + switch (result) + { + case HitResult.LargeTickHit: + return "slider tick"; + + case HitResult.SmallTickHit: + return "slider end"; + + case HitResult.SmallBonus: + return "spinner spin"; + + case HitResult.LargeBonus: + return "spinner bonus"; + } + + return base.GetDisplayNameForHitResult(result); + } + public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) { var timedHitEvents = score.HitEvents.Where(e => e.HitObject is HitCircle && !(e.HitObject is SliderTailCircle)).ToList(); diff --git a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs index ed7b8589ba..607eaf5dbd 100644 --- a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs +++ b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs @@ -65,7 +65,7 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps converted.HitObjects = converted.HitObjects.GroupBy(t => t.StartTime).Select(x => { TaikoHitObject first = x.First(); - if (x.Skip(1).Any() && !(first is Swell)) + if (x.Skip(1).Any() && first.CanBeStrong) first.IsStrong = true; return first; }).ToList(); diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs index d5dd758e10..a05de1f217 100644 --- a/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoSelectionHandler.cs @@ -52,32 +52,32 @@ namespace osu.Game.Rulesets.Taiko.Edit public void SetStrongState(bool state) { - var hits = SelectedHitObjects.OfType(); + var hits = EditorBeatmap.SelectedHitObjects.OfType(); - ChangeHandler.BeginChange(); + EditorBeatmap.BeginChange(); foreach (var h in hits) { if (h.IsStrong != state) { h.IsStrong = state; - EditorBeatmap.UpdateHitObject(h); + EditorBeatmap.Update(h); } } - ChangeHandler.EndChange(); + EditorBeatmap.EndChange(); } public void SetRimState(bool state) { - var hits = SelectedHitObjects.OfType(); + var hits = EditorBeatmap.SelectedHitObjects.OfType(); - ChangeHandler.BeginChange(); + EditorBeatmap.BeginChange(); foreach (var h in hits) h.Type = state ? HitType.Rim : HitType.Centre; - ChangeHandler.EndChange(); + EditorBeatmap.EndChange(); } protected override IEnumerable GetContextMenuItemsForSelection(IEnumerable selection) @@ -89,12 +89,14 @@ namespace osu.Game.Rulesets.Taiko.Edit yield return new TernaryStateMenuItem("Strong") { State = { BindTarget = selectionStrongState } }; } + public override bool HandleMovement(MoveSelectionEvent moveEvent) => true; + protected override void UpdateTernaryStates() { base.UpdateTernaryStates(); - selectionRimState.Value = GetStateFromSelection(SelectedHitObjects.OfType(), h => h.Type == HitType.Rim); - selectionStrongState.Value = GetStateFromSelection(SelectedHitObjects.OfType(), h => h.IsStrong); + selectionRimState.Value = GetStateFromSelection(EditorBeatmap.SelectedHitObjects.OfType(), h => h.Type == HitType.Rim); + selectionStrongState.Value = GetStateFromSelection(EditorBeatmap.SelectedHitObjects.OfType(), h => h.IsStrong); } } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs index 9cd23383c4..d8d75a7614 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs @@ -158,7 +158,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { base.LoadSamples(); - isStrong.Value = getStrongSamples().Any(); + if (HitObject.CanBeStrong) + isStrong.Value = getStrongSamples().Any(); } private void updateSamplesFromStrong() diff --git a/osu.Game.Rulesets.Taiko/Objects/Swell.cs b/osu.Game.Rulesets.Taiko/Objects/Swell.cs index eeae6e79f8..bf8b7bc178 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Swell.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Swell.cs @@ -17,6 +17,8 @@ namespace osu.Game.Rulesets.Taiko.Objects set => Duration = value - StartTime; } + public override bool CanBeStrong => false; + public double Duration { get; set; } /// diff --git a/osu.Game.Rulesets.Taiko/Objects/TaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/TaikoHitObject.cs index 2922010001..d2c37d965c 100644 --- a/osu.Game.Rulesets.Taiko/Objects/TaikoHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/TaikoHitObject.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Threading; using osu.Framework.Bindables; using osu.Game.Rulesets.Judgements; @@ -30,6 +31,11 @@ namespace osu.Game.Rulesets.Taiko.Objects public readonly Bindable IsStrongBindable = new BindableBool(); + /// + /// Whether this can be made a "strong" (large) hit. + /// + public virtual bool CanBeStrong => true; + /// /// Whether this HitObject is a "strong" type. /// Strong hit objects give more points for hitting the hit object with both keys. @@ -37,7 +43,13 @@ namespace osu.Game.Rulesets.Taiko.Objects public bool IsStrong { get => IsStrongBindable.Value; - set => IsStrongBindable.Value = value; + set + { + if (value && !CanBeStrong) + throw new InvalidOperationException($"Object of type {GetType()} cannot be strong"); + + IsStrongBindable.Value = value; + } } protected override void CreateNestedHitObjects(CancellationToken cancellationToken) diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index 642eb0ddcc..73e9c16d07 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -159,6 +159,33 @@ namespace osu.Game.Rulesets.Taiko public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new TaikoReplayFrame(); + protected override IEnumerable GetValidHitResults() + { + return new[] + { + HitResult.Great, + HitResult.Ok, + + HitResult.SmallTickHit, + + HitResult.SmallBonus, + }; + } + + public override string GetDisplayNameForHitResult(HitResult result) + { + switch (result) + { + case HitResult.SmallTickHit: + return "drum tick"; + + case HitResult.SmallBonus: + return "strong bonus"; + } + + return base.GetDisplayNameForHitResult(result); + } + public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) { var timedHitEvents = score.HitEvents.Where(e => e.HitObject is Hit).ToList(); diff --git a/osu.Game.Tests/Beatmaps/BeatmapDifficultyManagerTest.cs b/osu.Game.Tests/Beatmaps/BeatmapDifficultyManagerTest.cs index 0f6d956b3c..7c1ddd757f 100644 --- a/osu.Game.Tests/Beatmaps/BeatmapDifficultyManagerTest.cs +++ b/osu.Game.Tests/Beatmaps/BeatmapDifficultyManagerTest.cs @@ -28,5 +28,28 @@ namespace osu.Game.Tests.Beatmaps Assert.That(key1, Is.EqualTo(key2)); } + + [TestCase(1.3, DifficultyRating.Easy)] + [TestCase(1.993, DifficultyRating.Easy)] + [TestCase(1.998, DifficultyRating.Normal)] + [TestCase(2.4, DifficultyRating.Normal)] + [TestCase(2.693, DifficultyRating.Normal)] + [TestCase(2.698, DifficultyRating.Hard)] + [TestCase(3.5, DifficultyRating.Hard)] + [TestCase(3.993, DifficultyRating.Hard)] + [TestCase(3.997, DifficultyRating.Insane)] + [TestCase(5.0, DifficultyRating.Insane)] + [TestCase(5.292, DifficultyRating.Insane)] + [TestCase(5.297, DifficultyRating.Expert)] + [TestCase(6.2, DifficultyRating.Expert)] + [TestCase(6.493, DifficultyRating.Expert)] + [TestCase(6.498, DifficultyRating.ExpertPlus)] + [TestCase(8.3, DifficultyRating.ExpertPlus)] + public void TestDifficultyRatingMapping(double starRating, DifficultyRating expectedBracket) + { + var actualBracket = BeatmapDifficultyManager.GetDifficultyRating(starRating); + + Assert.AreEqual(expectedBracket, actualBracket); + } } } diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs index dab923d75b..b6e1af57fd 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs @@ -651,5 +651,63 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.IsInstanceOf(decoder); } } + + [Test] + public void TestMultiSegmentSliders() + { + var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; + + using (var resStream = TestResources.OpenResource("multi-segment-slider.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + var decoded = decoder.Decode(stream); + + // Multi-segment + var first = ((IHasPath)decoded.HitObjects[0]).Path; + + Assert.That(first.ControlPoints[0].Position.Value, Is.EqualTo(Vector2.Zero)); + Assert.That(first.ControlPoints[0].Type.Value, Is.EqualTo(PathType.PerfectCurve)); + Assert.That(first.ControlPoints[1].Position.Value, Is.EqualTo(new Vector2(161, -244))); + Assert.That(first.ControlPoints[1].Type.Value, Is.EqualTo(null)); + + Assert.That(first.ControlPoints[2].Position.Value, Is.EqualTo(new Vector2(376, -3))); + Assert.That(first.ControlPoints[2].Type.Value, Is.EqualTo(PathType.Bezier)); + Assert.That(first.ControlPoints[3].Position.Value, Is.EqualTo(new Vector2(68, 15))); + Assert.That(first.ControlPoints[3].Type.Value, Is.EqualTo(null)); + Assert.That(first.ControlPoints[4].Position.Value, Is.EqualTo(new Vector2(259, -132))); + Assert.That(first.ControlPoints[4].Type.Value, Is.EqualTo(null)); + Assert.That(first.ControlPoints[5].Position.Value, Is.EqualTo(new Vector2(92, -107))); + Assert.That(first.ControlPoints[5].Type.Value, Is.EqualTo(null)); + + // Single-segment + var second = ((IHasPath)decoded.HitObjects[1]).Path; + + Assert.That(second.ControlPoints[0].Position.Value, Is.EqualTo(Vector2.Zero)); + Assert.That(second.ControlPoints[0].Type.Value, Is.EqualTo(PathType.PerfectCurve)); + Assert.That(second.ControlPoints[1].Position.Value, Is.EqualTo(new Vector2(161, -244))); + Assert.That(second.ControlPoints[1].Type.Value, Is.EqualTo(null)); + Assert.That(second.ControlPoints[2].Position.Value, Is.EqualTo(new Vector2(376, -3))); + Assert.That(second.ControlPoints[2].Type.Value, Is.EqualTo(null)); + + // Implicit multi-segment + var third = ((IHasPath)decoded.HitObjects[2]).Path; + + Assert.That(third.ControlPoints[0].Position.Value, Is.EqualTo(Vector2.Zero)); + Assert.That(third.ControlPoints[0].Type.Value, Is.EqualTo(PathType.Bezier)); + Assert.That(third.ControlPoints[1].Position.Value, Is.EqualTo(new Vector2(0, 192))); + Assert.That(third.ControlPoints[1].Type.Value, Is.EqualTo(null)); + Assert.That(third.ControlPoints[2].Position.Value, Is.EqualTo(new Vector2(224, 192))); + Assert.That(third.ControlPoints[2].Type.Value, Is.EqualTo(null)); + + Assert.That(third.ControlPoints[3].Position.Value, Is.EqualTo(new Vector2(224, 0))); + Assert.That(third.ControlPoints[3].Type.Value, Is.EqualTo(PathType.Bezier)); + Assert.That(third.ControlPoints[4].Position.Value, Is.EqualTo(new Vector2(224, -192))); + Assert.That(third.ControlPoints[4].Type.Value, Is.EqualTo(null)); + Assert.That(third.ControlPoints[5].Position.Value, Is.EqualTo(new Vector2(480, -192))); + Assert.That(third.ControlPoints[5].Type.Value, Is.EqualTo(null)); + Assert.That(third.ControlPoints[6].Position.Value, Is.EqualTo(new Vector2(480, 0))); + Assert.That(third.ControlPoints[6].Type.Value, Is.EqualTo(null)); + } + } } } diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs index 8b22309033..0784109158 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs @@ -30,7 +30,7 @@ namespace osu.Game.Tests.Beatmaps.Formats { private static readonly DllResourceStore beatmaps_resource_store = TestResources.GetStore(); - private static IEnumerable allBeatmaps = beatmaps_resource_store.GetAvailableResources().Where(res => res.EndsWith(".osu")); + private static IEnumerable allBeatmaps = beatmaps_resource_store.GetAvailableResources().Where(res => res.EndsWith(".osu", StringComparison.Ordinal)); [TestCaseSource(nameof(allBeatmaps))] public void TestEncodeDecodeStability(string name) diff --git a/osu.Game.Tests/Editing/TransactionalCommitComponentTest.cs b/osu.Game.Tests/Editing/TransactionalCommitComponentTest.cs new file mode 100644 index 0000000000..4ce9115ec4 --- /dev/null +++ b/osu.Game.Tests/Editing/TransactionalCommitComponentTest.cs @@ -0,0 +1,100 @@ +// 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 NUnit.Framework; +using osu.Game.Screens.Edit; + +namespace osu.Game.Tests.Editing +{ + [TestFixture] + public class TransactionalCommitComponentTest + { + private TestHandler handler; + + [SetUp] + public void SetUp() + { + handler = new TestHandler(); + } + + [Test] + public void TestCommitTransaction() + { + Assert.That(handler.StateUpdateCount, Is.EqualTo(0)); + + handler.BeginChange(); + Assert.That(handler.StateUpdateCount, Is.EqualTo(0)); + handler.EndChange(); + + Assert.That(handler.StateUpdateCount, Is.EqualTo(1)); + } + + [Test] + public void TestSaveOutsideOfTransactionTriggersUpdates() + { + Assert.That(handler.StateUpdateCount, Is.EqualTo(0)); + + handler.SaveState(); + Assert.That(handler.StateUpdateCount, Is.EqualTo(1)); + + handler.SaveState(); + Assert.That(handler.StateUpdateCount, Is.EqualTo(2)); + } + + [Test] + public void TestEventsFire() + { + int transactionBegan = 0; + int transactionEnded = 0; + int stateSaved = 0; + + handler.TransactionBegan += () => transactionBegan++; + handler.TransactionEnded += () => transactionEnded++; + handler.SaveStateTriggered += () => stateSaved++; + + handler.BeginChange(); + Assert.That(transactionBegan, Is.EqualTo(1)); + + handler.EndChange(); + Assert.That(transactionEnded, Is.EqualTo(1)); + + Assert.That(stateSaved, Is.EqualTo(0)); + handler.SaveState(); + Assert.That(stateSaved, Is.EqualTo(1)); + } + + [Test] + public void TestSaveDuringTransactionDoesntTriggerUpdate() + { + Assert.That(handler.StateUpdateCount, Is.EqualTo(0)); + + handler.BeginChange(); + + handler.SaveState(); + Assert.That(handler.StateUpdateCount, Is.EqualTo(0)); + + handler.EndChange(); + + Assert.That(handler.StateUpdateCount, Is.EqualTo(1)); + } + + [Test] + public void TestEndWithoutBeginThrows() + { + handler.BeginChange(); + handler.EndChange(); + Assert.That(() => handler.EndChange(), Throws.TypeOf()); + } + + private class TestHandler : TransactionalCommitComponent + { + public int StateUpdateCount { get; private set; } + + protected override void UpdateState() + { + StateUpdateCount++; + } + } + } +} diff --git a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs index b6ab73eceb..045246e5ed 100644 --- a/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs +++ b/osu.Game.Tests/NonVisual/CustomDataDirectoryTest.cs @@ -111,6 +111,7 @@ namespace osu.Game.Tests.NonVisual var osu = LoadOsuIntoHost(host); var storage = osu.Dependencies.Get(); + var osuStorage = storage as MigratableStorage; // Store the current storage's path. We'll need to refer to this for assertions in the original directory after the migration completes. string originalDirectory = storage.GetFullPath("."); @@ -137,13 +138,15 @@ namespace osu.Game.Tests.NonVisual Assert.That(!Directory.Exists(Path.Combine(originalDirectory, "test-nested", "cache"))); Assert.That(storage.ExistsDirectory(Path.Combine("test-nested", "cache"))); - foreach (var file in OsuStorage.IGNORE_FILES) + Assert.That(osuStorage, Is.Not.Null); + + foreach (var file in osuStorage.IgnoreFiles) { Assert.That(File.Exists(Path.Combine(originalDirectory, file))); Assert.That(storage.Exists(file), Is.False); } - foreach (var dir in OsuStorage.IGNORE_DIRECTORIES) + foreach (var dir in osuStorage.IgnoreDirectories) { Assert.That(Directory.Exists(Path.Combine(originalDirectory, dir))); Assert.That(storage.ExistsDirectory(dir), Is.False); diff --git a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs index 760a033aff..5c7adb3f49 100644 --- a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs +++ b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs @@ -94,6 +94,52 @@ namespace osu.Game.Tests.NonVisual Assert.IsTrue(combinations[2] is ModIncompatibleWithAofA); } + [Test] + public void TestMultiModFlattening() + { + var combinations = new TestLegacyDifficultyCalculator(new ModA(), new MultiMod(new ModB(), new ModC())).CreateDifficultyAdjustmentModCombinations(); + + Assert.AreEqual(4, combinations.Length); + Assert.IsTrue(combinations[0] is ModNoMod); + Assert.IsTrue(combinations[1] is ModA); + Assert.IsTrue(combinations[2] is MultiMod); + Assert.IsTrue(combinations[3] is MultiMod); + + Assert.IsTrue(((MultiMod)combinations[2]).Mods[0] is ModA); + Assert.IsTrue(((MultiMod)combinations[2]).Mods[1] is ModB); + Assert.IsTrue(((MultiMod)combinations[2]).Mods[2] is ModC); + Assert.IsTrue(((MultiMod)combinations[3]).Mods[0] is ModB); + Assert.IsTrue(((MultiMod)combinations[3]).Mods[1] is ModC); + } + + [Test] + public void TestIncompatibleThroughMultiMod() + { + var combinations = new TestLegacyDifficultyCalculator(new ModA(), new MultiMod(new ModB(), new ModIncompatibleWithA())).CreateDifficultyAdjustmentModCombinations(); + + Assert.AreEqual(3, combinations.Length); + Assert.IsTrue(combinations[0] is ModNoMod); + Assert.IsTrue(combinations[1] is ModA); + Assert.IsTrue(combinations[2] is MultiMod); + + Assert.IsTrue(((MultiMod)combinations[2]).Mods[0] is ModB); + Assert.IsTrue(((MultiMod)combinations[2]).Mods[1] is ModIncompatibleWithA); + } + + [Test] + public void TestIncompatibleWithSameInstanceViaMultiMod() + { + var combinations = new TestLegacyDifficultyCalculator(new ModA(), new MultiMod(new ModA(), new ModB())).CreateDifficultyAdjustmentModCombinations(); + + Assert.AreEqual(3, combinations.Length); + Assert.IsTrue(combinations[0] is ModNoMod); + Assert.IsTrue(combinations[1] is ModA); + Assert.IsTrue(combinations[2] is MultiMod); + + Assert.IsTrue(((MultiMod)combinations[2]).Mods[0] is ModA); + Assert.IsTrue(((MultiMod)combinations[2]).Mods[1] is ModB); + } + private class ModA : Mod { public override string Name => nameof(ModA); @@ -112,6 +158,13 @@ namespace osu.Game.Tests.NonVisual public override Type[] IncompatibleMods => new[] { typeof(ModIncompatibleWithAAndB) }; } + private class ModC : Mod + { + public override string Name => nameof(ModC); + public override string Acronym => nameof(ModC); + public override double ScoreMultiplier => 1; + } + private class ModIncompatibleWithA : Mod { public override string Name => $"Incompatible With {nameof(ModA)}"; diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs index 30686cb947..24a0a662ba 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs @@ -197,5 +197,22 @@ namespace osu.Game.Tests.NonVisual.Filtering carouselItem.Filter(criteria); Assert.AreEqual(filtered, carouselItem.Filtered.Value); } + + [TestCase("202010", true)] + [TestCase("20201010", false)] + [TestCase("153", true)] + [TestCase("1535", false)] + public void TestCriteriaMatchingBeatmapIDs(string query, bool filtered) + { + var beatmap = getExampleBeatmap(); + beatmap.OnlineBeatmapID = 20201010; + beatmap.BeatmapSet = new BeatmapSetInfo { OnlineBeatmapSetID = 1535 }; + + var criteria = new FilterCriteria { SearchText = query }; + var carouselItem = new CarouselBeatmap(beatmap); + carouselItem.Filter(criteria); + + Assert.AreEqual(filtered, carouselItem.Filtered.Value); + } } } diff --git a/osu.Game.Tests/Resources/multi-segment-slider.osu b/osu.Game.Tests/Resources/multi-segment-slider.osu new file mode 100644 index 0000000000..6eabe640e4 --- /dev/null +++ b/osu.Game.Tests/Resources/multi-segment-slider.osu @@ -0,0 +1,11 @@ +osu file format v128 + +[HitObjects] +// Multi-segment +63,301,1000,6,0,P|224:57|B|439:298|131:316|322:169|155:194,1,1040,0|0,0:0|0:0,0:0:0:0: + +// Single-segment +63,301,2000,6,0,P|224:57|439:298,1,1040,0|0,0:0|0:0,0:0:0:0: + +// Implicit multi-segment +32,192,3000,6,0,B|32:384|256:384|256:192|256:192|256:0|512:0|512:192,1,800 diff --git a/osu.Game.Tests/Resources/old-skin/score-0.png b/osu.Game.Tests/Resources/old-skin/score-0.png new file mode 100644 index 0000000000..8304617d8c Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-0.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-1.png b/osu.Game.Tests/Resources/old-skin/score-1.png new file mode 100644 index 0000000000..c3b85eb873 Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-1.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-2.png b/osu.Game.Tests/Resources/old-skin/score-2.png new file mode 100644 index 0000000000..7f65eb7ca7 Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-2.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-3.png b/osu.Game.Tests/Resources/old-skin/score-3.png new file mode 100644 index 0000000000..82bec3babe Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-3.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-4.png b/osu.Game.Tests/Resources/old-skin/score-4.png new file mode 100644 index 0000000000..5e38c75a9d Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-4.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-5.png b/osu.Game.Tests/Resources/old-skin/score-5.png new file mode 100644 index 0000000000..a562d9f2ac Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-5.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-6.png b/osu.Game.Tests/Resources/old-skin/score-6.png new file mode 100644 index 0000000000..b4cf81f26e Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-6.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-7.png b/osu.Game.Tests/Resources/old-skin/score-7.png new file mode 100644 index 0000000000..a23f5379b2 Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-7.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-8.png b/osu.Game.Tests/Resources/old-skin/score-8.png new file mode 100644 index 0000000000..430b18509d Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-8.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-9.png b/osu.Game.Tests/Resources/old-skin/score-9.png new file mode 100644 index 0000000000..add1202c31 Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-9.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-comma.png b/osu.Game.Tests/Resources/old-skin/score-comma.png new file mode 100644 index 0000000000..f68d32957f Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-comma.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-dot.png b/osu.Game.Tests/Resources/old-skin/score-dot.png new file mode 100644 index 0000000000..80c39b8745 Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-dot.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-percent.png b/osu.Game.Tests/Resources/old-skin/score-percent.png new file mode 100644 index 0000000000..fc750abc7e Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-percent.png differ diff --git a/osu.Game.Tests/Resources/old-skin/score-x.png b/osu.Game.Tests/Resources/old-skin/score-x.png new file mode 100644 index 0000000000..779773f8bd Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/score-x.png differ diff --git a/osu.Game.Tests/Resources/old-skin/scorebar-bg.png b/osu.Game.Tests/Resources/old-skin/scorebar-bg.png new file mode 100644 index 0000000000..1e94f464ca Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/scorebar-bg.png differ diff --git a/osu.Game.Tests/Resources/old-skin/scorebar-colour-0.png b/osu.Game.Tests/Resources/old-skin/scorebar-colour-0.png new file mode 100644 index 0000000000..1119ce289e Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/scorebar-colour-0.png differ diff --git a/osu.Game.Tests/Resources/old-skin/scorebar-colour-1.png b/osu.Game.Tests/Resources/old-skin/scorebar-colour-1.png new file mode 100644 index 0000000000..7669474d8b Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/scorebar-colour-1.png differ diff --git a/osu.Game.Tests/Resources/old-skin/scorebar-colour-2.png b/osu.Game.Tests/Resources/old-skin/scorebar-colour-2.png new file mode 100644 index 0000000000..70fdb4b146 Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/scorebar-colour-2.png differ diff --git a/osu.Game.Tests/Resources/old-skin/scorebar-colour-3.png b/osu.Game.Tests/Resources/old-skin/scorebar-colour-3.png new file mode 100644 index 0000000000..18ac6976c9 Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/scorebar-colour-3.png differ diff --git a/osu.Game.Tests/Resources/old-skin/scorebar-ki.png b/osu.Game.Tests/Resources/old-skin/scorebar-ki.png new file mode 100644 index 0000000000..a030c5801e Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/scorebar-ki.png differ diff --git a/osu.Game.Tests/Resources/old-skin/scorebar-kidanger.png b/osu.Game.Tests/Resources/old-skin/scorebar-kidanger.png new file mode 100644 index 0000000000..ac5a2c5893 Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/scorebar-kidanger.png differ diff --git a/osu.Game.Tests/Resources/old-skin/scorebar-kidanger2.png b/osu.Game.Tests/Resources/old-skin/scorebar-kidanger2.png new file mode 100644 index 0000000000..507be0463f Binary files /dev/null and b/osu.Game.Tests/Resources/old-skin/scorebar-kidanger2.png differ diff --git a/osu.Game.Tests/Resources/old-skin/skin.ini b/osu.Game.Tests/Resources/old-skin/skin.ini new file mode 100644 index 0000000000..5369de24e9 --- /dev/null +++ b/osu.Game.Tests/Resources/old-skin/skin.ini @@ -0,0 +1,2 @@ +[General] +Version: 1.0 \ No newline at end of file diff --git a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs index ace57aad1d..9f16312121 100644 --- a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs +++ b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs @@ -53,5 +53,263 @@ namespace osu.Game.Tests.Rulesets.Scoring Assert.IsTrue(Precision.AlmostEquals(expectedScore, scoreProcessor.TotalScore.Value)); } + + /// + /// Test to see that all s contribute to score portions in correct amounts. + /// + /// Scoring mode to test. + /// The that will be applied to selected hit objects. + /// The maximum achievable. + /// Expected score after all objects have been judged, rounded to the nearest integer. + /// + /// This test intentionally misses the 3rd hitobject to achieve lower than 75% accuracy and 50% max combo. + /// + /// For standardised scoring, is calculated using the following formula: + /// 1_000_000 * (((3 * ) / (4 * )) * 30% + (bestCombo / maxCombo) * 70%) + /// + /// + /// For classic scoring, is calculated using the following formula: + /// / * 936 + /// where 936 is simplified from: + /// 75% * 4 * 300 * (1 + 1/25) + /// + /// + [TestCase(ScoringMode.Standardised, HitResult.Miss, HitResult.Great, 0)] // (3 * 0) / (4 * 300) * 300_000 + (0 / 4) * 700_000 + [TestCase(ScoringMode.Standardised, HitResult.Meh, HitResult.Great, 387_500)] // (3 * 50) / (4 * 300) * 300_000 + (2 / 4) * 700_000 + [TestCase(ScoringMode.Standardised, HitResult.Ok, HitResult.Great, 425_000)] // (3 * 100) / (4 * 300) * 300_000 + (2 / 4) * 700_000 + [TestCase(ScoringMode.Standardised, HitResult.Good, HitResult.Perfect, 478_571)] // (3 * 200) / (4 * 350) * 300_000 + (2 / 4) * 700_000 + [TestCase(ScoringMode.Standardised, HitResult.Great, HitResult.Great, 575_000)] // (3 * 300) / (4 * 300) * 300_000 + (2 / 4) * 700_000 + [TestCase(ScoringMode.Standardised, HitResult.Perfect, HitResult.Perfect, 575_000)] // (3 * 350) / (4 * 350) * 300_000 + (2 / 4) * 700_000 + [TestCase(ScoringMode.Standardised, HitResult.SmallTickMiss, HitResult.SmallTickHit, 700_000)] // (3 * 0) / (4 * 10) * 300_000 + 700_000 (max combo 0) + [TestCase(ScoringMode.Standardised, HitResult.SmallTickHit, HitResult.SmallTickHit, 925_000)] // (3 * 10) / (4 * 10) * 300_000 + 700_000 (max combo 0) + [TestCase(ScoringMode.Standardised, HitResult.LargeTickMiss, HitResult.LargeTickHit, 0)] // (3 * 0) / (4 * 30) * 300_000 + (0 / 4) * 700_000 + [TestCase(ScoringMode.Standardised, HitResult.LargeTickHit, HitResult.LargeTickHit, 575_000)] // (3 * 30) / (4 * 30) * 300_000 + (0 / 4) * 700_000 + [TestCase(ScoringMode.Standardised, HitResult.SmallBonus, HitResult.SmallBonus, 700_030)] // 0 * 300_000 + 700_000 (max combo 0) + 3 * 10 (bonus points) + [TestCase(ScoringMode.Standardised, HitResult.LargeBonus, HitResult.LargeBonus, 700_150)] // 0 * 300_000 + 700_000 (max combo 0) + 3 * 50 (bonus points) + [TestCase(ScoringMode.Classic, HitResult.Miss, HitResult.Great, 0)] // (0 * 4 * 300) * (1 + 0 / 25) + [TestCase(ScoringMode.Classic, HitResult.Meh, HitResult.Great, 156)] // (((3 * 50) / (4 * 300)) * 4 * 300) * (1 + 1 / 25) + [TestCase(ScoringMode.Classic, HitResult.Ok, HitResult.Great, 312)] // (((3 * 100) / (4 * 300)) * 4 * 300) * (1 + 1 / 25) + [TestCase(ScoringMode.Classic, HitResult.Good, HitResult.Perfect, 535)] // (((3 * 200) / (4 * 350)) * 4 * 300) * (1 + 1 / 25) + [TestCase(ScoringMode.Classic, HitResult.Great, HitResult.Great, 936)] // (((3 * 300) / (4 * 300)) * 4 * 300) * (1 + 1 / 25) + [TestCase(ScoringMode.Classic, HitResult.Perfect, HitResult.Perfect, 936)] // (((3 * 350) / (4 * 350)) * 4 * 300) * (1 + 1 / 25) + [TestCase(ScoringMode.Classic, HitResult.SmallTickMiss, HitResult.SmallTickHit, 0)] // (0 * 1 * 300) * (1 + 0 / 25) + [TestCase(ScoringMode.Classic, HitResult.SmallTickHit, HitResult.SmallTickHit, 225)] // (((3 * 10) / (4 * 10)) * 1 * 300) * (1 + 0 / 25) + [TestCase(ScoringMode.Classic, HitResult.LargeTickMiss, HitResult.LargeTickHit, 0)] // (0 * 4 * 300) * (1 + 0 / 25) + [TestCase(ScoringMode.Classic, HitResult.LargeTickHit, HitResult.LargeTickHit, 936)] // (((3 * 50) / (4 * 50)) * 4 * 300) * (1 + 1 / 25) + [TestCase(ScoringMode.Classic, HitResult.SmallBonus, HitResult.SmallBonus, 30)] // (0 * 1 * 300) * (1 + 0 / 25) + 3 * 10 (bonus points) + [TestCase(ScoringMode.Classic, HitResult.LargeBonus, HitResult.LargeBonus, 150)] // (0 * 1 * 300) * (1 + 0 / 25) * 3 * 50 (bonus points) + public void TestFourVariousResultsOneMiss(ScoringMode scoringMode, HitResult hitResult, HitResult maxResult, int expectedScore) + { + var minResult = new TestJudgement(hitResult).MinResult; + + IBeatmap fourObjectBeatmap = new TestBeatmap(new RulesetInfo()) + { + HitObjects = new List(Enumerable.Repeat(new TestHitObject(maxResult), 4)) + }; + scoreProcessor.Mode.Value = scoringMode; + scoreProcessor.ApplyBeatmap(fourObjectBeatmap); + + for (int i = 0; i < 4; i++) + { + var judgementResult = new JudgementResult(fourObjectBeatmap.HitObjects[i], new Judgement()) + { + Type = i == 2 ? minResult : hitResult + }; + scoreProcessor.ApplyResult(judgementResult); + } + + Assert.IsTrue(Precision.AlmostEquals(expectedScore, scoreProcessor.TotalScore.Value, 0.5)); + } + + /// + /// This test uses a beatmap with four small ticks and one object with the of . + /// Its goal is to ensure that with the of , + /// small ticks contribute to the accuracy portion, but not the combo portion. + /// In contrast, does not have separate combo and accuracy portion (they are multiplied by each other). + /// + [TestCase(ScoringMode.Standardised, HitResult.SmallTickHit, 978_571)] // (3 * 10 + 100) / (4 * 10 + 100) * 300_000 + (1 / 1) * 700_000 + [TestCase(ScoringMode.Standardised, HitResult.SmallTickMiss, 914_286)] // (3 * 0 + 100) / (4 * 10 + 100) * 300_000 + (1 / 1) * 700_000 + [TestCase(ScoringMode.Classic, HitResult.SmallTickHit, 279)] // (((3 * 10 + 100) / (4 * 10 + 100)) * 1 * 300) * (1 + 0 / 25) + [TestCase(ScoringMode.Classic, HitResult.SmallTickMiss, 214)] // (((3 * 0 + 100) / (4 * 10 + 100)) * 1 * 300) * (1 + 0 / 25) + public void TestSmallTicksAccuracy(ScoringMode scoringMode, HitResult hitResult, int expectedScore) + { + IEnumerable hitObjects = Enumerable + .Repeat(new TestHitObject(HitResult.SmallTickHit), 4) + .Append(new TestHitObject(HitResult.Ok)); + IBeatmap fiveObjectBeatmap = new TestBeatmap(new RulesetInfo()) + { + HitObjects = hitObjects.ToList() + }; + scoreProcessor.Mode.Value = scoringMode; + scoreProcessor.ApplyBeatmap(fiveObjectBeatmap); + + for (int i = 0; i < 4; i++) + { + var judgementResult = new JudgementResult(fiveObjectBeatmap.HitObjects[i], new Judgement()) + { + Type = i == 2 ? HitResult.SmallTickMiss : hitResult + }; + scoreProcessor.ApplyResult(judgementResult); + } + + var lastJudgementResult = new JudgementResult(fiveObjectBeatmap.HitObjects.Last(), new Judgement()) + { + Type = HitResult.Ok + }; + scoreProcessor.ApplyResult(lastJudgementResult); + + Assert.IsTrue(Precision.AlmostEquals(expectedScore, scoreProcessor.TotalScore.Value, 0.5)); + } + + [Test] + public void TestEmptyBeatmap( + [Values(ScoringMode.Standardised, ScoringMode.Classic)] + ScoringMode scoringMode) + { + scoreProcessor.Mode.Value = scoringMode; + scoreProcessor.ApplyBeatmap(new TestBeatmap(new RulesetInfo())); + + Assert.IsTrue(Precision.AlmostEquals(0, scoreProcessor.TotalScore.Value)); + } + + [TestCase(HitResult.IgnoreHit, HitResult.IgnoreMiss)] + [TestCase(HitResult.Meh, HitResult.Miss)] + [TestCase(HitResult.Ok, HitResult.Miss)] + [TestCase(HitResult.Good, HitResult.Miss)] + [TestCase(HitResult.Great, HitResult.Miss)] + [TestCase(HitResult.Perfect, HitResult.Miss)] + [TestCase(HitResult.SmallTickHit, HitResult.SmallTickMiss)] + [TestCase(HitResult.LargeTickHit, HitResult.LargeTickMiss)] + [TestCase(HitResult.SmallBonus, HitResult.IgnoreMiss)] + [TestCase(HitResult.LargeBonus, HitResult.IgnoreMiss)] + public void TestMinResults(HitResult hitResult, HitResult expectedMinResult) + { + Assert.AreEqual(expectedMinResult, new TestJudgement(hitResult).MinResult); + } + + [TestCase(HitResult.None, false)] + [TestCase(HitResult.IgnoreMiss, false)] + [TestCase(HitResult.IgnoreHit, false)] + [TestCase(HitResult.Miss, true)] + [TestCase(HitResult.Meh, true)] + [TestCase(HitResult.Ok, true)] + [TestCase(HitResult.Good, true)] + [TestCase(HitResult.Great, true)] + [TestCase(HitResult.Perfect, true)] + [TestCase(HitResult.SmallTickMiss, false)] + [TestCase(HitResult.SmallTickHit, false)] + [TestCase(HitResult.LargeTickMiss, true)] + [TestCase(HitResult.LargeTickHit, true)] + [TestCase(HitResult.SmallBonus, false)] + [TestCase(HitResult.LargeBonus, false)] + public void TestAffectsCombo(HitResult hitResult, bool expectedReturnValue) + { + Assert.AreEqual(expectedReturnValue, hitResult.AffectsCombo()); + } + + [TestCase(HitResult.None, false)] + [TestCase(HitResult.IgnoreMiss, false)] + [TestCase(HitResult.IgnoreHit, false)] + [TestCase(HitResult.Miss, true)] + [TestCase(HitResult.Meh, true)] + [TestCase(HitResult.Ok, true)] + [TestCase(HitResult.Good, true)] + [TestCase(HitResult.Great, true)] + [TestCase(HitResult.Perfect, true)] + [TestCase(HitResult.SmallTickMiss, true)] + [TestCase(HitResult.SmallTickHit, true)] + [TestCase(HitResult.LargeTickMiss, true)] + [TestCase(HitResult.LargeTickHit, true)] + [TestCase(HitResult.SmallBonus, false)] + [TestCase(HitResult.LargeBonus, false)] + public void TestAffectsAccuracy(HitResult hitResult, bool expectedReturnValue) + { + Assert.AreEqual(expectedReturnValue, hitResult.AffectsAccuracy()); + } + + [TestCase(HitResult.None, false)] + [TestCase(HitResult.IgnoreMiss, false)] + [TestCase(HitResult.IgnoreHit, false)] + [TestCase(HitResult.Miss, false)] + [TestCase(HitResult.Meh, false)] + [TestCase(HitResult.Ok, false)] + [TestCase(HitResult.Good, false)] + [TestCase(HitResult.Great, false)] + [TestCase(HitResult.Perfect, false)] + [TestCase(HitResult.SmallTickMiss, false)] + [TestCase(HitResult.SmallTickHit, false)] + [TestCase(HitResult.LargeTickMiss, false)] + [TestCase(HitResult.LargeTickHit, false)] + [TestCase(HitResult.SmallBonus, true)] + [TestCase(HitResult.LargeBonus, true)] + public void TestIsBonus(HitResult hitResult, bool expectedReturnValue) + { + Assert.AreEqual(expectedReturnValue, hitResult.IsBonus()); + } + + [TestCase(HitResult.None, false)] + [TestCase(HitResult.IgnoreMiss, false)] + [TestCase(HitResult.IgnoreHit, true)] + [TestCase(HitResult.Miss, false)] + [TestCase(HitResult.Meh, true)] + [TestCase(HitResult.Ok, true)] + [TestCase(HitResult.Good, true)] + [TestCase(HitResult.Great, true)] + [TestCase(HitResult.Perfect, true)] + [TestCase(HitResult.SmallTickMiss, false)] + [TestCase(HitResult.SmallTickHit, true)] + [TestCase(HitResult.LargeTickMiss, false)] + [TestCase(HitResult.LargeTickHit, true)] + [TestCase(HitResult.SmallBonus, true)] + [TestCase(HitResult.LargeBonus, true)] + public void TestIsHit(HitResult hitResult, bool expectedReturnValue) + { + Assert.AreEqual(expectedReturnValue, hitResult.IsHit()); + } + + [TestCase(HitResult.None, false)] + [TestCase(HitResult.IgnoreMiss, false)] + [TestCase(HitResult.IgnoreHit, false)] + [TestCase(HitResult.Miss, true)] + [TestCase(HitResult.Meh, true)] + [TestCase(HitResult.Ok, true)] + [TestCase(HitResult.Good, true)] + [TestCase(HitResult.Great, true)] + [TestCase(HitResult.Perfect, true)] + [TestCase(HitResult.SmallTickMiss, true)] + [TestCase(HitResult.SmallTickHit, true)] + [TestCase(HitResult.LargeTickMiss, true)] + [TestCase(HitResult.LargeTickHit, true)] + [TestCase(HitResult.SmallBonus, true)] + [TestCase(HitResult.LargeBonus, true)] + public void TestIsScorable(HitResult hitResult, bool expectedReturnValue) + { + Assert.AreEqual(expectedReturnValue, hitResult.IsScorable()); + } + + private class TestJudgement : Judgement + { + public override HitResult MaxResult { get; } + + public TestJudgement(HitResult maxResult) + { + MaxResult = maxResult; + } + } + + private class TestHitObject : HitObject + { + private readonly HitResult maxResult; + + public override Judgement CreateJudgement() + { + return new TestJudgement(maxResult); + } + + public TestHitObject(HitResult maxResult) + { + this.maxResult = maxResult; + } + } } } diff --git a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs index 9ef9649f77..1c0af19322 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs @@ -192,6 +192,7 @@ namespace osu.Game.Tests.Visual.Background AddStep("Transition to Results", () => player.Push(results = new FadeAccessibleResults(new ScoreInfo { + Ruleset = new OsuRuleset().RulesetInfo, User = new User { Username = "osu!" }, Beatmap = new TestBeatmap(Ruleset.Value).BeatmapInfo, Ruleset = Ruleset.Value, diff --git a/osu.Game.Tests/Visual/Editing/TestSceneTimelineTickDisplay.cs b/osu.Game.Tests/Visual/Editing/TestSceneTimelineTickDisplay.cs index e33040acdc..20e58c3d2a 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneTimelineTickDisplay.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneTimelineTickDisplay.cs @@ -5,7 +5,6 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Screens.Edit.Compose.Components; -using osu.Game.Screens.Edit.Compose.Components.Timeline; using osuTK; namespace osu.Game.Tests.Visual.Editing @@ -13,7 +12,7 @@ namespace osu.Game.Tests.Visual.Editing [TestFixture] public class TestSceneTimelineTickDisplay : TimelineTestScene { - public override Drawable CreateTestComponent() => new TimelineTickDisplay(); + public override Drawable CreateTestComponent() => Empty(); // tick display is implicitly inside the timeline. [BackgroundDependencyLoader] private void load() diff --git a/osu.Game.Tests/Visual/Editing/TestSceneWaveform.cs b/osu.Game.Tests/Visual/Editing/TestSceneWaveform.cs index 0c1296b82c..c3a5a0e944 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneWaveform.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneWaveform.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Threading; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -76,7 +77,7 @@ namespace osu.Game.Tests.Visual.Editing }; }); - AddUntilStep("wait for load", () => graph.ResampledWaveform != null); + AddUntilStep("wait for load", () => graph.Loaded.IsSet); } [Test] @@ -98,12 +99,18 @@ namespace osu.Game.Tests.Visual.Editing }; }); - AddUntilStep("wait for load", () => graph.ResampledWaveform != null); + AddUntilStep("wait for load", () => graph.Loaded.IsSet); } public class TestWaveformGraph : WaveformGraph { - public new Waveform ResampledWaveform => base.ResampledWaveform; + public readonly ManualResetEventSlim Loaded = new ManualResetEventSlim(); + + protected override void OnWaveformRegenerated(Waveform waveform) + { + base.OnWaveformRegenerated(waveform); + Loaded.Set(); + } } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneComboCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneComboCounter.cs new file mode 100644 index 0000000000..d0c2fb5064 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneComboCounter.cs @@ -0,0 +1,47 @@ +// 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 NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Play.HUD; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneComboCounter : SkinnableTestScene + { + private IEnumerable comboCounters => CreatedDrawables.OfType(); + + protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("Create combo counters", () => SetContents(() => + { + var comboCounter = new SkinnableComboCounter(); + comboCounter.Current.Value = 1; + return comboCounter; + })); + } + + [Test] + public void TestComboCounterIncrementing() + { + AddRepeatStep("increase combo", () => + { + foreach (var counter in comboCounters) + counter.Current.Value++; + }, 10); + + AddStep("reset combo", () => + { + foreach (var counter in comboCounters) + counter.Current.Value = 0; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs index 5bb3851264..6e505b16c2 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySamplePlayback.cs @@ -26,7 +26,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("get variables", () => { - gameplayClock = Player.ChildrenOfType().First().GameplayClock; + gameplayClock = Player.ChildrenOfType().First(); slider = Player.ChildrenOfType().OrderBy(s => s.HitObject.StartTime).First(); samples = slider.ChildrenOfType().ToArray(); }); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs index c192a7b0e0..603b5d4956 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs @@ -2,23 +2,29 @@ // 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.Allocation; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Configuration; +using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; using osu.Game.Screens.Play; using osuTK.Input; namespace osu.Game.Tests.Visual.Gameplay { - public class TestSceneHUDOverlay : OsuManualInputManagerTestScene + public class TestSceneHUDOverlay : SkinnableTestScene { private HUDOverlay hudOverlay; + private IEnumerable hudOverlays => CreatedDrawables.OfType(); + // best way to check without exposing. private Drawable hideTarget => hudOverlay.KeyCounter; private FillFlowContainer keyCounterFlow => hudOverlay.KeyCounter.ChildrenOfType>().First(); @@ -26,6 +32,24 @@ namespace osu.Game.Tests.Visual.Gameplay [Resolved] private OsuConfigManager config { get; set; } + [Test] + public void TestComboCounterIncrementing() + { + createNew(); + + AddRepeatStep("increase combo", () => + { + foreach (var hud in hudOverlays) + hud.ComboCounter.Current.Value++; + }, 10); + + AddStep("reset combo", () => + { + foreach (var hud in hudOverlays) + hud.ComboCounter.Current.Value = 0; + }); + } + [Test] public void TestShownByDefault() { @@ -45,7 +69,7 @@ namespace osu.Game.Tests.Visual.Gameplay createNew(h => h.OnLoadComplete += _ => initialAlpha = hideTarget.Alpha); AddUntilStep("wait for load", () => hudOverlay.IsAlive); - AddAssert("initial alpha was less than 1", () => initialAlpha != null && initialAlpha < 1); + AddAssert("initial alpha was less than 1", () => initialAlpha < 1); } [Test] @@ -53,7 +77,7 @@ namespace osu.Game.Tests.Visual.Gameplay { createNew(); - AddStep("set showhud false", () => hudOverlay.ShowHud.Value = false); + AddStep("set showhud false", () => hudOverlays.ForEach(h => h.ShowHud.Value = false)); AddUntilStep("hidetarget is hidden", () => !hideTarget.IsPresent); AddAssert("pause button is still visible", () => hudOverlay.HoldToQuit.IsPresent); @@ -89,14 +113,14 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("set keycounter visible false", () => { config.Set(OsuSetting.KeyOverlay, false); - hudOverlay.KeyCounter.AlwaysVisible.Value = false; + hudOverlays.ForEach(h => h.KeyCounter.AlwaysVisible.Value = false); }); - AddStep("set showhud false", () => hudOverlay.ShowHud.Value = false); + AddStep("set showhud false", () => hudOverlays.ForEach(h => h.ShowHud.Value = false)); AddUntilStep("hidetarget is hidden", () => !hideTarget.IsPresent); AddAssert("key counters hidden", () => !keyCounterFlow.IsPresent); - AddStep("set showhud true", () => hudOverlay.ShowHud.Value = true); + AddStep("set showhud true", () => hudOverlays.ForEach(h => h.ShowHud.Value = true)); AddUntilStep("hidetarget is visible", () => hideTarget.IsPresent); AddAssert("key counters still hidden", () => !keyCounterFlow.IsPresent); @@ -107,13 +131,22 @@ namespace osu.Game.Tests.Visual.Gameplay { AddStep("create overlay", () => { - Child = hudOverlay = new HUDOverlay(null, null, null, Array.Empty()); + SetContents(() => + { + hudOverlay = new HUDOverlay(null, null, null, Array.Empty()); - // Add any key just to display the key counter visually. - hudOverlay.KeyCounter.Add(new KeyCounterKeyboard(Key.Space)); + // Add any key just to display the key counter visually. + hudOverlay.KeyCounter.Add(new KeyCounterKeyboard(Key.Space)); - action?.Invoke(hudOverlay); + hudOverlay.ComboCounter.Current.Value = 1; + + action?.Invoke(hudOverlay); + + return hudOverlay; + }); }); } + + protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs index 377f305d63..1021ac3760 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHitErrorMeter.cs @@ -22,8 +22,10 @@ namespace osu.Game.Tests.Visual.Gameplay { private BarHitErrorMeter barMeter; private BarHitErrorMeter barMeter2; + private BarHitErrorMeter barMeter3; private ColourHitErrorMeter colourMeter; private ColourHitErrorMeter colourMeter2; + private ColourHitErrorMeter colourMeter3; private HitWindows hitWindows; public TestSceneHitErrorMeter() @@ -115,6 +117,13 @@ namespace osu.Game.Tests.Visual.Gameplay Origin = Anchor.CentreLeft, }); + Add(barMeter3 = new BarHitErrorMeter(hitWindows, true) + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.CentreLeft, + Rotation = 270, + }); + Add(colourMeter = new ColourHitErrorMeter(hitWindows) { Anchor = Anchor.CentreRight, @@ -128,6 +137,14 @@ namespace osu.Game.Tests.Visual.Gameplay Origin = Anchor.CentreLeft, Margin = new MarginPadding { Left = 50 } }); + + Add(colourMeter3 = new ColourHitErrorMeter(hitWindows) + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.CentreLeft, + Rotation = 270, + Margin = new MarginPadding { Left = 50 } + }); } private void newJudgement(double offset = 0) @@ -140,8 +157,10 @@ namespace osu.Game.Tests.Visual.Gameplay barMeter.OnNewJudgement(judgement); barMeter2.OnNewJudgement(judgement); + barMeter3.OnNewJudgement(judgement); colourMeter.OnNewJudgement(judgement); colourMeter2.OnNewJudgement(judgement); + colourMeter3.OnNewJudgement(judgement); } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs index ce04b940e7..4fa4c00981 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneOverlayActivation.cs @@ -3,6 +3,7 @@ using System.Linq; using NUnit.Framework; +using osu.Framework.Bindables; using osu.Game.Overlays; using osu.Game.Rulesets; @@ -23,33 +24,41 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestGameplayOverlayActivation() { + AddAssert("local user playing", () => Player.LocalUserPlaying.Value); AddAssert("activation mode is disabled", () => Player.OverlayActivationMode == OverlayActivation.Disabled); } [Test] public void TestGameplayOverlayActivationPaused() { + AddAssert("local user playing", () => Player.LocalUserPlaying.Value); AddAssert("activation mode is disabled", () => Player.OverlayActivationMode == OverlayActivation.Disabled); AddStep("pause gameplay", () => Player.Pause()); + AddAssert("local user not playing", () => !Player.LocalUserPlaying.Value); AddUntilStep("activation mode is user triggered", () => Player.OverlayActivationMode == OverlayActivation.UserTriggered); } [Test] public void TestGameplayOverlayActivationReplayLoaded() { + AddAssert("local user playing", () => Player.LocalUserPlaying.Value); AddAssert("activation mode is disabled", () => Player.OverlayActivationMode == OverlayActivation.Disabled); AddStep("load a replay", () => Player.DrawableRuleset.HasReplayLoaded.Value = true); + AddAssert("local user not playing", () => !Player.LocalUserPlaying.Value); AddAssert("activation mode is user triggered", () => Player.OverlayActivationMode == OverlayActivation.UserTriggered); } [Test] public void TestGameplayOverlayActivationBreaks() { + AddAssert("local user playing", () => Player.LocalUserPlaying.Value); AddAssert("activation mode is disabled", () => Player.OverlayActivationMode == OverlayActivation.Disabled); AddStep("seek to break", () => Player.GameplayClockContainer.Seek(Beatmap.Value.Beatmap.Breaks.First().StartTime)); AddUntilStep("activation mode is user triggered", () => Player.OverlayActivationMode == OverlayActivation.UserTriggered); + AddAssert("local user not playing", () => !Player.LocalUserPlaying.Value); AddStep("seek to break end", () => Player.GameplayClockContainer.Seek(Beatmap.Value.Beatmap.Breaks.First().EndTime)); AddUntilStep("activation mode is disabled", () => Player.OverlayActivationMode == OverlayActivation.Disabled); + AddAssert("local user playing", () => Player.LocalUserPlaying.Value); } protected override TestPlayer CreatePlayer(Ruleset ruleset) => new OverlayTestPlayer(); @@ -57,6 +66,7 @@ namespace osu.Game.Tests.Visual.Gameplay protected class OverlayTestPlayer : TestPlayer { public new OverlayActivation OverlayActivationMode => base.OverlayActivationMode.Value; + public new Bindable LocalUserPlaying => base.LocalUserPlaying; } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneScoreCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneScoreCounter.cs deleted file mode 100644 index 09b4f9b761..0000000000 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneScoreCounter.cs +++ /dev/null @@ -1,68 +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 NUnit.Framework; -using osu.Framework.Graphics; -using osu.Game.Graphics.UserInterface; -using osu.Game.Screens.Play.HUD; -using osuTK; - -namespace osu.Game.Tests.Visual.Gameplay -{ - [TestFixture] - public class TestSceneScoreCounter : OsuTestScene - { - public TestSceneScoreCounter() - { - int numerator = 0, denominator = 0; - - ScoreCounter score = new ScoreCounter(7) - { - Origin = Anchor.TopRight, - Anchor = Anchor.TopRight, - Margin = new MarginPadding(20), - }; - Add(score); - - ComboCounter comboCounter = new StandardComboCounter - { - Origin = Anchor.BottomLeft, - Anchor = Anchor.BottomLeft, - Margin = new MarginPadding(10), - }; - Add(comboCounter); - - PercentageCounter accuracyCounter = new PercentageCounter - { - Origin = Anchor.TopRight, - Anchor = Anchor.TopRight, - Position = new Vector2(-20, 60), - }; - Add(accuracyCounter); - - AddStep(@"Reset all", delegate - { - score.Current.Value = 0; - comboCounter.Current.Value = 0; - numerator = denominator = 0; - accuracyCounter.SetFraction(0, 0); - }); - - AddStep(@"Hit! :D", delegate - { - score.Current.Value += 300 + (ulong)(300.0 * (comboCounter.Current.Value > 0 ? comboCounter.Current.Value - 1 : 0) / 25.0); - comboCounter.Increment(); - numerator++; - denominator++; - accuracyCounter.SetFraction(numerator, denominator); - }); - - AddStep(@"miss...", delegate - { - comboCounter.Current.Value = 0; - denominator++; - accuracyCounter.SetFraction(numerator, denominator); - }); - } - } -} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableAccuracyCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableAccuracyCounter.cs new file mode 100644 index 0000000000..709929dcb0 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableAccuracyCounter.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Play.HUD; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneSkinnableAccuracyCounter : SkinnableTestScene + { + private IEnumerable accuracyCounters => CreatedDrawables.OfType(); + + protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("Create combo counters", () => SetContents(() => + { + var accuracyCounter = new SkinnableAccuracyCounter(); + + accuracyCounter.Current.Value = 1; + + return accuracyCounter; + })); + } + + [Test] + public void TestChangingAccuracy() + { + AddStep(@"Reset all", delegate + { + foreach (var s in accuracyCounters) + s.Current.Value = 1; + }); + + AddStep(@"Hit! :D", delegate + { + foreach (var s in accuracyCounters) + s.Current.Value -= 0.023f; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs new file mode 100644 index 0000000000..e1b0820662 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHealthDisplay.cs @@ -0,0 +1,61 @@ +// 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 NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Judgements; +using osu.Game.Screens.Play; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneSkinnableHealthDisplay : SkinnableTestScene + { + private IEnumerable healthDisplays => CreatedDrawables.OfType(); + + protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("Create health displays", () => + { + SetContents(() => new SkinnableHealthDisplay()); + }); + AddStep(@"Reset all", delegate + { + foreach (var s in healthDisplays) + s.Current.Value = 1; + }); + } + + [Test] + public void TestHealthDisplayIncrementing() + { + AddRepeatStep(@"decrease hp", delegate + { + foreach (var healthDisplay in healthDisplays) + healthDisplay.Current.Value -= 0.08f; + }, 10); + + AddRepeatStep(@"increase hp without flash", delegate + { + foreach (var healthDisplay in healthDisplays) + healthDisplay.Current.Value += 0.1f; + }, 3); + + AddRepeatStep(@"increase hp with flash", delegate + { + foreach (var healthDisplay in healthDisplays) + { + healthDisplay.Current.Value += 0.1f; + healthDisplay.Flash(new JudgementResult(null, new OsuJudgement())); + } + }, 3); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs new file mode 100644 index 0000000000..e212ceeba7 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs @@ -0,0 +1,54 @@ +// 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 NUnit.Framework; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Testing; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Osu; +using osu.Game.Screens.Play.HUD; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneSkinnableScoreCounter : SkinnableTestScene + { + private IEnumerable scoreCounters => CreatedDrawables.OfType(); + + protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("Create combo counters", () => SetContents(() => + { + var comboCounter = new SkinnableScoreCounter(); + comboCounter.Current.Value = 1; + return comboCounter; + })); + } + + [Test] + public void TestScoreCounterIncrementing() + { + AddStep(@"Reset all", delegate + { + foreach (var s in scoreCounters) + s.Current.Value = 0; + }); + + AddStep(@"Hit! :D", delegate + { + foreach (var s in scoreCounters) + s.Current.Value += 300; + }); + } + + [Test] + public void TestVeryLargeScore() + { + AddStep("set large score", () => scoreCounters.ForEach(counter => counter.Current.Value = 1_000_000_000)); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs index 8f2011e5dd..864e88d023 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableSound.cs @@ -13,7 +13,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.Testing; -using osu.Framework.Timing; using osu.Game.Audio; using osu.Game.Screens.Play; using osu.Game.Skinning; @@ -22,27 +21,24 @@ namespace osu.Game.Tests.Visual.Gameplay { public class TestSceneSkinnableSound : OsuTestScene { - [Cached(typeof(ISamplePlaybackDisabler))] - private GameplayClock gameplayClock = new GameplayClock(new FramedClock()); - private TestSkinSourceContainer skinSource; private PausableSkinnableSound skinnableSound; [SetUp] - public void SetUp() => Schedule(() => + public void SetUpSteps() { - gameplayClock.IsPaused.Value = false; - - Children = new Drawable[] + AddStep("setup hierarchy", () => { - skinSource = new TestSkinSourceContainer + Children = new Drawable[] { - Clock = gameplayClock, - RelativeSizeAxes = Axes.Both, - Child = skinnableSound = new PausableSkinnableSound(new SampleInfo("normal-sliderslide")) - }, - }; - }); + skinSource = new TestSkinSourceContainer + { + RelativeSizeAxes = Axes.Both, + Child = skinnableSound = new PausableSkinnableSound(new SampleInfo("normal-sliderslide")) + }, + }; + }); + } [Test] public void TestStoppedSoundDoesntResumeAfterPause() @@ -62,8 +58,9 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for sample to stop playing", () => !sample.Playing); - AddStep("pause gameplay clock", () => gameplayClock.IsPaused.Value = true); - AddStep("resume gameplay clock", () => gameplayClock.IsPaused.Value = false); + AddStep("disable sample playback", () => skinSource.SamplePlaybackDisabled.Value = true); + + AddStep("enable sample playback", () => skinSource.SamplePlaybackDisabled.Value = false); AddWaitStep("wait a bit", 5); AddAssert("sample not playing", () => !sample.Playing); @@ -82,8 +79,11 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for sample to start playing", () => sample.Playing); - AddStep("pause gameplay clock", () => gameplayClock.IsPaused.Value = true); + AddStep("disable sample playback", () => skinSource.SamplePlaybackDisabled.Value = true); AddUntilStep("wait for sample to stop playing", () => !sample.Playing); + + AddStep("enable sample playback", () => skinSource.SamplePlaybackDisabled.Value = false); + AddUntilStep("wait for sample to start playing", () => sample.Playing); } [Test] @@ -98,10 +98,11 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("sample playing", () => sample.Playing); - AddStep("pause gameplay clock", () => gameplayClock.IsPaused.Value = true); - AddUntilStep("wait for sample to stop playing", () => !sample.Playing); + AddStep("disable sample playback", () => skinSource.SamplePlaybackDisabled.Value = true); - AddStep("resume gameplay clock", () => gameplayClock.IsPaused.Value = false); + AddUntilStep("sample not playing", () => !sample.Playing); + + AddStep("enable sample playback", () => skinSource.SamplePlaybackDisabled.Value = false); AddAssert("sample not playing", () => !sample.Playing); AddAssert("sample not playing", () => !sample.Playing); @@ -120,7 +121,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("sample playing", () => sample.Playing); - AddStep("pause gameplay clock", () => gameplayClock.IsPaused.Value = true); + AddStep("disable sample playback", () => skinSource.SamplePlaybackDisabled.Value = true); AddUntilStep("wait for sample to stop playing", () => !sample.Playing); AddStep("trigger skin change", () => skinSource.TriggerSourceChanged()); @@ -133,20 +134,25 @@ namespace osu.Game.Tests.Visual.Gameplay }); AddAssert("new sample stopped", () => !sample.Playing); - AddStep("resume gameplay clock", () => gameplayClock.IsPaused.Value = false); + AddStep("enable sample playback", () => skinSource.SamplePlaybackDisabled.Value = false); AddWaitStep("wait a bit", 5); AddAssert("new sample not played", () => !sample.Playing); } [Cached(typeof(ISkinSource))] - private class TestSkinSourceContainer : Container, ISkinSource + [Cached(typeof(ISamplePlaybackDisabler))] + private class TestSkinSourceContainer : Container, ISkinSource, ISamplePlaybackDisabler { [Resolved] private ISkinSource source { get; set; } public event Action SourceChanged; + public Bindable SamplePlaybackDisabled { get; } = new Bindable(); + + IBindable ISamplePlaybackDisabler.SamplePlaybackDisabled => SamplePlaybackDisabled; + public Drawable GetDrawableComponent(ISkinComponent component) => source?.GetDrawableComponent(component); public Texture GetTexture(string componentName, WrapMode wrapModeS, WrapMode wrapModeT) => source?.GetTexture(componentName, wrapModeS, wrapModeT); public SampleChannel GetSample(ISampleInfo sampleInfo) => source?.GetSample(sampleInfo); diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs index 144f8da2fa..4bc843096f 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneHitEventTimingDistributionGraph.cs @@ -23,6 +23,12 @@ namespace osu.Game.Tests.Visual.Ranking createTest(CreateDistributedHitEvents()); } + [Test] + public void TestAroundCentre() + { + createTest(Enumerable.Range(-150, 300).Select(i => new HitEvent(i / 50f, HitResult.Perfect, new HitCircle(), new HitCircle(), null)).ToList()); + } + [Test] public void TestZeroTimeOffset() { diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs index 3aff390a47..8669235a7a 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs @@ -394,7 +394,7 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("Sort by author", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Author }, false)); AddAssert("Check zzzzz is at bottom", () => carousel.BeatmapSets.Last().Metadata.AuthorString == "zzzzz"); AddStep("Sort by artist", () => carousel.Filter(new FilterCriteria { Sort = SortMode.Artist }, false)); - AddAssert($"Check #{set_count} is at bottom", () => carousel.BeatmapSets.Last().Metadata.Title.EndsWith($"#{set_count}!")); + AddAssert($"Check #{set_count} is at bottom", () => carousel.BeatmapSets.Last().Metadata.Title.EndsWith($"#{set_count}!", StringComparison.Ordinal)); } [Test] diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs index c5ce3751ef..645b83758c 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettings.cs @@ -9,6 +9,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Utils; +using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; @@ -18,10 +19,11 @@ using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.UI; +using osuTK.Input; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneModSettings : OsuTestScene + public class TestSceneModSettings : OsuManualInputManagerTestScene { private TestModSelectOverlay modSelect; @@ -95,6 +97,41 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("copy has original value", () => Precision.AlmostEquals(1.5, copy.SpeedChange.Value)); } + [Test] + public void TestMultiModSettingsUnboundWhenCopied() + { + MultiMod original = null; + MultiMod copy = null; + + AddStep("create mods", () => + { + original = new MultiMod(new OsuModDoubleTime()); + copy = (MultiMod)original.CreateCopy(); + }); + + AddStep("change property", () => ((OsuModDoubleTime)original.Mods[0]).SpeedChange.Value = 2); + + AddAssert("original has new value", () => Precision.AlmostEquals(2.0, ((OsuModDoubleTime)original.Mods[0]).SpeedChange.Value)); + AddAssert("copy has original value", () => Precision.AlmostEquals(1.5, ((OsuModDoubleTime)copy.Mods[0]).SpeedChange.Value)); + } + + [Test] + public void TestCustomisationMenuNoClickthrough() + { + createModSelect(); + openModSelect(); + + AddStep("change mod settings menu width to full screen", () => modSelect.SetModSettingsWidth(1.0f)); + AddStep("select cm2", () => modSelect.SelectMod(testCustomisableAutoOpenMod)); + AddAssert("Customisation opened", () => modSelect.ModSettingsContainer.Alpha == 1); + AddStep("hover over mod behind settings menu", () => InputManager.MoveMouseTo(modSelect.GetModButton(testCustomisableMod))); + AddAssert("Mod is not considered hovered over", () => !modSelect.GetModButton(testCustomisableMod).IsHovered); + AddStep("left click mod", () => InputManager.Click(MouseButton.Left)); + AddAssert("only cm2 is active", () => SelectedMods.Value.Count == 1); + AddStep("right click mod", () => InputManager.Click(MouseButton.Right)); + AddAssert("only cm2 is active", () => SelectedMods.Value.Count == 1); + } + private void createModSelect() { AddStep("create mod select", () => @@ -121,9 +158,16 @@ namespace osu.Game.Tests.Visual.UserInterface public bool ButtonsLoaded => ModSectionsContainer.Children.All(c => c.ModIconsLoaded); + public ModButton GetModButton(Mod mod) + { + return ModSectionsContainer.ChildrenOfType().Single(b => b.Mods.Any(m => m.GetType() == mod.GetType())); + } + public void SelectMod(Mod mod) => - ModSectionsContainer.Children.Single(s => s.ModType == mod.Type) - .ButtonsContainer.OfType().Single(b => b.Mods.Any(m => m.GetType() == mod.GetType())).SelectNext(1); + GetModButton(mod).SelectNext(1); + + public void SetModSettingsWidth(float newWidth) => + ModSettingsContainer.Width = newWidth; } public class TestRulesetInfo : RulesetInfo diff --git a/osu.Game.Tests/WaveformTestBeatmap.cs b/osu.Game.Tests/WaveformTestBeatmap.cs index 7dc5ce1d7f..f9613d9e25 100644 --- a/osu.Game.Tests/WaveformTestBeatmap.cs +++ b/osu.Game.Tests/WaveformTestBeatmap.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.IO; using System.Linq; using osu.Framework.Audio; @@ -59,7 +60,7 @@ namespace osu.Game.Tests get { using (var reader = getZipReader()) - return reader.Filenames.First(f => f.EndsWith(".mp3")); + return reader.Filenames.First(f => f.EndsWith(".mp3", StringComparison.Ordinal)); } } @@ -73,7 +74,7 @@ namespace osu.Game.Tests protected override Beatmap CreateBeatmap() { using (var reader = getZipReader()) - using (var beatmapStream = reader.GetStream(reader.Filenames.First(f => f.EndsWith(".osu")))) + using (var beatmapStream = reader.GetStream(reader.Filenames.First(f => f.EndsWith(".osu", StringComparison.Ordinal)))) using (var beatmapReader = new LineBufferedReader(beatmapStream)) return Decoder.GetDecoder(beatmapReader).Decode(beatmapReader); } diff --git a/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs b/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs new file mode 100644 index 0000000000..567d9f0d62 --- /dev/null +++ b/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs @@ -0,0 +1,169 @@ +// 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.IO; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework; +using osu.Framework.Allocation; +using osu.Framework.Platform; +using osu.Game.Tournament.Configuration; +using osu.Game.Tests; + +namespace osu.Game.Tournament.Tests.NonVisual +{ + [TestFixture] + public class CustomTourneyDirectoryTest + { + [Test] + public void TestDefaultDirectory() + { + using (HeadlessGameHost host = new CleanRunHeadlessGameHost()) + { + try + { + var osu = loadOsu(host); + var storage = osu.Dependencies.Get(); + + Assert.That(storage.GetFullPath("."), Is.EqualTo(Path.Combine(host.Storage.GetFullPath("."), "tournaments", "default"))); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestCustomDirectory() + { + using (HeadlessGameHost host = new HeadlessGameHost(nameof(TestCustomDirectory))) // don't use clean run as we are writing a config file. + { + string osuDesktopStorage = basePath(nameof(TestCustomDirectory)); + const string custom_tournament = "custom"; + + // need access before the game has constructed its own storage yet. + Storage storage = new DesktopStorage(osuDesktopStorage, host); + // manual cleaning so we can prepare a config file. + storage.DeleteDirectory(string.Empty); + + using (var storageConfig = new TournamentStorageManager(storage)) + storageConfig.Set(StorageConfig.CurrentTournament, custom_tournament); + + try + { + var osu = loadOsu(host); + + storage = osu.Dependencies.Get(); + + Assert.That(storage.GetFullPath("."), Is.EqualTo(Path.Combine(host.Storage.GetFullPath("."), "tournaments", custom_tournament))); + } + finally + { + host.Exit(); + } + } + } + + [Test] + public void TestMigration() + { + using (HeadlessGameHost host = new HeadlessGameHost(nameof(TestMigration))) // don't use clean run as we are writing test files for migration. + { + string osuRoot = basePath(nameof(TestMigration)); + string configFile = Path.Combine(osuRoot, "tournament.ini"); + + if (File.Exists(configFile)) + File.Delete(configFile); + + // Recreate the old setup that uses "tournament" as the base path. + string oldPath = Path.Combine(osuRoot, "tournament"); + + string videosPath = Path.Combine(oldPath, "videos"); + string modsPath = Path.Combine(oldPath, "mods"); + string flagsPath = Path.Combine(oldPath, "flags"); + + Directory.CreateDirectory(videosPath); + Directory.CreateDirectory(modsPath); + Directory.CreateDirectory(flagsPath); + + // Define testing files corresponding to the specific file migrations that are needed + string bracketFile = Path.Combine(osuRoot, "bracket.json"); + + string drawingsConfig = Path.Combine(osuRoot, "drawings.ini"); + string drawingsFile = Path.Combine(osuRoot, "drawings.txt"); + string drawingsResult = Path.Combine(osuRoot, "drawings_results.txt"); + + // Define sample files to test recursive copying + string videoFile = Path.Combine(videosPath, "video.mp4"); + string modFile = Path.Combine(modsPath, "mod.png"); + string flagFile = Path.Combine(flagsPath, "flag.png"); + + File.WriteAllText(bracketFile, "{}"); + File.WriteAllText(drawingsConfig, "test"); + File.WriteAllText(drawingsFile, "test"); + File.WriteAllText(drawingsResult, "test"); + File.WriteAllText(videoFile, "test"); + File.WriteAllText(modFile, "test"); + File.WriteAllText(flagFile, "test"); + + try + { + var osu = loadOsu(host); + + var storage = osu.Dependencies.Get(); + + string migratedPath = Path.Combine(host.Storage.GetFullPath("."), "tournaments", "default"); + + videosPath = Path.Combine(migratedPath, "videos"); + modsPath = Path.Combine(migratedPath, "mods"); + flagsPath = Path.Combine(migratedPath, "flags"); + + videoFile = Path.Combine(videosPath, "video.mp4"); + modFile = Path.Combine(modsPath, "mod.png"); + flagFile = Path.Combine(flagsPath, "flag.png"); + + Assert.That(storage.GetFullPath("."), Is.EqualTo(migratedPath)); + + Assert.True(storage.Exists("bracket.json")); + Assert.True(storage.Exists("drawings.txt")); + Assert.True(storage.Exists("drawings_results.txt")); + + Assert.True(storage.Exists("drawings.ini")); + + Assert.True(storage.Exists(videoFile)); + Assert.True(storage.Exists(modFile)); + Assert.True(storage.Exists(flagFile)); + } + finally + { + host.Storage.Delete("tournament.ini"); + host.Storage.DeleteDirectory("tournaments"); + host.Exit(); + } + } + } + + private TournamentGameBase loadOsu(GameHost host) + { + var osu = new TournamentGameBase(); + Task.Run(() => host.Run(osu)); + waitForOrAssert(() => osu.IsLoaded, @"osu! failed to start in a reasonable amount of time"); + return osu; + } + + private static void waitForOrAssert(Func result, string failureMessage, int timeout = 90000) + { + Task task = Task.Run(() => + { + while (!result()) Thread.Sleep(200); + }); + + Assert.IsTrue(task.Wait(timeout), failureMessage); + } + + private string basePath(string testInstance) => Path.Combine(RuntimeInfo.StartupDirectory, "headless", testInstance); + } +} diff --git a/osu.Game.Tournament/Components/TourneyVideo.cs b/osu.Game.Tournament/Components/TourneyVideo.cs index 317c5f6a56..2709580385 100644 --- a/osu.Game.Tournament/Components/TourneyVideo.cs +++ b/osu.Game.Tournament/Components/TourneyVideo.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Video; using osu.Framework.Timing; using osu.Game.Graphics; +using osu.Game.Tournament.IO; namespace osu.Game.Tournament.Components { @@ -17,7 +18,6 @@ namespace osu.Game.Tournament.Components private readonly string filename; private readonly bool drawFallbackGradient; private Video video; - private ManualClock manualClock; public TourneyVideo(string filename, bool drawFallbackGradient = false) @@ -27,9 +27,9 @@ namespace osu.Game.Tournament.Components } [BackgroundDependencyLoader] - private void load(TournamentStorage storage) + private void load(TournamentVideoResourceStore storage) { - var stream = storage.GetStream($@"videos/{filename}"); + var stream = storage.GetStream(filename); if (stream != null) { diff --git a/osu.Game.Tournament/Configuration/TournamentStorageManager.cs b/osu.Game.Tournament/Configuration/TournamentStorageManager.cs new file mode 100644 index 0000000000..e3d0a9e75c --- /dev/null +++ b/osu.Game.Tournament/Configuration/TournamentStorageManager.cs @@ -0,0 +1,23 @@ +// 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.Configuration; +using osu.Framework.Platform; + +namespace osu.Game.Tournament.Configuration +{ + public class TournamentStorageManager : IniConfigManager + { + protected override string Filename => "tournament.ini"; + + public TournamentStorageManager(Storage storage) + : base(storage) + { + } + } + + public enum StorageConfig + { + CurrentTournament, + } +} diff --git a/osu.Game.Tournament/IO/TournamentStorage.cs b/osu.Game.Tournament/IO/TournamentStorage.cs new file mode 100644 index 0000000000..2e8a6ce667 --- /dev/null +++ b/osu.Game.Tournament/IO/TournamentStorage.cs @@ -0,0 +1,72 @@ +// 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.Logging; +using osu.Framework.Platform; +using osu.Game.IO; +using System.IO; +using osu.Game.Tournament.Configuration; + +namespace osu.Game.Tournament.IO +{ + public class TournamentStorage : MigratableStorage + { + private const string default_tournament = "default"; + private readonly Storage storage; + private readonly TournamentStorageManager storageConfig; + + public TournamentStorage(Storage storage) + : base(storage.GetStorageForDirectory("tournaments"), string.Empty) + { + this.storage = storage; + + storageConfig = new TournamentStorageManager(storage); + + if (storage.Exists("tournament.ini")) + { + ChangeTargetStorage(UnderlyingStorage.GetStorageForDirectory(storageConfig.Get(StorageConfig.CurrentTournament))); + } + else + Migrate(UnderlyingStorage.GetStorageForDirectory(default_tournament)); + + Logger.Log("Using tournament storage: " + GetFullPath(string.Empty)); + } + + public override void Migrate(Storage newStorage) + { + // this migration only happens once on moving to the per-tournament storage system. + // listed files are those known at that point in time. + // this can be removed at some point in the future (6 months obsoletion would mean 2021-04-19) + + var source = new DirectoryInfo(storage.GetFullPath("tournament")); + var destination = new DirectoryInfo(newStorage.GetFullPath(".")); + + if (source.Exists) + { + Logger.Log("Migrating tournament assets to default tournament storage."); + CopyRecursive(source, destination); + DeleteRecursive(source); + } + + moveFileIfExists("bracket.json", destination); + moveFileIfExists("drawings.txt", destination); + moveFileIfExists("drawings_results.txt", destination); + moveFileIfExists("drawings.ini", destination); + + ChangeTargetStorage(newStorage); + storageConfig.Set(StorageConfig.CurrentTournament, default_tournament); + storageConfig.Save(); + } + + private void moveFileIfExists(string file, DirectoryInfo destination) + { + if (!storage.Exists(file)) + return; + + Logger.Log($"Migrating {file} to default tournament storage."); + var fileInfo = new System.IO.FileInfo(storage.GetFullPath(file)); + AttemptOperation(() => fileInfo.CopyTo(Path.Combine(destination.FullName, fileInfo.Name), true)); + fileInfo.Delete(); + } + } +} diff --git a/osu.Game.Tournament/TournamentStorage.cs b/osu.Game.Tournament/IO/TournamentVideoResourceStore.cs similarity index 58% rename from osu.Game.Tournament/TournamentStorage.cs rename to osu.Game.Tournament/IO/TournamentVideoResourceStore.cs index 139ad3857b..4b26840b79 100644 --- a/osu.Game.Tournament/TournamentStorage.cs +++ b/osu.Game.Tournament/IO/TournamentVideoResourceStore.cs @@ -4,12 +4,12 @@ using osu.Framework.IO.Stores; using osu.Framework.Platform; -namespace osu.Game.Tournament +namespace osu.Game.Tournament.IO { - internal class TournamentStorage : NamespacedResourceStore + public class TournamentVideoResourceStore : NamespacedResourceStore { - public TournamentStorage(Storage storage) - : base(new StorageBackedResourceStore(storage), "tournament") + public TournamentVideoResourceStore(Storage storage) + : base(new StorageBackedResourceStore(storage), "videos") { AddExtension("m4v"); AddExtension("avi"); diff --git a/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs b/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs index e10154b722..4c3adeae76 100644 --- a/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs +++ b/osu.Game.Tournament/Screens/Drawings/DrawingsScreen.cs @@ -234,7 +234,7 @@ namespace osu.Game.Tournament.Screens.Drawings if (string.IsNullOrEmpty(line)) continue; - if (line.ToUpperInvariant().StartsWith("GROUP")) + if (line.ToUpperInvariant().StartsWith("GROUP", StringComparison.Ordinal)) continue; // ReSharper disable once AccessToModifiedClosure diff --git a/osu.Game.Tournament/TournamentGameBase.cs b/osu.Game.Tournament/TournamentGameBase.cs index 5fc1d03f6d..dbda6aa023 100644 --- a/osu.Game.Tournament/TournamentGameBase.cs +++ b/osu.Game.Tournament/TournamentGameBase.cs @@ -8,11 +8,12 @@ using Newtonsoft.Json; using osu.Framework.Allocation; using osu.Framework.Graphics.Textures; using osu.Framework.Input; -using osu.Framework.IO.Stores; using osu.Framework.Platform; +using osu.Framework.IO.Stores; using osu.Game.Beatmaps; using osu.Game.Online.API.Requests; using osu.Game.Tournament.IPC; +using osu.Game.Tournament.IO; using osu.Game.Tournament.Models; using osu.Game.Users; using osuTK.Input; @@ -23,13 +24,8 @@ namespace osu.Game.Tournament public class TournamentGameBase : OsuGameBase { private const string bracket_filename = "bracket.json"; - private LadderInfo ladder; - - private Storage storage; - - private TournamentStorage tournamentStorage; - + private TournamentStorage storage; private DependencyContainer dependencies; private FileBasedIPC ipc; @@ -39,15 +35,14 @@ namespace osu.Game.Tournament } [BackgroundDependencyLoader] - private void load(Storage storage) + private void load(Storage baseStorage) { Resources.AddStore(new DllResourceStore(typeof(TournamentGameBase).Assembly)); - dependencies.CacheAs(tournamentStorage = new TournamentStorage(storage)); + dependencies.CacheAs(storage = new TournamentStorage(baseStorage)); + dependencies.Cache(new TournamentVideoResourceStore(storage)); - Textures.AddStore(new TextureLoaderStore(tournamentStorage)); - - this.storage = storage; + Textures.AddStore(new TextureLoaderStore(new StorageBackedResourceStore(storage))); readBracket(); diff --git a/osu.Game/Beatmaps/BeatmapDifficultyManager.cs b/osu.Game/Beatmaps/BeatmapDifficultyManager.cs index 3a21df8aeb..9e83738e70 100644 --- a/osu.Game/Beatmaps/BeatmapDifficultyManager.cs +++ b/osu.Game/Beatmaps/BeatmapDifficultyManager.cs @@ -13,10 +13,13 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Framework.Lists; +using osu.Framework.Logging; using osu.Framework.Threading; +using osu.Framework.Utils; using osu.Game.Rulesets; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; namespace osu.Game.Beatmaps { @@ -125,13 +128,22 @@ namespace osu.Game.Beatmaps /// The that best describes . public static DifficultyRating GetDifficultyRating(double starRating) { - if (starRating < 2.0) return DifficultyRating.Easy; - if (starRating < 2.7) return DifficultyRating.Normal; - if (starRating < 4.0) return DifficultyRating.Hard; - if (starRating < 5.3) return DifficultyRating.Insane; - if (starRating < 6.5) return DifficultyRating.Expert; + if (Precision.AlmostBigger(starRating, 6.5, 0.005)) + return DifficultyRating.ExpertPlus; - return DifficultyRating.ExpertPlus; + if (Precision.AlmostBigger(starRating, 5.3, 0.005)) + return DifficultyRating.Expert; + + if (Precision.AlmostBigger(starRating, 4.0, 0.005)) + return DifficultyRating.Insane; + + if (Precision.AlmostBigger(starRating, 2.7, 0.005)) + return DifficultyRating.Hard; + + if (Precision.AlmostBigger(starRating, 2.0, 0.005)) + return DifficultyRating.Normal; + + return DifficultyRating.Easy; } private CancellationTokenSource trackedUpdateCancellationSource; @@ -229,6 +241,24 @@ namespace osu.Game.Beatmaps return difficultyCache[key] = new StarDifficulty(attributes); } + catch (BeatmapInvalidForRulesetException e) + { + // Conversion has failed for the given ruleset, so return the difficulty in the beatmap's default ruleset. + + // Ensure the beatmap's default ruleset isn't the one already being converted to. + // This shouldn't happen as it means something went seriously wrong, but if it does an endless loop should be avoided. + if (rulesetInfo.Equals(beatmapInfo.Ruleset)) + { + Logger.Error(e, $"Failed to convert {beatmapInfo.OnlineBeatmapID} to the beatmap's default ruleset ({beatmapInfo.Ruleset})."); + return difficultyCache[key] = new StarDifficulty(); + } + + // Check the cache first because this is now a different ruleset than the one previously guarded against. + if (tryGetExisting(beatmapInfo, beatmapInfo.Ruleset, Array.Empty(), out var existingDefault, out var existingDefaultKey)) + return existingDefault; + + return computeDifficulty(existingDefaultKey, beatmapInfo, beatmapInfo.Ruleset); + } catch { return difficultyCache[key] = new StarDifficulty(); diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index acab525821..8d1f0e59bf 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -98,7 +98,7 @@ namespace osu.Game.Beatmaps [JsonIgnore] public string StoredBookmarks { - get => string.Join(",", Bookmarks); + get => string.Join(',', Bookmarks); set { if (string.IsNullOrEmpty(value)) diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 4c75069f08..33e024fa28 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -19,6 +19,7 @@ using osu.Framework.Graphics.Textures; using osu.Framework.Lists; using osu.Framework.Logging; using osu.Framework.Platform; +using osu.Framework.Testing; using osu.Game.Beatmaps.Formats; using osu.Game.Database; using osu.Game.IO; @@ -36,6 +37,7 @@ namespace osu.Game.Beatmaps /// /// Handles the storage and retrieval of Beatmaps/WorkingBeatmaps. /// + [ExcludeFromDynamicCompile] public partial class BeatmapManager : DownloadableArchiveModelManager, IDisposable { /// @@ -389,7 +391,7 @@ namespace osu.Game.Beatmaps protected override BeatmapSetInfo CreateModel(ArchiveReader reader) { // let's make sure there are actually .osu files to import. - string mapName = reader.Filenames.FirstOrDefault(f => f.EndsWith(".osu")); + string mapName = reader.Filenames.FirstOrDefault(f => f.EndsWith(".osu", StringComparison.OrdinalIgnoreCase)); if (string.IsNullOrEmpty(mapName)) { @@ -417,7 +419,7 @@ namespace osu.Game.Beatmaps { var beatmapInfos = new List(); - foreach (var file in files.Where(f => f.Filename.EndsWith(".osu"))) + foreach (var file in files.Where(f => f.Filename.EndsWith(".osu", StringComparison.OrdinalIgnoreCase))) { using (var raw = Files.Store.GetStream(file.FileInfo.StoragePath)) using (var ms = new MemoryStream()) // we need a memory stream so we can seek diff --git a/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs b/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs index 16207c7d2a..cb4884aa51 100644 --- a/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs +++ b/osu.Game/Beatmaps/BeatmapManager_BeatmapOnlineLookupQueue.cs @@ -13,6 +13,7 @@ using osu.Framework.Development; using osu.Framework.IO.Network; using osu.Framework.Logging; using osu.Framework.Platform; +using osu.Framework.Testing; using osu.Framework.Threading; using osu.Game.Online.API; using osu.Game.Online.API.Requests; @@ -23,6 +24,7 @@ namespace osu.Game.Beatmaps { public partial class BeatmapManager { + [ExcludeFromDynamicCompile] private class BeatmapOnlineLookupQueue : IDisposable { private readonly IAPIProvider api; diff --git a/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs b/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs index 362c99ea3f..f5c0d97c1f 100644 --- a/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/BeatmapManager_WorkingBeatmap.cs @@ -8,6 +8,7 @@ using osu.Framework.Audio.Track; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Framework.Logging; +using osu.Framework.Testing; using osu.Game.Beatmaps.Formats; using osu.Game.IO; using osu.Game.Skinning; @@ -17,6 +18,7 @@ namespace osu.Game.Beatmaps { public partial class BeatmapManager { + [ExcludeFromDynamicCompile] private class BeatmapManagerWorkingBeatmap : WorkingBeatmap { private readonly IResourceStore store; diff --git a/osu.Game/Beatmaps/BeatmapSetInfo.cs b/osu.Game/Beatmaps/BeatmapSetInfo.cs index b76d780860..7bc1c8c7b9 100644 --- a/osu.Game/Beatmaps/BeatmapSetInfo.cs +++ b/osu.Game/Beatmaps/BeatmapSetInfo.cs @@ -57,7 +57,7 @@ namespace osu.Game.Beatmaps public string Hash { get; set; } - public string StoryboardFile => Files?.Find(f => f.Filename.EndsWith(".osb"))?.Filename; + public string StoryboardFile => Files?.Find(f => f.Filename.EndsWith(".osb", StringComparison.OrdinalIgnoreCase))?.Filename; public List Files { get; set; } diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs index b30ec0ca2c..6dadbbd2da 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs @@ -307,12 +307,7 @@ namespace osu.Game.Beatmaps.Formats double start = getOffsetTime(Parsing.ParseDouble(split[1])); double end = Math.Max(start, getOffsetTime(Parsing.ParseDouble(split[2]))); - var breakEvent = new BreakPeriod(start, end); - - if (!breakEvent.HasEffect) - return; - - beatmap.Breaks.Add(breakEvent); + beatmap.Breaks.Add(new BreakPeriod(start, end)); break; } } diff --git a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs index c15240a4f6..7b377e481f 100644 --- a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs @@ -92,7 +92,7 @@ namespace osu.Game.Beatmaps.Formats { var pair = SplitKeyVal(line); - bool isCombo = pair.Key.StartsWith(@"Combo"); + bool isCombo = pair.Key.StartsWith(@"Combo", StringComparison.Ordinal); string[] split = pair.Value.Split(','); diff --git a/osu.Game/Beatmaps/Timing/BreakPeriod.cs b/osu.Game/Beatmaps/Timing/BreakPeriod.cs index bb8ae4a66a..4c90b16745 100644 --- a/osu.Game/Beatmaps/Timing/BreakPeriod.cs +++ b/osu.Game/Beatmaps/Timing/BreakPeriod.cs @@ -28,7 +28,7 @@ namespace osu.Game.Beatmaps.Timing public double Duration => EndTime - StartTime; /// - /// Whether the break has any effect. Breaks that are too short are culled before they are added to the beatmap. + /// Whether the break has any effect. /// public bool HasEffect => Duration >= MIN_BREAK_DURATION; diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 207a3f01d3..78179a781a 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -7,6 +7,7 @@ using osu.Framework.Configuration.Tracking; using osu.Framework.Extensions; using osu.Framework.Platform; using osu.Framework.Testing; +using osu.Game.Input; using osu.Game.Overlays; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Select; @@ -69,6 +70,7 @@ namespace osu.Game.Configuration Set(OsuSetting.MouseDisableButtons, false); Set(OsuSetting.MouseDisableWheel, false); + Set(OsuSetting.ConfineMouseMode, OsuConfineMouseMode.DuringGameplay); // Graphics Set(OsuSetting.ShowFpsDisplay, false); @@ -194,6 +196,7 @@ namespace osu.Game.Configuration FadePlayfieldWhenHealthLow, MouseDisableButtons, MouseDisableWheel, + ConfineMouseMode, AudioOffset, VolumeInactive, MenuMusic, diff --git a/osu.Game/Configuration/ScoreMeterType.cs b/osu.Game/Configuration/ScoreMeterType.cs index 156c4b1377..b9499c758e 100644 --- a/osu.Game/Configuration/ScoreMeterType.cs +++ b/osu.Game/Configuration/ScoreMeterType.cs @@ -16,7 +16,10 @@ namespace osu.Game.Configuration [Description("Hit Error (right)")] HitErrorRight, - [Description("Hit Error (both)")] + [Description("Hit Error (bottom)")] + HitErrorBottom, + + [Description("Hit Error (left+right)")] HitErrorBoth, [Description("Colour (left)")] @@ -25,7 +28,10 @@ namespace osu.Game.Configuration [Description("Colour (right)")] ColourRight, - [Description("Colour (both)")] + [Description("Colour (left+right)")] ColourBoth, + + [Description("Colour (bottom)")] + ColourBottom, } } diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs index 3292936f5f..8bdc804311 100644 --- a/osu.Game/Database/ArchiveModelManager.cs +++ b/osu.Game/Database/ArchiveModelManager.cs @@ -279,7 +279,7 @@ namespace osu.Game.Database // for now, concatenate all .osu files in the set to create a unique hash. MemoryStream hashable = new MemoryStream(); - foreach (TFileModel file in item.Files.Where(f => HashableFileTypes.Any(f.Filename.EndsWith)).OrderBy(f => f.Filename)) + foreach (TFileModel file in item.Files.Where(f => HashableFileTypes.Any(ext => f.Filename.EndsWith(ext, StringComparison.OrdinalIgnoreCase))).OrderBy(f => f.Filename)) { using (Stream s = Files.Store.GetStream(file.FileInfo.StoragePath)) s.CopyTo(hashable); @@ -593,7 +593,7 @@ namespace osu.Game.Database var fileInfos = new List(); string prefix = reader.Filenames.GetCommonPrefix(); - if (!(prefix.EndsWith("/") || prefix.EndsWith("\\"))) + if (!(prefix.EndsWith('/') || prefix.EndsWith('\\'))) prefix = string.Empty; // import files to manager diff --git a/osu.Game/Graphics/UserInterface/PercentageCounter.cs b/osu.Game/Graphics/UserInterface/PercentageCounter.cs index 1ccf7798e5..2d53ec066b 100644 --- a/osu.Game/Graphics/UserInterface/PercentageCounter.cs +++ b/osu.Game/Graphics/UserInterface/PercentageCounter.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Utils; @@ -28,9 +27,6 @@ namespace osu.Game.Graphics.UserInterface Current.Value = DisplayedCount = 1.0f; } - [BackgroundDependencyLoader] - private void load(OsuColour colours) => Colour = colours.BlueLighter; - protected override string FormatCount(double count) => count.FormatAccuracy(); protected override double GetProportionalDuration(double currentValue, double newValue) diff --git a/osu.Game/Graphics/UserInterface/RollingCounter.cs b/osu.Game/Graphics/UserInterface/RollingCounter.cs index 91a557094d..b96181416d 100644 --- a/osu.Game/Graphics/UserInterface/RollingCounter.cs +++ b/osu.Game/Graphics/UserInterface/RollingCounter.cs @@ -56,8 +56,7 @@ namespace osu.Game.Graphics.UserInterface return; displayedCount = value; - if (displayedCountSpriteText != null) - displayedCountSpriteText.Text = FormatCount(value); + UpdateDisplay(); } } @@ -73,10 +72,17 @@ namespace osu.Game.Graphics.UserInterface private void load() { displayedCountSpriteText = CreateSpriteText(); - displayedCountSpriteText.Text = FormatCount(DisplayedCount); + + UpdateDisplay(); Child = displayedCountSpriteText; } + protected void UpdateDisplay() + { + if (displayedCountSpriteText != null) + displayedCountSpriteText.Text = FormatCount(DisplayedCount); + } + protected override void LoadComplete() { base.LoadComplete(); diff --git a/osu.Game/Graphics/UserInterface/ScoreCounter.cs b/osu.Game/Graphics/UserInterface/ScoreCounter.cs index 73bbe5f03e..d75e49a4ce 100644 --- a/osu.Game/Graphics/UserInterface/ScoreCounter.cs +++ b/osu.Game/Graphics/UserInterface/ScoreCounter.cs @@ -1,35 +1,37 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Screens.Play.HUD; namespace osu.Game.Graphics.UserInterface { - public class ScoreCounter : RollingCounter + public abstract class ScoreCounter : RollingCounter, IScoreCounter { protected override double RollingDuration => 1000; protected override Easing RollingEasing => Easing.Out; - public bool UseCommaSeparator; - /// - /// How many leading zeroes the counter has. + /// Whether comma separators should be displayed. /// - public uint LeadingZeroes { get; } + public bool UseCommaSeparator { get; } + + public Bindable RequiredDisplayDigits { get; } = new Bindable(); /// /// Displays score. /// /// How many leading zeroes the counter will have. - public ScoreCounter(uint leading = 0) + /// Whether comma separators should be displayed. + protected ScoreCounter(int leading = 0, bool useCommaSeparator = false) { - LeadingZeroes = leading; - } + UseCommaSeparator = useCommaSeparator; - [BackgroundDependencyLoader] - private void load(OsuColour colours) => Colour = colours.BlueLighter; + RequiredDisplayDigits.Value = leading; + RequiredDisplayDigits.BindValueChanged(_ => UpdateDisplay()); + } protected override double GetProportionalDuration(double currentValue, double newValue) { @@ -38,7 +40,7 @@ namespace osu.Game.Graphics.UserInterface protected override string FormatCount(double count) { - string format = new string('0', (int)LeadingZeroes); + string format = new string('0', RequiredDisplayDigits.Value); if (UseCommaSeparator) { diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs index 0e995ca73d..ec68223a3d 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs @@ -73,8 +73,9 @@ namespace osu.Game.Graphics.UserInterfaceV2 }, new Container { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, + // top right works better when the vertical height of the component changes smoothly (avoids weird layout animations). + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Child = Component = CreateComponent().With(d => diff --git a/osu.Game/IO/MigratableStorage.cs b/osu.Game/IO/MigratableStorage.cs new file mode 100644 index 0000000000..1b76725b04 --- /dev/null +++ b/osu.Game/IO/MigratableStorage.cs @@ -0,0 +1,132 @@ +// 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.IO; +using System.Linq; +using System.Threading; +using osu.Framework.Platform; + +namespace osu.Game.IO +{ + /// + /// A that is migratable to different locations. + /// + public abstract class MigratableStorage : WrappedStorage + { + /// + /// A relative list of directory paths which should not be migrated. + /// + public virtual string[] IgnoreDirectories => Array.Empty(); + + /// + /// A relative list of file paths which should not be migrated. + /// + public virtual string[] IgnoreFiles => Array.Empty(); + + protected MigratableStorage(Storage storage, string subPath = null) + : base(storage, subPath) + { + } + + /// + /// A general purpose migration method to move the storage to a different location. + /// The target storage of the migration. + /// + public virtual void Migrate(Storage newStorage) + { + var source = new DirectoryInfo(GetFullPath(".")); + var destination = new DirectoryInfo(newStorage.GetFullPath(".")); + + // using Uri is the easiest way to check equality and contains (https://stackoverflow.com/a/7710620) + var sourceUri = new Uri(source.FullName + Path.DirectorySeparatorChar); + var destinationUri = new Uri(destination.FullName + Path.DirectorySeparatorChar); + + if (sourceUri == destinationUri) + throw new ArgumentException("Destination provided is already the current location", destination.FullName); + + if (sourceUri.IsBaseOf(destinationUri)) + throw new ArgumentException("Destination provided is inside the source", destination.FullName); + + // ensure the new location has no files present, else hard abort + if (destination.Exists) + { + if (destination.GetFiles().Length > 0 || destination.GetDirectories().Length > 0) + throw new ArgumentException("Destination provided already has files or directories present", destination.FullName); + } + + CopyRecursive(source, destination); + ChangeTargetStorage(newStorage); + DeleteRecursive(source); + } + + protected void DeleteRecursive(DirectoryInfo target, bool topLevelExcludes = true) + { + foreach (System.IO.FileInfo fi in target.GetFiles()) + { + if (topLevelExcludes && IgnoreFiles.Contains(fi.Name)) + continue; + + AttemptOperation(() => fi.Delete()); + } + + foreach (DirectoryInfo dir in target.GetDirectories()) + { + if (topLevelExcludes && IgnoreDirectories.Contains(dir.Name)) + continue; + + AttemptOperation(() => dir.Delete(true)); + } + + if (target.GetFiles().Length == 0 && target.GetDirectories().Length == 0) + AttemptOperation(target.Delete); + } + + protected void CopyRecursive(DirectoryInfo source, DirectoryInfo destination, bool topLevelExcludes = true) + { + // based off example code https://docs.microsoft.com/en-us/dotnet/api/system.io.directoryinfo + if (!destination.Exists) + Directory.CreateDirectory(destination.FullName); + + foreach (System.IO.FileInfo fi in source.GetFiles()) + { + if (topLevelExcludes && IgnoreFiles.Contains(fi.Name)) + continue; + + AttemptOperation(() => fi.CopyTo(Path.Combine(destination.FullName, fi.Name), true)); + } + + foreach (DirectoryInfo dir in source.GetDirectories()) + { + if (topLevelExcludes && IgnoreDirectories.Contains(dir.Name)) + continue; + + CopyRecursive(dir, destination.CreateSubdirectory(dir.Name), false); + } + } + + /// + /// Attempt an IO operation multiple times and only throw if none of the attempts succeed. + /// + /// The action to perform. + /// The number of attempts (250ms wait between each). + protected static void AttemptOperation(Action action, int attempts = 10) + { + while (true) + { + try + { + action(); + return; + } + catch (Exception) + { + if (attempts-- == 0) + throw; + } + + Thread.Sleep(250); + } + } + } +} diff --git a/osu.Game/IO/OsuStorage.cs b/osu.Game/IO/OsuStorage.cs index 1d15294666..8097f61ea4 100644 --- a/osu.Game/IO/OsuStorage.cs +++ b/osu.Game/IO/OsuStorage.cs @@ -1,11 +1,8 @@ // 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.Diagnostics; -using System.IO; using System.Linq; -using System.Threading; using JetBrains.Annotations; using osu.Framework.Logging; using osu.Framework.Platform; @@ -13,7 +10,7 @@ using osu.Game.Configuration; namespace osu.Game.IO { - public class OsuStorage : WrappedStorage + public class OsuStorage : MigratableStorage { /// /// Indicates the error (if any) that occurred when initialising the custom storage during initial startup. @@ -36,9 +33,9 @@ namespace osu.Game.IO private readonly StorageConfigManager storageConfig; private readonly Storage defaultStorage; - public static readonly string[] IGNORE_DIRECTORIES = { "cache" }; + public override string[] IgnoreDirectories => new[] { "cache" }; - public static readonly string[] IGNORE_FILES = + public override string[] IgnoreFiles => new[] { "framework.ini", "storage.ini" @@ -103,106 +100,11 @@ namespace osu.Game.IO Logger.Storage = UnderlyingStorage.GetStorageForDirectory("logs"); } - public void Migrate(string newLocation) + public override void Migrate(Storage newStorage) { - var source = new DirectoryInfo(GetFullPath(".")); - var destination = new DirectoryInfo(newLocation); - - // using Uri is the easiest way to check equality and contains (https://stackoverflow.com/a/7710620) - var sourceUri = new Uri(source.FullName + Path.DirectorySeparatorChar); - var destinationUri = new Uri(destination.FullName + Path.DirectorySeparatorChar); - - if (sourceUri == destinationUri) - throw new ArgumentException("Destination provided is already the current location", nameof(newLocation)); - - if (sourceUri.IsBaseOf(destinationUri)) - throw new ArgumentException("Destination provided is inside the source", nameof(newLocation)); - - // ensure the new location has no files present, else hard abort - if (destination.Exists) - { - if (destination.GetFiles().Length > 0 || destination.GetDirectories().Length > 0) - throw new ArgumentException("Destination provided already has files or directories present", nameof(newLocation)); - - deleteRecursive(destination); - } - - copyRecursive(source, destination); - - ChangeTargetStorage(host.GetStorage(newLocation)); - - storageConfig.Set(StorageConfig.FullPath, newLocation); + base.Migrate(newStorage); + storageConfig.Set(StorageConfig.FullPath, newStorage.GetFullPath(".")); storageConfig.Save(); - - deleteRecursive(source); - } - - private static void deleteRecursive(DirectoryInfo target, bool topLevelExcludes = true) - { - foreach (System.IO.FileInfo fi in target.GetFiles()) - { - if (topLevelExcludes && IGNORE_FILES.Contains(fi.Name)) - continue; - - attemptOperation(() => fi.Delete()); - } - - foreach (DirectoryInfo dir in target.GetDirectories()) - { - if (topLevelExcludes && IGNORE_DIRECTORIES.Contains(dir.Name)) - continue; - - attemptOperation(() => dir.Delete(true)); - } - - if (target.GetFiles().Length == 0 && target.GetDirectories().Length == 0) - attemptOperation(target.Delete); - } - - private static void copyRecursive(DirectoryInfo source, DirectoryInfo destination, bool topLevelExcludes = true) - { - // based off example code https://docs.microsoft.com/en-us/dotnet/api/system.io.directoryinfo - Directory.CreateDirectory(destination.FullName); - - foreach (System.IO.FileInfo fi in source.GetFiles()) - { - if (topLevelExcludes && IGNORE_FILES.Contains(fi.Name)) - continue; - - attemptOperation(() => fi.CopyTo(Path.Combine(destination.FullName, fi.Name), true)); - } - - foreach (DirectoryInfo dir in source.GetDirectories()) - { - if (topLevelExcludes && IGNORE_DIRECTORIES.Contains(dir.Name)) - continue; - - copyRecursive(dir, destination.CreateSubdirectory(dir.Name), false); - } - } - - /// - /// Attempt an IO operation multiple times and only throw if none of the attempts succeed. - /// - /// The action to perform. - /// The number of attempts (250ms wait between each). - private static void attemptOperation(Action action, int attempts = 10) - { - while (true) - { - try - { - action(); - return; - } - catch (Exception) - { - if (attempts-- == 0) - throw; - } - - Thread.Sleep(250); - } } } diff --git a/osu.Game/Input/ConfineMouseTracker.cs b/osu.Game/Input/ConfineMouseTracker.cs new file mode 100644 index 0000000000..3dadae6317 --- /dev/null +++ b/osu.Game/Input/ConfineMouseTracker.cs @@ -0,0 +1,61 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Configuration; +using osu.Framework.Graphics; +using osu.Framework.Input; +using osu.Game.Configuration; + +namespace osu.Game.Input +{ + /// + /// Connects with . + /// If is true, we should also confine the mouse cursor if it has been + /// requested with . + /// + public class ConfineMouseTracker : Component + { + private Bindable frameworkConfineMode; + private Bindable osuConfineMode; + private IBindable localUserPlaying; + + [BackgroundDependencyLoader] + private void load(OsuGame game, FrameworkConfigManager frameworkConfigManager, OsuConfigManager osuConfigManager) + { + frameworkConfineMode = frameworkConfigManager.GetBindable(FrameworkSetting.ConfineMouseMode); + osuConfineMode = osuConfigManager.GetBindable(OsuSetting.ConfineMouseMode); + localUserPlaying = game.LocalUserPlaying.GetBoundCopy(); + + osuConfineMode.ValueChanged += _ => updateConfineMode(); + localUserPlaying.BindValueChanged(_ => updateConfineMode(), true); + } + + private void updateConfineMode() + { + // confine mode is unavailable on some platforms + if (frameworkConfineMode.Disabled) + return; + + switch (osuConfineMode.Value) + { + case OsuConfineMouseMode.Never: + frameworkConfineMode.Value = ConfineMouseMode.Never; + break; + + case OsuConfineMouseMode.Fullscreen: + frameworkConfineMode.Value = ConfineMouseMode.Fullscreen; + break; + + case OsuConfineMouseMode.DuringGameplay: + frameworkConfineMode.Value = localUserPlaying.Value ? ConfineMouseMode.Always : ConfineMouseMode.Never; + break; + + case OsuConfineMouseMode.Always: + frameworkConfineMode.Value = ConfineMouseMode.Always; + break; + } + } + } +} diff --git a/osu.Game/Input/OsuConfineMouseMode.cs b/osu.Game/Input/OsuConfineMouseMode.cs new file mode 100644 index 0000000000..32b456395c --- /dev/null +++ b/osu.Game/Input/OsuConfineMouseMode.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.ComponentModel; +using osu.Framework.Input; + +namespace osu.Game.Input +{ + /// + /// Determines the situations in which the mouse cursor should be confined to the window. + /// Expands upon by providing the option to confine during gameplay. + /// + public enum OsuConfineMouseMode + { + /// + /// The mouse cursor will be free to move outside the game window. + /// + Never, + + /// + /// The mouse cursor will be locked to the window bounds while in fullscreen mode. + /// + Fullscreen, + + /// + /// The mouse cursor will be locked to the window bounds during gameplay, + /// but may otherwise move freely. + /// + [Description("During Gameplay")] + DuringGameplay, + + /// + /// The mouse cursor will always be locked to the window bounds while the game has focus. + /// + Always + } +} diff --git a/osu.Game/Online/Chat/ChannelManager.cs b/osu.Game/Online/Chat/ChannelManager.cs index f7ed57f207..16f46581c5 100644 --- a/osu.Game/Online/Chat/ChannelManager.cs +++ b/osu.Game/Online/Chat/ChannelManager.cs @@ -196,7 +196,7 @@ namespace osu.Game.Online.Chat if (target == null) return; - var parameters = text.Split(new[] { ' ' }, 2); + var parameters = text.Split(' ', 2); string command = parameters[0]; string content = parameters.Length == 2 ? parameters[1] : string.Empty; diff --git a/osu.Game/Online/Chat/MessageFormatter.cs b/osu.Game/Online/Chat/MessageFormatter.cs index 648e4a762b..d2a117876d 100644 --- a/osu.Game/Online/Chat/MessageFormatter.cs +++ b/osu.Game/Online/Chat/MessageFormatter.cs @@ -111,7 +111,7 @@ namespace osu.Game.Online.Chat public static LinkDetails GetLinkDetails(string url) { - var args = url.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries); + var args = url.Split('/', StringSplitOptions.RemoveEmptyEntries); args[0] = args[0].TrimEnd(':'); switch (args[0]) diff --git a/osu.Game/Online/Leaderboards/Leaderboard.cs b/osu.Game/Online/Leaderboards/Leaderboard.cs index 084ba89f6e..2acee394a6 100644 --- a/osu.Game/Online/Leaderboards/Leaderboard.cs +++ b/osu.Game/Online/Leaderboards/Leaderboard.cs @@ -10,6 +10,7 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; using osu.Framework.Threading; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Cursor; @@ -150,9 +151,9 @@ namespace osu.Game.Online.Leaderboards switch (placeholderState = value) { case PlaceholderState.NetworkFailure: - replacePlaceholder(new RetrievalFailurePlaceholder + replacePlaceholder(new ClickablePlaceholder(@"Couldn't fetch scores!", FontAwesome.Solid.Sync) { - OnRetry = UpdateScores, + Action = UpdateScores, }); break; diff --git a/osu.Game/Online/Leaderboards/RetrievalFailurePlaceholder.cs b/osu.Game/Online/Leaderboards/RetrievalFailurePlaceholder.cs deleted file mode 100644 index d109f28e72..0000000000 --- a/osu.Game/Online/Leaderboards/RetrievalFailurePlaceholder.cs +++ /dev/null @@ -1,65 +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 System; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Sprites; -using osu.Framework.Input.Events; -using osu.Game.Graphics.Containers; -using osu.Game.Online.Placeholders; -using osuTK; - -namespace osu.Game.Online.Leaderboards -{ - public class RetrievalFailurePlaceholder : Placeholder - { - public Action OnRetry; - - public RetrievalFailurePlaceholder() - { - AddArbitraryDrawable(new RetryButton - { - Action = () => OnRetry?.Invoke(), - Padding = new MarginPadding { Right = 10 } - }); - - AddText(@"Couldn't retrieve scores!"); - } - - public class RetryButton : OsuHoverContainer - { - private readonly SpriteIcon icon; - - public new Action Action; - - public RetryButton() - { - AutoSizeAxes = Axes.Both; - - Child = new OsuClickableContainer - { - AutoSizeAxes = Axes.Both, - Action = () => Action?.Invoke(), - Child = icon = new SpriteIcon - { - Icon = FontAwesome.Solid.Sync, - Size = new Vector2(TEXT_SIZE), - Shadow = true, - }, - }; - } - - protected override bool OnMouseDown(MouseDownEvent e) - { - icon.ScaleTo(0.8f, 4000, Easing.OutQuint); - return base.OnMouseDown(e); - } - - protected override void OnMouseUp(MouseUpEvent e) - { - icon.ScaleTo(1, 1000, Easing.OutElastic); - base.OnMouseUp(e); - } - } - } -} diff --git a/osu.Game/Online/Multiplayer/Room.cs b/osu.Game/Online/Multiplayer/Room.cs index 34cf158442..9a21543b2e 100644 --- a/osu.Game/Online/Multiplayer/Room.cs +++ b/osu.Game/Online/Multiplayer/Room.cs @@ -103,6 +103,20 @@ namespace osu.Game.Online.Multiplayer [JsonIgnore] public readonly Bindable Position = new Bindable(-1); + /// + /// Create a copy of this room without online information. + /// Should be used to create a local copy of a room for submitting in the future. + /// + public Room CreateCopy() + { + var copy = new Room(); + + copy.CopyFrom(this); + copy.RoomID.Value = null; + + return copy; + } + public void CopyFrom(Room other) { RoomID.Value = other.RoomID.Value; diff --git a/osu.Game/Online/Placeholders/ClickablePlaceholder.cs b/osu.Game/Online/Placeholders/ClickablePlaceholder.cs new file mode 100644 index 0000000000..936ad79c64 --- /dev/null +++ b/osu.Game/Online/Placeholders/ClickablePlaceholder.cs @@ -0,0 +1,38 @@ +// 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.Sprites; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Online.Placeholders +{ + public class ClickablePlaceholder : Placeholder + { + public Action Action; + + public ClickablePlaceholder(string actionMessage, IconUsage icon) + { + OsuTextFlowContainer textFlow; + + AddArbitraryDrawable(new OsuAnimatedButton + { + AutoSizeAxes = Framework.Graphics.Axes.Both, + Child = textFlow = new OsuTextFlowContainer(cp => cp.Font = cp.Font.With(size: TEXT_SIZE)) + { + AutoSizeAxes = Framework.Graphics.Axes.Both, + Margin = new Framework.Graphics.MarginPadding(5) + }, + Action = () => Action?.Invoke() + }); + + textFlow.AddIcon(icon, i => + { + i.Padding = new Framework.Graphics.MarginPadding { Right = 10 }; + }); + + textFlow.AddText(actionMessage); + } + } +} diff --git a/osu.Game/Online/Placeholders/LoginPlaceholder.cs b/osu.Game/Online/Placeholders/LoginPlaceholder.cs index 73b0fa27c3..f8a326a52e 100644 --- a/osu.Game/Online/Placeholders/LoginPlaceholder.cs +++ b/osu.Game/Online/Placeholders/LoginPlaceholder.cs @@ -2,45 +2,20 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; -using osu.Framework.Input.Events; using osu.Game.Overlays; namespace osu.Game.Online.Placeholders { - public sealed class LoginPlaceholder : Placeholder + public sealed class LoginPlaceholder : ClickablePlaceholder { [Resolved(CanBeNull = true)] private LoginOverlay login { get; set; } public LoginPlaceholder(string actionMessage) + : base(actionMessage, FontAwesome.Solid.UserLock) { - AddIcon(FontAwesome.Solid.UserLock, cp => - { - cp.Font = cp.Font.With(size: TEXT_SIZE); - cp.Padding = new MarginPadding { Right = 10 }; - }); - - AddText(actionMessage); - } - - protected override bool OnMouseDown(MouseDownEvent e) - { - this.ScaleTo(0.8f, 4000, Easing.OutQuint); - return base.OnMouseDown(e); - } - - protected override void OnMouseUp(MouseUpEvent e) - { - this.ScaleTo(1, 1000, Easing.OutElastic); - base.OnMouseUp(e); - } - - protected override bool OnClick(ClickEvent e) - { - login?.Show(); - return base.OnClick(e); + Action = () => login?.Show(); } } } diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 4a699dc82e..a0ddab702e 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -95,6 +95,15 @@ namespace osu.Game /// public readonly IBindable OverlayActivationMode = new Bindable(); + /// + /// Whether the local user is currently interacting with the game in a way that should not be interrupted. + /// + /// + /// This is exclusively managed by . If other components are mutating this state, a more + /// resilient method should be used to ensure correct state. + /// + public Bindable LocalUserPlaying = new BindableBool(); + protected OsuScreenStack ScreenStack; protected BackButton BackButton; @@ -172,7 +181,7 @@ namespace osu.Game if (args?.Length > 0) { - var paths = args.Where(a => !a.StartsWith(@"-")).ToArray(); + var paths = args.Where(a => !a.StartsWith('-')).ToArray(); if (paths.Length > 0) Task.Run(() => Import(paths)); } @@ -280,7 +289,7 @@ namespace osu.Game public void OpenUrlExternally(string url) => waitForReady(() => externalLinkOpener, _ => { - if (url.StartsWith("/")) + if (url.StartsWith('/')) url = $"{API.Endpoint}{url}"; externalLinkOpener.OpenUrlExternally(url); @@ -577,7 +586,8 @@ namespace osu.Game rightFloatingOverlayContent = new Container { RelativeSizeAxes = Axes.Both }, leftFloatingOverlayContent = new Container { RelativeSizeAxes = Axes.Both }, topMostOverlayContent = new Container { RelativeSizeAxes = Axes.Both }, - idleTracker + idleTracker, + new ConfineMouseTracker() }); ScreenStack.ScreenPushed += screenPushed; @@ -947,6 +957,9 @@ namespace osu.Game break; } + // reset on screen change for sanity. + LocalUserPlaying.Value = false; + if (current is IOsuScreen currentOsuScreen) OverlayActivationMode.UnbindFrom(currentOsuScreen.OverlayActivationMode); diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 70d33e7cf7..fee9d320df 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -376,8 +376,10 @@ namespace osu.Game protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); + RulesetStore?.Dispose(); BeatmapManager?.Dispose(); + LocalConfig?.Dispose(); contextFactory.FlushConnections(); } @@ -411,7 +413,7 @@ namespace osu.Game public void Migrate(string path) { contextFactory.FlushConnections(); - (Storage as OsuStorage)?.Migrate(path); + (Storage as OsuStorage)?.Migrate(Host.GetStorage(path)); } } } diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs index 968355c377..324299ccba 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs @@ -60,7 +60,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores /// /// The statistics that appear in the table, in order of appearance. /// - private readonly List statisticResultTypes = new List(); + private readonly List<(HitResult result, string displayName)> statisticResultTypes = new List<(HitResult, string)>(); private bool showPerformancePoints; @@ -101,15 +101,24 @@ namespace osu.Game.Overlays.BeatmapSet.Scores }; // All statistics across all scores, unordered. - var allScoreStatistics = scores.SelectMany(s => s.GetStatisticsForDisplay().Select(stat => stat.result)).ToHashSet(); + var allScoreStatistics = scores.SelectMany(s => s.GetStatisticsForDisplay().Select(stat => stat.Result)).ToHashSet(); + + var ruleset = scores.First().Ruleset.CreateInstance(); foreach (var result in OrderAttributeUtils.GetValuesInOrder()) { if (!allScoreStatistics.Contains(result)) continue; - columns.Add(new TableColumn(result.GetDescription(), Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 35, maxSize: 60))); - statisticResultTypes.Add(result); + // for the time being ignore bonus result types. + // this is not being sent from the API and will be empty in all cases. + if (result.IsBonus()) + continue; + + string displayName = ruleset.GetDisplayNameForHitResult(result); + + columns.Add(new TableColumn(displayName, Anchor.CentreLeft, new Dimension(GridSizeMode.Distributed, minSize: 35, maxSize: 60))); + statisticResultTypes.Add((result, displayName)); } if (showPerformancePoints) @@ -163,18 +172,18 @@ namespace osu.Game.Overlays.BeatmapSet.Scores } }; - var availableStatistics = score.GetStatisticsForDisplay().ToDictionary(tuple => tuple.result); + var availableStatistics = score.GetStatisticsForDisplay().ToDictionary(tuple => tuple.Result); foreach (var result in statisticResultTypes) { - if (!availableStatistics.TryGetValue(result, out var stat)) - stat = (result, 0, null); + if (!availableStatistics.TryGetValue(result.result, out var stat)) + stat = new HitResultDisplayStatistic(result.result, 0, null, result.displayName); content.Add(new OsuSpriteText { - Text = stat.maxCount == null ? $"{stat.count}" : $"{stat.count}/{stat.maxCount}", + Text = stat.MaxCount == null ? $"{stat.Count}" : $"{stat.Count}/{stat.MaxCount}", Font = OsuFont.GetFont(size: text_size), - Colour = stat.count == 0 ? Color4.Gray : Color4.White + Colour = stat.Count == 0 ? Color4.Gray : Color4.White }); } diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs index 05789e1fc0..93744dd6a3 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs @@ -5,7 +5,6 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -15,7 +14,6 @@ using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Scoring; using osuTK; @@ -117,7 +115,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores ppColumn.Alpha = value.Beatmap?.Status == BeatmapSetOnlineStatus.Ranked ? 1 : 0; ppColumn.Text = $@"{value.PP:N0}"; - statisticsColumns.ChildrenEnumerable = value.GetStatisticsForDisplay().Select(s => createStatisticsColumn(s.result, s.count, s.maxCount)); + statisticsColumns.ChildrenEnumerable = value.GetStatisticsForDisplay().Select(createStatisticsColumn); modsColumn.Mods = value.Mods; if (scoreManager != null) @@ -125,9 +123,9 @@ namespace osu.Game.Overlays.BeatmapSet.Scores } } - private TextColumn createStatisticsColumn(HitResult hitResult, int count, int? maxCount) => new TextColumn(hitResult.GetDescription(), smallFont, bottom_columns_min_width) + private TextColumn createStatisticsColumn(HitResultDisplayStatistic stat) => new TextColumn(stat.DisplayName, smallFont, bottom_columns_min_width) { - Text = maxCount == null ? $"{count}" : $"{count}/{maxCount}" + Text = stat.MaxCount == null ? $"{stat.Count}" : $"{stat.Count}/{stat.MaxCount}" }; private class InfoColumn : CompositeDrawable diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index 4eb4fc6501..31adf47456 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -13,7 +13,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; -using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; using osu.Game.Graphics.Containers; @@ -45,9 +44,7 @@ namespace osu.Game.Overlays.Mods protected readonly FillFlowContainer ModSectionsContainer; - protected readonly FillFlowContainer ModSettingsContent; - - protected readonly Container ModSettingsContainer; + protected readonly ModSettingsContainer ModSettingsContainer; public readonly Bindable> SelectedMods = new Bindable>(Array.Empty()); @@ -284,7 +281,7 @@ namespace osu.Game.Overlays.Mods }, }, }, - ModSettingsContainer = new Container + ModSettingsContainer = new ModSettingsContainer { RelativeSizeAxes = Axes.Both, Anchor = Anchor.BottomRight, @@ -292,29 +289,11 @@ namespace osu.Game.Overlays.Mods Width = 0.25f, Alpha = 0, X = -100, - Children = new Drawable[] - { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = new Color4(0, 0, 0, 192) - }, - new OsuScrollContainer - { - RelativeSizeAxes = Axes.Both, - Child = ModSettingsContent = new FillFlowContainer - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Spacing = new Vector2(0f, 10f), - Padding = new MarginPadding(20), - } - } - } + SelectedMods = { BindTarget = SelectedMods }, } }; + + ((IBindable)CustomiseButton.Enabled).BindTo(ModSettingsContainer.HasSettingsForSelection); } [BackgroundDependencyLoader(true)] @@ -423,8 +402,6 @@ namespace osu.Game.Overlays.Mods section.SelectTypes(mods.NewValue.Select(m => m.GetType()).ToList()); updateMods(); - - updateModSettings(mods); } private void updateMods() @@ -445,25 +422,6 @@ namespace osu.Game.Overlays.Mods MultiplierLabel.FadeColour(Color4.White, 200); } - private void updateModSettings(ValueChangedEvent> selectedMods) - { - ModSettingsContent.Clear(); - - foreach (var mod in selectedMods.NewValue) - { - var settings = mod.CreateSettingsControls().ToList(); - if (settings.Count > 0) - ModSettingsContent.Add(new ModControlSection(mod, settings)); - } - - bool hasSettings = ModSettingsContent.Count > 0; - - CustomiseButton.Enabled.Value = hasSettings; - - if (!hasSettings) - ModSettingsContainer.Hide(); - } - private void modButtonPressed(Mod selectedMod) { if (selectedMod != null) diff --git a/osu.Game/Overlays/Mods/ModSettingsContainer.cs b/osu.Game/Overlays/Mods/ModSettingsContainer.cs new file mode 100644 index 0000000000..b185b56ecd --- /dev/null +++ b/osu.Game/Overlays/Mods/ModSettingsContainer.cs @@ -0,0 +1,84 @@ +// 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 osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Game.Configuration; +using osu.Game.Graphics.Containers; +using osu.Game.Rulesets.Mods; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Mods +{ + public class ModSettingsContainer : Container + { + public readonly IBindable> SelectedMods = new Bindable>(Array.Empty()); + + public IBindable HasSettingsForSelection => hasSettingsForSelection; + + private readonly Bindable hasSettingsForSelection = new Bindable(); + + private readonly FillFlowContainer modSettingsContent; + + public ModSettingsContainer() + { + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = new Color4(0, 0, 0, 192) + }, + new OsuScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = modSettingsContent = new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0f, 10f), + Padding = new MarginPadding(20), + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + SelectedMods.BindValueChanged(modsChanged, true); + } + + private void modsChanged(ValueChangedEvent> mods) + { + modSettingsContent.Clear(); + + foreach (var mod in mods.NewValue) + { + var settings = mod.CreateSettingsControls().ToList(); + if (settings.Count > 0) + modSettingsContent.Add(new ModControlSection(mod, settings)); + } + + bool hasSettings = modSettingsContent.Count > 0; + + if (!hasSettings) + Hide(); + + hasSettingsForSelection.Value = hasSettings; + } + + protected override bool OnMouseDown(MouseDownEvent e) => true; + protected override bool OnHover(HoverEvent e) => true; + } +} diff --git a/osu.Game/Overlays/MusicController.cs b/osu.Game/Overlays/MusicController.cs index 0764f34697..12caf98021 100644 --- a/osu.Game/Overlays/MusicController.cs +++ b/osu.Game/Overlays/MusicController.cs @@ -150,7 +150,7 @@ namespace osu.Game.Overlays { if (IsUserPaused) return; - if (CurrentTrack.IsDummyDevice) + if (CurrentTrack.IsDummyDevice || beatmap.Value.BeatmapSetInfo.DeletePending) { if (beatmap.Disabled) return; diff --git a/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs b/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs index c27b5f4b4a..ebee377a51 100644 --- a/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs +++ b/osu.Game/Overlays/Profile/Header/BottomHeaderContainer.cs @@ -135,7 +135,6 @@ namespace osu.Game.Overlays.Profile.Header anyInfoAdded |= tryAddInfo(FontAwesome.Brands.Twitter, "@" + user.Twitter, $@"https://twitter.com/{user.Twitter}"); anyInfoAdded |= tryAddInfo(FontAwesome.Brands.Discord, user.Discord); anyInfoAdded |= tryAddInfo(FontAwesome.Brands.Skype, user.Skype, @"skype:" + user.Skype + @"?chat"); - anyInfoAdded |= tryAddInfo(FontAwesome.Brands.Lastfm, user.Lastfm, $@"https://last.fm/users/{user.Lastfm}"); anyInfoAdded |= tryAddInfo(FontAwesome.Solid.Link, websiteWithoutProtocol, user.Website); // If no information was added to the bottomLinkContainer, hide it to avoid unwanted padding @@ -149,7 +148,7 @@ namespace osu.Game.Overlays.Profile.Header if (string.IsNullOrEmpty(content)) return false; // newlines could be contained in API returned user content. - content = content.Replace("\n", " "); + content = content.Replace('\n', ' '); bottomLinkContainer.AddIcon(icon, text => { diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs index 73968761e2..66b3b8c4ca 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs @@ -76,7 +76,8 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay new SettingsEnumDropdown { LabelText = "Score display mode", - Current = config.GetBindable(OsuSetting.ScoreDisplayMode) + Current = config.GetBindable(OsuSetting.ScoreDisplayMode), + Keywords = new[] { "scoring" } } }; diff --git a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs index a59a6b00b9..c213313559 100644 --- a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs @@ -4,9 +4,11 @@ using System.Threading.Tasks; using osu.Framework; using osu.Framework.Allocation; +using osu.Framework.Graphics.Sprites; using osu.Framework.Platform; using osu.Framework.Screens; using osu.Game.Configuration; +using osu.Game.Overlays.Notifications; using osu.Game.Overlays.Settings.Sections.Maintenance; using osu.Game.Updater; @@ -21,6 +23,9 @@ namespace osu.Game.Overlays.Settings.Sections.General private SettingsButton checkForUpdatesButton; + [Resolved(CanBeNull = true)] + private NotificationOverlay notifications { get; set; } + [BackgroundDependencyLoader(true)] private void load(Storage storage, OsuConfigManager config, OsuGame game) { @@ -38,7 +43,19 @@ namespace osu.Game.Overlays.Settings.Sections.General Action = () => { checkForUpdatesButton.Enabled.Value = false; - Task.Run(updateManager.CheckForUpdateAsync).ContinueWith(t => Schedule(() => checkForUpdatesButton.Enabled.Value = true)); + Task.Run(updateManager.CheckForUpdateAsync).ContinueWith(t => Schedule(() => + { + if (!t.Result) + { + notifications?.Post(new SimpleNotification + { + Text = $"You are running the latest release ({game.Version})", + Icon = FontAwesome.Solid.CheckCircle, + }); + } + + checkForUpdatesButton.Enabled.Value = true; + })); } }); } diff --git a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs index 5227e328ec..f0d51a0d37 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs @@ -6,9 +6,9 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Configuration; using osu.Framework.Graphics; -using osu.Framework.Input; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; +using osu.Game.Input; namespace osu.Game.Overlays.Settings.Sections.Input { @@ -47,10 +47,10 @@ namespace osu.Game.Overlays.Settings.Sections.Input LabelText = "Map absolute input to window", Current = config.GetBindable(FrameworkSetting.MapAbsoluteInputToWindow) }, - new SettingsEnumDropdown + new SettingsEnumDropdown { LabelText = "Confine mouse cursor to window", - Current = config.GetBindable(FrameworkSetting.ConfineMouseMode), + Current = osuConfig.GetBindable(OsuSetting.ConfineMouseMode) }, new SettingsCheckbox { diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index 1902de5bda..f15e5e1df0 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -69,7 +69,7 @@ namespace osu.Game.Rulesets.Difficulty if (!beatmap.HitObjects.Any()) return CreateDifficultyAttributes(beatmap, mods, skills, clockRate); - var difficultyHitObjects = CreateDifficultyHitObjects(beatmap, clockRate).OrderBy(h => h.BaseObject.StartTime).ToList(); + var difficultyHitObjects = SortObjects(CreateDifficultyHitObjects(beatmap, clockRate)).ToList(); double sectionLength = SectionLength * clockRate; @@ -100,15 +100,24 @@ namespace osu.Game.Rulesets.Difficulty return CreateDifficultyAttributes(beatmap, mods, skills, clockRate); } + /// + /// Sorts a given set of s. + /// + /// The s to sort. + /// The sorted s. + protected virtual IEnumerable SortObjects(IEnumerable input) + => input.OrderBy(h => h.BaseObject.StartTime); + /// /// Creates all combinations which adjust the difficulty. /// public Mod[] CreateDifficultyAdjustmentModCombinations() { - return createDifficultyAdjustmentModCombinations(Array.Empty(), DifficultyAdjustmentMods).ToArray(); + return createDifficultyAdjustmentModCombinations(DifficultyAdjustmentMods, Array.Empty()).ToArray(); - IEnumerable createDifficultyAdjustmentModCombinations(IEnumerable currentSet, Mod[] adjustmentSet, int currentSetCount = 0, int adjustmentSetStart = 0) + static IEnumerable createDifficultyAdjustmentModCombinations(ReadOnlyMemory remainingMods, IEnumerable currentSet, int currentSetCount = 0) { + // Return the current set. switch (currentSetCount) { case 0: @@ -128,18 +137,43 @@ namespace osu.Game.Rulesets.Difficulty break; } - // Apply mods in the adjustment set recursively. Using the entire adjustment set would result in duplicate multi-mod mod - // combinations in further recursions, so a moving subset is used to eliminate this effect - for (int i = adjustmentSetStart; i < adjustmentSet.Length; i++) + // Apply the rest of the remaining mods recursively. + for (int i = 0; i < remainingMods.Length; i++) { - var adjustmentMod = adjustmentSet[i]; - if (currentSet.Any(c => c.IncompatibleMods.Any(m => m.IsInstanceOfType(adjustmentMod)))) + var (nextSet, nextCount) = flatten(remainingMods.Span[i]); + + // Check if any mods in the next set are incompatible with any of the current set. + if (currentSet.SelectMany(m => m.IncompatibleMods).Any(c => nextSet.Any(c.IsInstanceOfType))) continue; - foreach (var combo in createDifficultyAdjustmentModCombinations(currentSet.Append(adjustmentMod), adjustmentSet, currentSetCount + 1, i + 1)) + // Check if any mods in the next set are the same type as the current set. Mods of the exact same type are not incompatible with themselves. + if (currentSet.Any(c => nextSet.Any(n => c.GetType() == n.GetType()))) + continue; + + // If all's good, attach the next set to the current set and recurse further. + foreach (var combo in createDifficultyAdjustmentModCombinations(remainingMods.Slice(i + 1), currentSet.Concat(nextSet), currentSetCount + nextCount)) yield return combo; } } + + // Flattens a mod hierarchy (through MultiMod) as an IEnumerable + static (IEnumerable set, int count) flatten(Mod mod) + { + if (!(mod is MultiMod multi)) + return (mod.Yield(), 1); + + IEnumerable set = Enumerable.Empty(); + int count = 0; + + foreach (var nested in multi.Mods) + { + var (nestedSet, nestedCount) = flatten(nested); + set = set.Concat(nestedSet); + count += nestedCount; + } + + return (set, count); + } } /// diff --git a/osu.Game/Rulesets/Difficulty/Skills/Skill.cs b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs index 227f2f4018..1063a24b27 100644 --- a/osu.Game/Rulesets/Difficulty/Skills/Skill.cs +++ b/osu.Game/Rulesets/Difficulty/Skills/Skill.cs @@ -41,7 +41,11 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// protected readonly LimitedCapacityStack Previous = new LimitedCapacityStack(2); // Contained objects not used yet - private double currentStrain = 1; // We keep track of the strain level at all times throughout the beatmap. + /// + /// The current strain level. + /// + protected double CurrentStrain { get; private set; } = 1; + private double currentSectionPeak = 1; // We also keep track of the peak strain level in the current section. private readonly List strainPeaks = new List(); @@ -51,10 +55,10 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// public void Process(DifficultyHitObject current) { - currentStrain *= strainDecay(current.DeltaTime); - currentStrain += StrainValueOf(current) * SkillMultiplier; + CurrentStrain *= strainDecay(current.DeltaTime); + CurrentStrain += StrainValueOf(current) * SkillMultiplier; - currentSectionPeak = Math.Max(currentStrain, currentSectionPeak); + currentSectionPeak = Math.Max(CurrentStrain, currentSectionPeak); Previous.Push(current); } @@ -71,15 +75,22 @@ namespace osu.Game.Rulesets.Difficulty.Skills /// /// Sets the initial strain level for a new section. /// - /// The beginning of the new section in milliseconds. - public void StartNewSectionFrom(double offset) + /// The beginning of the new section in milliseconds. + public void StartNewSectionFrom(double time) { // The maximum strain of the new section is not zero by default, strain decays as usual regardless of section boundaries. // This means we need to capture the strain level at the beginning of the new section, and use that as the initial peak level. if (Previous.Count > 0) - currentSectionPeak = currentStrain * strainDecay(offset - Previous[0].BaseObject.StartTime); + currentSectionPeak = GetPeakStrain(time); } + /// + /// Retrieves the peak strain at a point in time. + /// + /// The time to retrieve the peak strain at. + /// The peak strain. + protected virtual double GetPeakStrain(double time) => CurrentStrain * strainDecay(time - Previous[0].BaseObject.StartTime); + /// /// Returns the calculated difficulty value representing all processed s. /// diff --git a/osu.Game/Rulesets/Edit/SelectionBlueprint.cs b/osu.Game/Rulesets/Edit/SelectionBlueprint.cs index 71256093d5..4abdbfc244 100644 --- a/osu.Game/Rulesets/Edit/SelectionBlueprint.cs +++ b/osu.Game/Rulesets/Edit/SelectionBlueprint.cs @@ -89,9 +89,23 @@ namespace osu.Game.Rulesets.Edit } } - protected virtual void OnDeselected() => Hide(); + protected virtual void OnDeselected() + { + // selection blueprints are AlwaysPresent while the related DrawableHitObject is visible + // set the body piece's alpha directly to avoid arbitrarily rendering frame buffers etc. of children. + foreach (var d in InternalChildren) + d.Hide(); - protected virtual void OnSelected() => Show(); + Hide(); + } + + protected virtual void OnSelected() + { + foreach (var d in InternalChildren) + d.Show(); + + Show(); + } // When not selected, input is only required for the blueprint itself to receive IsHovering protected override bool ShouldBeConsideredForInput(Drawable child) => State == SelectionState.Selected; diff --git a/osu.Game/Rulesets/Mods/ModFlashlight.cs b/osu.Game/Rulesets/Mods/ModFlashlight.cs index 6e94a84e7d..08f2ccb75c 100644 --- a/osu.Game/Rulesets/Mods/ModFlashlight.cs +++ b/osu.Game/Rulesets/Mods/ModFlashlight.cs @@ -107,6 +107,9 @@ namespace osu.Game.Rulesets.Mods { foreach (var breakPeriod in Breaks) { + if (!breakPeriod.HasEffect) + continue; + if (breakPeriod.Duration < FLASHLIGHT_FADE_DURATION * 2) continue; this.Delay(breakPeriod.StartTime + FLASHLIGHT_FADE_DURATION).FadeOutFromOne(FLASHLIGHT_FADE_DURATION); diff --git a/osu.Game/Rulesets/Mods/ModHidden.cs b/osu.Game/Rulesets/Mods/ModHidden.cs index a1915b974c..ad01bf036c 100644 --- a/osu.Game/Rulesets/Mods/ModHidden.cs +++ b/osu.Game/Rulesets/Mods/ModHidden.cs @@ -38,7 +38,15 @@ namespace osu.Game.Rulesets.Mods public virtual void ApplyToDrawableHitObjects(IEnumerable drawables) { if (IncreaseFirstObjectVisibility.Value) - drawables = drawables.SkipWhile(h => !IsFirstHideableObject(h)).Skip(1); + { + drawables = drawables.SkipWhile(h => !IsFirstHideableObject(h)); + + var firstObject = drawables.FirstOrDefault(); + if (firstObject != null) + firstObject.ApplyCustomUpdateState += ApplyFirstObjectIncreaseVisibilityState; + + drawables = drawables.Skip(1); + } foreach (var dho in drawables) dho.ApplyCustomUpdateState += ApplyHiddenState; @@ -65,6 +73,20 @@ namespace osu.Game.Rulesets.Mods } } + /// + /// Apply a special visibility state to the first object in a beatmap, if the user chooses to turn on the "increase first object visibility" setting. + /// + /// The hit object to apply the state change to. + /// The state of the hit object. + protected virtual void ApplyFirstObjectIncreaseVisibilityState(DrawableHitObject hitObject, ArmedState state) + { + } + + /// + /// Apply a hidden state to the provided object. + /// + /// The hit object to apply the state change to. + /// The state of the hit object. protected virtual void ApplyHiddenState(DrawableHitObject hitObject, ArmedState state) { } diff --git a/osu.Game/Rulesets/Mods/MultiMod.cs b/osu.Game/Rulesets/Mods/MultiMod.cs index f7d574d3c7..2107009dbb 100644 --- a/osu.Game/Rulesets/Mods/MultiMod.cs +++ b/osu.Game/Rulesets/Mods/MultiMod.cs @@ -6,7 +6,7 @@ using System.Linq; namespace osu.Game.Rulesets.Mods { - public class MultiMod : Mod + public sealed class MultiMod : Mod { public override string Name => string.Empty; public override string Acronym => string.Empty; @@ -20,6 +20,8 @@ namespace osu.Game.Rulesets.Mods Mods = mods; } + public override Mod CreateCopy() => new MultiMod(Mods.Select(m => m.CreateCopy()).ToArray()); + public override Type[] IncompatibleMods => Mods.SelectMany(m => m.IncompatibleMods).ToArray(); } } diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index 9afc0ecaf4..44b22033dc 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs @@ -70,53 +70,8 @@ namespace osu.Game.Rulesets.Objects.Legacy } else if (type.HasFlag(LegacyHitObjectType.Slider)) { - PathType pathType = PathType.Catmull; double? length = null; - string[] pointSplit = split[5].Split('|'); - - int pointCount = 1; - - foreach (var t in pointSplit) - { - if (t.Length > 1) - pointCount++; - } - - var points = new Vector2[pointCount]; - - int pointIndex = 1; - - foreach (string t in pointSplit) - { - if (t.Length == 1) - { - switch (t) - { - case @"C": - pathType = PathType.Catmull; - break; - - case @"B": - pathType = PathType.Bezier; - break; - - case @"L": - pathType = PathType.Linear; - break; - - case @"P": - pathType = PathType.PerfectCurve; - break; - } - - continue; - } - - string[] temp = t.Split(':'); - points[pointIndex++] = new Vector2((int)Parsing.ParseDouble(temp[0], Parsing.MAX_COORDINATE_VALUE), (int)Parsing.ParseDouble(temp[1], Parsing.MAX_COORDINATE_VALUE)) - pos; - } - int repeatCount = Parsing.ParseInt(split[6]); if (repeatCount > 9000) @@ -183,10 +138,7 @@ namespace osu.Game.Rulesets.Objects.Legacy for (int i = 0; i < nodes; i++) nodeSamples.Add(convertSoundType(nodeSoundTypes[i], nodeBankInfos[i])); - result = CreateSlider(pos, combo, comboOffset, convertControlPoints(points, pathType), length, repeatCount, nodeSamples); - - // The samples are played when the slider ends, which is the last node - result.Samples = nodeSamples[^1]; + result = CreateSlider(pos, combo, comboOffset, convertPathString(split[5], pos), length, repeatCount, nodeSamples); } else if (type.HasFlag(LegacyHitObjectType.Spinner)) { @@ -207,7 +159,7 @@ namespace osu.Game.Rulesets.Objects.Legacy { string[] ss = split[5].Split(':'); endTime = Math.Max(startTime, Parsing.ParseDouble(ss[0])); - readCustomSampleBanks(string.Join(":", ss.Skip(1)), bankInfo); + readCustomSampleBanks(string.Join(':', ss.Skip(1)), bankInfo); } result = CreateHold(pos, combo, comboOffset, endTime + Offset - startTime); @@ -255,8 +207,108 @@ namespace osu.Game.Rulesets.Objects.Legacy bankInfo.Filename = split.Length > 4 ? split[4] : null; } - private PathControlPoint[] convertControlPoints(Vector2[] vertices, PathType type) + private PathType convertPathType(string input) { + switch (input[0]) + { + default: + case 'C': + return PathType.Catmull; + + case 'B': + return PathType.Bezier; + + case 'L': + return PathType.Linear; + + case 'P': + return PathType.PerfectCurve; + } + } + + /// + /// Converts a given point string into a set of path control points. + /// + /// + /// A point string takes the form: X|1:1|2:2|2:2|3:3|Y|1:1|2:2. + /// This has three segments: + /// + /// + /// X: { (1,1), (2,2) } (implicit segment) + /// + /// + /// X: { (2,2), (3,3) } (implicit segment) + /// + /// + /// Y: { (3,3), (1,1), (2, 2) } (explicit segment) + /// + /// + /// + /// The point string. + /// The positional offset to apply to the control points. + /// All control points in the resultant path. + private PathControlPoint[] convertPathString(string pointString, Vector2 offset) + { + // This code takes on the responsibility of handling explicit segments of the path ("X" & "Y" from above). Implicit segments are handled by calls to convertPoints(). + string[] pointSplit = pointString.Split('|'); + + var controlPoints = new List>(); + int startIndex = 0; + int endIndex = 0; + bool first = true; + + while (++endIndex < pointSplit.Length) + { + // Keep incrementing endIndex while it's not the start of a new segment (indicated by having a type descriptor of length 1). + if (pointSplit[endIndex].Length > 1) + continue; + + // Multi-segmented sliders DON'T contain the end point as part of the current segment as it's assumed to be the start of the next segment. + // The start of the next segment is the index after the type descriptor. + string endPoint = endIndex < pointSplit.Length - 1 ? pointSplit[endIndex + 1] : null; + + controlPoints.AddRange(convertPoints(pointSplit.AsMemory().Slice(startIndex, endIndex - startIndex), endPoint, first, offset)); + startIndex = endIndex; + first = false; + } + + if (endIndex > startIndex) + controlPoints.AddRange(convertPoints(pointSplit.AsMemory().Slice(startIndex, endIndex - startIndex), null, first, offset)); + + return mergePointsLists(controlPoints); + } + + /// + /// Converts a given point list into a set of path segments. + /// + /// The point list. + /// Any extra endpoint to consider as part of the points. This will NOT be returned. + /// Whether this is the first segment in the set. If true the first of the returned segments will contain a zero point. + /// The positional offset to apply to the control points. + /// The set of points contained by as one or more segments of the path, prepended by an extra zero point if is true. + private IEnumerable> convertPoints(ReadOnlyMemory points, string endPoint, bool first, Vector2 offset) + { + PathType type = convertPathType(points.Span[0]); + + int readOffset = first ? 1 : 0; // First control point is zero for the first segment. + int readablePoints = points.Length - 1; // Total points readable from the base point span. + int endPointLength = endPoint != null ? 1 : 0; // Extra length if an endpoint is given that lies outside the base point span. + + var vertices = new PathControlPoint[readOffset + readablePoints + endPointLength]; + + // Fill any non-read points. + for (int i = 0; i < readOffset; i++) + vertices[i] = new PathControlPoint(); + + // Parse into control points. + for (int i = 1; i < points.Length; i++) + readPoint(points.Span[i], offset, out vertices[readOffset + i - 1]); + + // If an endpoint is given, add it to the end. + if (endPoint != null) + readPoint(endPoint, offset, out vertices[^1]); + + // Edge-case rules (to match stable). if (type == PathType.PerfectCurve) { if (vertices.Length != 3) @@ -268,29 +320,64 @@ namespace osu.Game.Rulesets.Objects.Legacy } } - var points = new List(vertices.Length) - { - new PathControlPoint - { - Position = { Value = vertices[0] }, - Type = { Value = type } - } - }; + // The first control point must have a definite type. + vertices[0].Type.Value = type; - for (int i = 1; i < vertices.Length; i++) + // A path can have multiple implicit segments of the same type if there are two sequential control points with the same position. + // To handle such cases, this code may return multiple path segments with the final control point in each segment having a non-null type. + // For the point string X|1:1|2:2|2:2|3:3, this code returns the segments: + // X: { (1,1), (2, 2) } + // X: { (3, 3) } + // Note: (2, 2) is not returned in the second segments, as it is implicit in the path. + int startIndex = 0; + int endIndex = 0; + + while (++endIndex < vertices.Length - endPointLength) { - if (vertices[i] == vertices[i - 1]) - { - points[^1].Type.Value = type; + if (vertices[endIndex].Position.Value != vertices[endIndex - 1].Position.Value) continue; - } - points.Add(new PathControlPoint { Position = { Value = vertices[i] } }); + // Force a type on the last point, and return the current control point set as a segment. + vertices[endIndex - 1].Type.Value = type; + yield return vertices.AsMemory().Slice(startIndex, endIndex - startIndex); + + // Skip the current control point - as it's the same as the one that's just been returned. + startIndex = endIndex + 1; } - return points.ToArray(); + if (endIndex > startIndex) + yield return vertices.AsMemory().Slice(startIndex, endIndex - startIndex); - static bool isLinear(Vector2[] p) => Precision.AlmostEquals(0, (p[1].Y - p[0].Y) * (p[2].X - p[0].X) - (p[1].X - p[0].X) * (p[2].Y - p[0].Y)); + static void readPoint(string value, Vector2 startPos, out PathControlPoint point) + { + string[] vertexSplit = value.Split(':'); + + Vector2 pos = new Vector2((int)Parsing.ParseDouble(vertexSplit[0], Parsing.MAX_COORDINATE_VALUE), (int)Parsing.ParseDouble(vertexSplit[1], Parsing.MAX_COORDINATE_VALUE)) - startPos; + point = new PathControlPoint { Position = { Value = pos } }; + } + + static bool isLinear(PathControlPoint[] p) => Precision.AlmostEquals(0, (p[1].Position.Value.Y - p[0].Position.Value.Y) * (p[2].Position.Value.X - p[0].Position.Value.X) + - (p[1].Position.Value.X - p[0].Position.Value.X) * (p[2].Position.Value.Y - p[0].Position.Value.Y)); + } + + private PathControlPoint[] mergePointsLists(List> controlPointList) + { + int totalCount = 0; + + foreach (var arr in controlPointList) + totalCount += arr.Length; + + var mergedArray = new PathControlPoint[totalCount]; + var mergedArrayMemory = mergedArray.AsMemory(); + int copyIndex = 0; + + foreach (var arr in controlPointList) + { + arr.CopyTo(mergedArrayMemory.Slice(copyIndex)); + copyIndex += arr.Length; + } + + return mergedArray; } /// diff --git a/osu.Game/Rulesets/Objects/SliderPath.cs b/osu.Game/Rulesets/Objects/SliderPath.cs index d577e8fdda..3083fcfccb 100644 --- a/osu.Game/Rulesets/Objects/SliderPath.cs +++ b/osu.Game/Rulesets/Objects/SliderPath.cs @@ -57,6 +57,7 @@ namespace osu.Game.Rulesets.Objects c.Changed += invalidate; break; + case NotifyCollectionChangedAction.Reset: case NotifyCollectionChangedAction.Remove: foreach (var c in args.OldItems.Cast()) c.Changed -= invalidate; diff --git a/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs b/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs index 7a3fb16196..674e2aee88 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasRepeats.cs @@ -35,5 +35,15 @@ namespace osu.Game.Rulesets.Objects.Types /// /// The object that has repeats. public static int SpanCount(this IHasRepeats obj) => obj.RepeatCount + 1; + + /// + /// Retrieves the samples at a particular node in a object. + /// + /// The . + /// The node to attempt to retrieve the samples at. + /// The samples at the given node index, or 's default samples if the given node doesn't exist. + public static IList GetNodeSamples(this T obj, int nodeIndex) + where T : HitObject, IHasRepeats + => nodeIndex < obj.NodeSamples.Count ? obj.NodeSamples[nodeIndex] : obj.Samples; } } diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index fef36ef16a..8caadffd1d 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -23,8 +23,10 @@ using osu.Game.Scoring; using osu.Game.Skinning; using osu.Game.Users; using JetBrains.Annotations; +using osu.Framework.Extensions; using osu.Framework.Testing; using osu.Game.Screens.Ranking.Statistics; +using osu.Game.Utils; namespace osu.Game.Rulesets { @@ -241,5 +243,52 @@ namespace osu.Game.Rulesets /// The s to display. Each may contain 0 or more . [NotNull] public virtual StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => Array.Empty(); + + /// + /// Get all valid s for this ruleset. + /// Generally used for results display purposes, where it can't be determined if zero-count means the user has not achieved any or the type is not used by this ruleset. + /// + /// + /// All valid s along with a display-friendly name. + /// + public IEnumerable<(HitResult result, string displayName)> GetHitResults() + { + var validResults = GetValidHitResults(); + + // enumerate over ordered list to guarantee return order is stable. + foreach (var result in OrderAttributeUtils.GetValuesInOrder()) + { + switch (result) + { + // hard blocked types, should never be displayed even if the ruleset tells us to. + case HitResult.None: + case HitResult.IgnoreHit: + case HitResult.IgnoreMiss: + // display is handled as a completion count with corresponding "hit" type. + case HitResult.LargeTickMiss: + case HitResult.SmallTickMiss: + continue; + } + + if (result == HitResult.Miss || validResults.Contains(result)) + yield return (result, GetDisplayNameForHitResult(result)); + } + } + + /// + /// Get all valid s for this ruleset. + /// Generally used for results display purposes, where it can't be determined if zero-count means the user has not achieved any or the type is not used by this ruleset. + /// + /// + /// is implicitly included. Special types like are ignored even when specified. + /// + protected virtual IEnumerable GetValidHitResults() => OrderAttributeUtils.GetValuesInOrder(); + + /// + /// Get a display friendly name for the specified result type. + /// + /// The result type to get the name for. + /// The display name. + public virtual string GetDisplayNameForHitResult(HitResult result) => result.GetDescription(); } } diff --git a/osu.Game/Rulesets/RulesetStore.cs b/osu.Game/Rulesets/RulesetStore.cs index 5d93f5186b..d422bca087 100644 --- a/osu.Game/Rulesets/RulesetStore.cs +++ b/osu.Game/Rulesets/RulesetStore.cs @@ -96,11 +96,13 @@ namespace osu.Game.Rulesets context.SaveChanges(); // add any other modes + var existingRulesets = context.RulesetInfo.ToList(); + foreach (var r in instances.Where(r => !(r is ILegacyRuleset))) { // todo: StartsWith can be changed to Equals on 2020-11-08 // This is to give users enough time to have their database use new abbreviated info). - if (context.RulesetInfo.FirstOrDefault(ri => ri.InstantiationInfo.StartsWith(r.RulesetInfo.InstantiationInfo)) == null) + if (existingRulesets.FirstOrDefault(ri => ri.InstantiationInfo.StartsWith(r.RulesetInfo.InstantiationInfo, StringComparison.Ordinal)) == null) context.RulesetInfo.Add(r.RulesetInfo); } diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index 7a5b707357..33271d9689 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -67,7 +67,7 @@ namespace osu.Game.Rulesets.Scoring private readonly double accuracyPortion; private readonly double comboPortion; - private int maxHighestCombo; + private int maxAchievableCombo; private double maxBaseScore; private double rollingMaxBaseScore; private double baseScore; @@ -195,9 +195,9 @@ namespace osu.Game.Rulesets.Scoring private double getScore(ScoringMode mode) { - return GetScore(mode, maxHighestCombo, + return GetScore(mode, maxAchievableCombo, maxBaseScore > 0 ? baseScore / maxBaseScore : 0, - maxHighestCombo > 0 ? (double)HighestCombo.Value / maxHighestCombo : 0, + maxAchievableCombo > 0 ? (double)HighestCombo.Value / maxAchievableCombo : 1, scoreResultCounts); } @@ -223,7 +223,7 @@ namespace osu.Game.Rulesets.Scoring case ScoringMode.Classic: // should emulate osu-stable's scoring as closely as we can (https://osu.ppy.sh/help/wiki/Score/ScoreV1) - return getBonusScore(statistics) + (accuracyRatio * maxCombo * 300) * (1 + Math.Max(0, (comboRatio * maxCombo) - 1) * scoreMultiplier / 25); + return getBonusScore(statistics) + (accuracyRatio * Math.Max(1, maxCombo) * 300) * (1 + Math.Max(0, (comboRatio * maxCombo) - 1) * scoreMultiplier / 25); } } @@ -265,14 +265,8 @@ namespace osu.Game.Rulesets.Scoring if (storeResults) { - maxHighestCombo = HighestCombo.Value; + maxAchievableCombo = HighestCombo.Value; maxBaseScore = baseScore; - - if (maxBaseScore == 0 || maxHighestCombo == 0) - { - Mode.Value = ScoringMode.Classic; - Mode.Disabled = true; - } } baseScore = 0; diff --git a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs index 70b3d0c7d4..e4a3a2fe3d 100644 --- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs +++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs @@ -18,8 +18,11 @@ namespace osu.Game.Rulesets.UI /// A container which consumes a parent gameplay clock and standardises frame counts for children. /// Will ensure a minimum of 50 frames per clock second is maintained, regardless of any system lag or seeks. /// - public class FrameStabilityContainer : Container, IHasReplayHandler + [Cached(typeof(ISamplePlaybackDisabler))] + public class FrameStabilityContainer : Container, IHasReplayHandler, ISamplePlaybackDisabler { + private readonly Bindable samplePlaybackDisabled = new Bindable(); + private readonly double gameplayStartTime; /// @@ -35,7 +38,6 @@ namespace osu.Game.Rulesets.UI public GameplayClock GameplayClock => stabilityGameplayClock; [Cached(typeof(GameplayClock))] - [Cached(typeof(ISamplePlaybackDisabler))] private readonly StabilityGameplayClock stabilityGameplayClock; public FrameStabilityContainer(double gameplayStartTime = double.MinValue) @@ -102,6 +104,8 @@ namespace osu.Game.Rulesets.UI requireMoreUpdateLoops = true; validState = !GameplayClock.IsPaused.Value; + samplePlaybackDisabled.Value = stabilityGameplayClock.ShouldDisableSamplePlayback; + int loops = 0; while (validState && requireMoreUpdateLoops && loops++ < MaxCatchUpFrames) @@ -224,6 +228,8 @@ namespace osu.Game.Rulesets.UI public ReplayInputHandler ReplayInputHandler { get; set; } + IBindable ISamplePlaybackDisabler.SamplePlaybackDisabled => samplePlaybackDisabled; + private class StabilityGameplayClock : GameplayClock { public GameplayClock ParentGameplayClock; @@ -237,7 +243,7 @@ namespace osu.Game.Rulesets.UI { } - protected override bool ShouldDisableSamplePlayback => + public override bool ShouldDisableSamplePlayback => // handle the case where playback is catching up to real-time. base.ShouldDisableSamplePlayback || ParentSampleDisabler?.SamplePlaybackDisabled.Value == true diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs index f2ac61eaf4..07de2bf601 100644 --- a/osu.Game/Rulesets/UI/RulesetInputManager.cs +++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs @@ -136,7 +136,11 @@ namespace osu.Game.Rulesets.UI KeyBindingContainer.Add(receptor); keyCounter.SetReceptor(receptor); - keyCounter.AddRange(KeyBindingContainer.DefaultKeyBindings.Select(b => b.GetAction()).Distinct().Select(b => new KeyCounterAction(b))); + keyCounter.AddRange(KeyBindingContainer.DefaultKeyBindings + .Select(b => b.GetAction()) + .Distinct() + .OrderBy(action => action) + .Select(action => new KeyCounterAction(action))); } public class ActionReceptor : KeyCounterDisplay.Receptor, IKeyBindingHandler diff --git a/osu.Game/Scoring/HitResultDisplayStatistic.cs b/osu.Game/Scoring/HitResultDisplayStatistic.cs new file mode 100644 index 0000000000..d43d8bf0ba --- /dev/null +++ b/osu.Game/Scoring/HitResultDisplayStatistic.cs @@ -0,0 +1,41 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Scoring +{ + /// + /// Compiled result data for a specific in a score. + /// + public class HitResultDisplayStatistic + { + /// + /// The associated result type. + /// + public HitResult Result { get; } + + /// + /// The count of successful hits of this type. + /// + public int Count { get; } + + /// + /// The maximum achievable hits of this type. May be null if undetermined. + /// + public int? MaxCount { get; } + + /// + /// A custom display name for the result type. May be provided by rulesets to give better clarity. + /// + public string DisplayName { get; } + + public HitResultDisplayStatistic(HitResult result, int count, int? maxCount, string displayName) + { + Result = result; + Count = count; + MaxCount = maxCount; + DisplayName = displayName; + } + } +} diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index 0206989231..596e98a6bd 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -213,22 +213,19 @@ namespace osu.Game.Scoring set => isLegacyScore = value; } - public IEnumerable<(HitResult result, int count, int? maxCount)> GetStatisticsForDisplay() + public IEnumerable GetStatisticsForDisplay() { - foreach (var key in OrderAttributeUtils.GetValuesInOrder()) + foreach (var r in Ruleset.CreateInstance().GetHitResults()) { - if (key.IsBonus()) - continue; + int value = Statistics.GetOrDefault(r.result); - int value = Statistics.GetOrDefault(key); - - switch (key) + switch (r.result) { case HitResult.SmallTickHit: { int total = value + Statistics.GetOrDefault(HitResult.SmallTickMiss); if (total > 0) - yield return (key, value, total); + yield return new HitResultDisplayStatistic(r.result, value, total, r.displayName); break; } @@ -237,7 +234,7 @@ namespace osu.Game.Scoring { int total = value + Statistics.GetOrDefault(HitResult.LargeTickMiss); if (total > 0) - yield return (key, value, total); + yield return new HitResultDisplayStatistic(r.result, value, total, r.displayName); break; } @@ -247,8 +244,7 @@ namespace osu.Game.Scoring break; default: - if (value > 0 || key == HitResult.Miss) - yield return (key, value, null); + yield return new HitResultDisplayStatistic(r.result, value, null, r.displayName); break; } diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index 5a6da53839..cce6153953 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -57,7 +57,7 @@ namespace osu.Game.Scoring if (archive == null) return null; - using (var stream = archive.GetStream(archive.Filenames.First(f => f.EndsWith(".osr")))) + using (var stream = archive.GetStream(archive.Filenames.First(f => f.EndsWith(".osr", StringComparison.OrdinalIgnoreCase)))) { try { diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/PointVisualisation.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/PointVisualisation.cs index 1ac960039e..b0ecffdd24 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/PointVisualisation.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Visualisations/PointVisualisation.cs @@ -1,9 +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 osuTK; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; +using osuTK; namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations { @@ -12,16 +12,23 @@ namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations /// public class PointVisualisation : Box { + public const float WIDTH = 1; + public PointVisualisation(double startTime) + : this() + { + X = (float)startTime; + } + + public PointVisualisation() { Origin = Anchor.TopCentre; - RelativeSizeAxes = Axes.Y; - Width = 1; - EdgeSmoothness = new Vector2(1, 0); - RelativePositionAxes = Axes.X; - X = (float)startTime; + RelativeSizeAxes = Axes.Y; + + Width = WIDTH; + EdgeSmoothness = new Vector2(WIDTH, 0); } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index 970e16d1c3..7751df29cf 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -203,7 +203,7 @@ namespace osu.Game.Screens.Edit.Compose.Components { // handle positional change etc. foreach (var obj in selectedHitObjects) - Beatmap.UpdateHitObject(obj); + Beatmap.Update(obj); changeHandler?.EndChange(); isDraggingBlueprint = false; @@ -436,8 +436,12 @@ namespace osu.Game.Screens.Edit.Compose.Components { // Apply the start time at the newly snapped-to position double offset = result.Time.Value - draggedObject.StartTime; - foreach (HitObject obj in SelectionHandler.SelectedHitObjects) + + foreach (HitObject obj in Beatmap.SelectedHitObjects) + { obj.StartTime += offset; + Beatmap.Update(obj); + } } return true; diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs index 9b3314e2ad..1527d20f54 100644 --- a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs @@ -79,9 +79,7 @@ namespace osu.Game.Screens.Edit.Compose.Components private void updatePlacementNewCombo() { - if (currentPlacement == null) return; - - if (currentPlacement.HitObject is IHasComboInformation c) + if (currentPlacement?.HitObject is IHasComboInformation c) c.NewCombo = NewCombo.Value == TernaryState.True; } @@ -201,7 +199,12 @@ namespace osu.Game.Screens.Edit.Compose.Components protected override void AddBlueprintFor(HitObject hitObject) { refreshTool(); + base.AddBlueprintFor(hitObject); + + // on successful placement, the new combo button should be reset as this is the most common user interaction. + if (Beatmap.SelectedHitObjects.Count == 0) + NewCombo.Value = TernaryState.False; } private void createPlacement() diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs index 64191e48e2..b753c45cca 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs @@ -17,10 +17,28 @@ namespace osu.Game.Screens.Edit.Compose.Components public Action OnRotation; public Action OnScale; public Action OnFlip; + public Action OnReverse; public Action OperationStarted; public Action OperationEnded; + private bool canReverse; + + /// + /// Whether pattern reversing support should be enabled. + /// + public bool CanReverse + { + get => canReverse; + set + { + if (canReverse == value) return; + + canReverse = value; + recreate(); + } + } + private bool canRotate; /// @@ -125,6 +143,7 @@ namespace osu.Game.Screens.Edit.Compose.Components if (CanScaleX && CanScaleY) addFullScaleComponents(); if (CanScaleY) addYScaleComponents(); if (CanRotate) addRotationComponents(); + if (CanReverse) addButton(FontAwesome.Solid.Backward, "Reverse pattern", () => OnReverse?.Invoke()); } private void addRotationComponents() diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index 7808d7a5bc..4caceedc5a 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -37,15 +37,13 @@ namespace osu.Game.Screens.Edit.Compose.Components public int SelectedCount => selectedBlueprints.Count; - public IEnumerable SelectedHitObjects => selectedBlueprints.Select(b => b.HitObject); - private Drawable content; private OsuSpriteText selectionDetailsText; protected SelectionBox SelectionBox { get; private set; } - [Resolved(CanBeNull = true)] + [Resolved] protected EditorBeatmap EditorBeatmap { get; private set; } [Resolved(CanBeNull = true)] @@ -103,6 +101,7 @@ namespace osu.Game.Screens.Edit.Compose.Components OnRotation = angle => HandleRotation(angle), OnScale = (amount, anchor) => HandleScale(amount, anchor), OnFlip = direction => HandleFlip(direction), + OnReverse = () => HandleReverse(), }; /// @@ -141,7 +140,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Handles the selected s being rotated. /// /// The delta angle to apply to the selection. - /// Whether any s could be moved. + /// Whether any s could be rotated. public virtual bool HandleRotation(float angle) => false; /// @@ -149,16 +148,22 @@ namespace osu.Game.Screens.Edit.Compose.Components /// /// The delta scale to apply, in playfield local coordinates. /// The point of reference where the scale is originating from. - /// Whether any s could be moved. + /// Whether any s could be scaled. public virtual bool HandleScale(Vector2 scale, Anchor anchor) => false; /// - /// Handled the selected s being flipped. + /// Handles the selected s being flipped. /// /// The direction to flip - /// Whether any s could be moved. + /// Whether any s could be flipped. public virtual bool HandleFlip(Direction direction) => false; + /// + /// Handles the selected s being reversed pattern-wise. + /// + /// Whether any s could be reversed. + public virtual bool HandleReverse() => false; + public bool OnPressed(PlatformAction action) { switch (action.ActionMethod) @@ -238,9 +243,7 @@ namespace osu.Game.Screens.Edit.Compose.Components private void deleteSelected() { - ChangeHandler?.BeginChange(); - EditorBeatmap?.RemoveRange(selectedBlueprints.Select(b => b.HitObject)); - ChangeHandler?.EndChange(); + EditorBeatmap.RemoveRange(selectedBlueprints.Select(b => b.HitObject)); } #endregion @@ -307,9 +310,9 @@ namespace osu.Game.Screens.Edit.Compose.Components /// The name of the hit sample. public void AddHitSample(string sampleName) { - ChangeHandler?.BeginChange(); + EditorBeatmap.BeginChange(); - foreach (var h in SelectedHitObjects) + foreach (var h in EditorBeatmap.SelectedHitObjects) { // Make sure there isn't already an existing sample if (h.Samples.Any(s => s.Name == sampleName)) @@ -318,7 +321,7 @@ namespace osu.Game.Screens.Edit.Compose.Components h.Samples.Add(new HitSampleInfo { Name = sampleName }); } - ChangeHandler?.EndChange(); + EditorBeatmap.EndChange(); } /// @@ -328,19 +331,19 @@ namespace osu.Game.Screens.Edit.Compose.Components /// Throws if any selected object doesn't implement public void SetNewCombo(bool state) { - ChangeHandler?.BeginChange(); + EditorBeatmap.BeginChange(); - foreach (var h in SelectedHitObjects) + foreach (var h in EditorBeatmap.SelectedHitObjects) { var comboInfo = h as IHasComboInformation; if (comboInfo == null || comboInfo.NewCombo == state) continue; comboInfo.NewCombo = state; - EditorBeatmap?.UpdateHitObject(h); + EditorBeatmap.Update(h); } - ChangeHandler?.EndChange(); + EditorBeatmap.EndChange(); } /// @@ -349,12 +352,12 @@ namespace osu.Game.Screens.Edit.Compose.Components /// The name of the hit sample. public void RemoveHitSample(string sampleName) { - ChangeHandler?.BeginChange(); + EditorBeatmap.BeginChange(); - foreach (var h in SelectedHitObjects) + foreach (var h in EditorBeatmap.SelectedHitObjects) h.SamplesBindable.RemoveAll(s => s.Name == sampleName); - ChangeHandler?.EndChange(); + EditorBeatmap.EndChange(); } #endregion @@ -425,11 +428,11 @@ namespace osu.Game.Screens.Edit.Compose.Components /// protected virtual void UpdateTernaryStates() { - SelectionNewComboState.Value = GetStateFromSelection(SelectedHitObjects.OfType(), h => h.NewCombo); + SelectionNewComboState.Value = GetStateFromSelection(EditorBeatmap.SelectedHitObjects.OfType(), h => h.NewCombo); foreach (var (sampleName, bindable) in SelectionSampleStates) { - bindable.Value = GetStateFromSelection(SelectedHitObjects, h => h.Samples.Any(s => s.Name == sampleName)); + bindable.Value = GetStateFromSelection(EditorBeatmap.SelectedHitObjects, h => h.Samples.Any(s => s.Name == sampleName)); } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs index be3bca3242..9aff4ddf8f 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs @@ -81,7 +81,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline waveform = new WaveformGraph { RelativeSizeAxes = Axes.Both, - Colour = colours.Blue.Opacity(0.2f), + BaseColour = colours.Blue.Opacity(0.2f), LowColour = colours.BlueLighter, MidColour = colours.BlueDark, HighColour = colours.BlueDarker, diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs index f0757a3dda..975433d407 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs @@ -392,6 +392,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline return; repeatHitObject.RepeatCount = proposedCount; + beatmap.Update(hitObject); break; case IHasDuration endTimeHitObject: @@ -401,10 +402,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline return; endTimeHitObject.Duration = snappedTime - hitObject.StartTime; + beatmap.Update(hitObject); break; } - - beatmap.UpdateHitObject(hitObject); } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs index 36ee976bf7..724256af8b 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineTickDisplay.cs @@ -1,9 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Caching; using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Graphics; @@ -12,7 +14,7 @@ using osu.Game.Screens.Edit.Components.Timelines.Summary.Visualisations; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class TimelineTickDisplay : TimelinePart + public class TimelineTickDisplay : TimelinePart { [Resolved] private EditorBeatmap beatmap { get; set; } @@ -31,15 +33,63 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline RelativeSizeAxes = Axes.Both; } + private readonly Cached tickCache = new Cached(); + [BackgroundDependencyLoader] private void load() { - beatDivisor.BindValueChanged(_ => createLines(), true); + beatDivisor.BindValueChanged(_ => tickCache.Invalidate()); } - private void createLines() + /// + /// The visible time/position range of the timeline. + /// + private (float min, float max) visibleRange = (float.MinValue, float.MaxValue); + + /// + /// The next time/position value to the left of the display when tick regeneration needs to be run. + /// + private float? nextMinTick; + + /// + /// The next time/position value to the right of the display when tick regeneration needs to be run. + /// + private float? nextMaxTick; + + [Resolved(canBeNull: true)] + private Timeline timeline { get; set; } + + protected override void Update() { - Clear(); + base.Update(); + + if (timeline != null) + { + var newRange = ( + (ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopLeft).X - PointVisualisation.WIDTH * 2) / DrawWidth * Content.RelativeChildSize.X, + (ToLocalSpace(timeline.ScreenSpaceDrawQuad.TopRight).X + PointVisualisation.WIDTH * 2) / DrawWidth * Content.RelativeChildSize.X); + + if (visibleRange != newRange) + { + visibleRange = newRange; + + // actual regeneration only needs to occur if we've passed one of the known next min/max tick boundaries. + if (nextMinTick == null || nextMaxTick == null || (visibleRange.min < nextMinTick || visibleRange.max > nextMaxTick)) + tickCache.Invalidate(); + } + } + + if (!tickCache.IsValid) + createTicks(); + } + + private void createTicks() + { + int drawableIndex = 0; + int highestDivisor = BindableBeatDivisor.VALID_DIVISORS.Last(); + + nextMinTick = null; + nextMaxTick = null; for (var i = 0; i < beatmap.ControlPointInfo.TimingPoints.Count; i++) { @@ -50,41 +100,70 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline for (double t = point.Time; t < until; t += point.BeatLength / beatDivisor.Value) { - var indexInBeat = beat % beatDivisor.Value; + float xPos = (float)t; - if (indexInBeat == 0) - { - Add(new PointVisualisation(t) - { - Colour = BindableBeatDivisor.GetColourFor(1, colours), - Origin = Anchor.TopCentre, - }); - } + if (t < visibleRange.min) + nextMinTick = xPos; + else if (t > visibleRange.max) + nextMaxTick ??= xPos; else { + // if this is the first beat in the beatmap, there is no next min tick + if (beat == 0 && i == 0) + nextMinTick = float.MinValue; + + var indexInBar = beat % ((int)point.TimeSignature * beatDivisor.Value); + var divisor = BindableBeatDivisor.GetDivisorForBeatIndex(beat, beatDivisor.Value); var colour = BindableBeatDivisor.GetColourFor(divisor, colours); - var height = 0.1f - (float)divisor / BindableBeatDivisor.VALID_DIVISORS.Last() * 0.08f; - Add(new PointVisualisation(t) - { - Colour = colour, - Height = height, - Origin = Anchor.TopCentre, - }); + // even though "bar lines" take up the full vertical space, we render them in two pieces because it allows for less anchor/origin churn. + var height = indexInBar == 0 ? 0.5f : 0.1f - (float)divisor / highestDivisor * 0.08f; - Add(new PointVisualisation(t) - { - Colour = colour, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomCentre, - Height = height, - }); + var topPoint = getNextUsablePoint(); + topPoint.X = xPos; + topPoint.Colour = colour; + topPoint.Height = height; + topPoint.Anchor = Anchor.TopLeft; + topPoint.Origin = Anchor.TopCentre; + + var bottomPoint = getNextUsablePoint(); + bottomPoint.X = xPos; + bottomPoint.Colour = colour; + bottomPoint.Anchor = Anchor.BottomLeft; + bottomPoint.Origin = Anchor.BottomCentre; + bottomPoint.Height = height; } beat++; } } + + int usedDrawables = drawableIndex; + + // save a few drawables beyond the currently used for edge cases. + while (drawableIndex < Math.Min(usedDrawables + 16, Count)) + Children[drawableIndex++].Hide(); + + // expire any excess + while (drawableIndex < Count) + Children[drawableIndex++].Expire(); + + tickCache.Validate(); + + Drawable getNextUsablePoint() + { + PointVisualisation point; + if (drawableIndex >= Count) + Add(point = new PointVisualisation()); + else + point = Children[drawableIndex]; + + drawableIndex++; + point.Show(); + + return point; + } } } } diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 3c5cbf30e9..c3560dff38 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -469,10 +469,17 @@ namespace osu.Game.Screens.Edit private void confirmExit() { + // stop the track if playing to allow the parent screen to choose a suitable playback mode. + Beatmap.Value.Track.Stop(); + if (isNewBeatmap) { // confirming exit without save means we should delete the new beatmap completely. beatmapManager.Delete(playableBeatmap.BeatmapInfo.BeatmapSet); + + // in theory this shouldn't be required but due to EF core not sharing instance states 100% + // MusicController is unaware of the changed DeletePending state. + Beatmap.SetDefault(); } exitConfirmed = true; @@ -509,14 +516,14 @@ namespace osu.Game.Screens.Edit foreach (var h in objects) h.StartTime += timeOffset; - changeHandler.BeginChange(); + editorBeatmap.BeginChange(); editorBeatmap.SelectedHitObjects.Clear(); editorBeatmap.AddRange(objects); editorBeatmap.SelectedHitObjects.AddRange(objects); - changeHandler.EndChange(); + editorBeatmap.EndChange(); } protected void Undo() => changeHandler.RestoreState(-1); @@ -590,10 +597,20 @@ namespace osu.Game.Screens.Edit { double amount = e.ShiftPressed ? 4 : 1; + bool trackPlaying = clock.IsRunning; + + if (trackPlaying) + { + // generally users are not looking to perform tiny seeks when the track is playing, + // so seeks should always be by one full beat, bypassing the beatDivisor. + // this multiplication undoes the division that will be applied in the underlying seek operation. + amount *= beatDivisor.Value; + } + if (direction < 1) - clock.SeekBackward(!clock.IsRunning, amount); + clock.SeekBackward(!trackPlaying, amount); else - clock.SeekForward(!clock.IsRunning, amount); + clock.SeekForward(!trackPlaying, amount); } private void exportBeatmap() diff --git a/osu.Game/Screens/Edit/EditorBeatmap.cs b/osu.Game/Screens/Edit/EditorBeatmap.cs index fb75d91d16..165d2ba278 100644 --- a/osu.Game/Screens/Edit/EditorBeatmap.cs +++ b/osu.Game/Screens/Edit/EditorBeatmap.cs @@ -8,7 +8,6 @@ using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Timing; @@ -18,7 +17,7 @@ using osu.Game.Skinning; namespace osu.Game.Screens.Edit { - public class EditorBeatmap : Component, IBeatmap, IBeatSnapProvider + public class EditorBeatmap : TransactionalCommitComponent, IBeatmap, IBeatSnapProvider { /// /// Invoked when a is added to this . @@ -89,9 +88,11 @@ namespace osu.Game.Screens.Edit private IList mutableHitObjects => (IList)PlayableBeatmap.HitObjects; - private readonly HashSet pendingUpdates = new HashSet(); + private readonly List batchPendingInserts = new List(); - private bool isBatchApplying; + private readonly List batchPendingDeletes = new List(); + + private readonly HashSet batchPendingUpdates = new HashSet(); /// /// Adds a collection of s to this . @@ -99,11 +100,10 @@ namespace osu.Game.Screens.Edit /// The s to add. public void AddRange(IEnumerable hitObjects) { - ApplyBatchChanges(_ => - { - foreach (var h in hitObjects) - Add(h); - }); + BeginChange(); + foreach (var h in hitObjects) + Add(h); + EndChange(); } /// @@ -131,26 +131,28 @@ namespace osu.Game.Screens.Edit mutableHitObjects.Insert(index, hitObject); - if (isBatchApplying) - batchPendingInserts.Add(hitObject); - else - { - // must be run after any change to hitobject ordering - beatmapProcessor?.PreProcess(); - processHitObject(hitObject); - beatmapProcessor?.PostProcess(); - - HitObjectAdded?.Invoke(hitObject); - } + BeginChange(); + batchPendingInserts.Add(hitObject); + EndChange(); } /// /// Updates a , invoking and re-processing the beatmap. /// /// The to update. - public void UpdateHitObject([NotNull] HitObject hitObject) + public void Update([NotNull] HitObject hitObject) { - pendingUpdates.Add(hitObject); + // updates are debounced regardless of whether a batch is active. + batchPendingUpdates.Add(hitObject); + } + + /// + /// Update all hit objects with potentially changed difficulty or control point data. + /// + public void UpdateAllHitObjects() + { + foreach (var h in HitObjects) + batchPendingUpdates.Add(h); } /// @@ -175,11 +177,10 @@ namespace osu.Game.Screens.Edit /// The s to remove. public void RemoveRange(IEnumerable hitObjects) { - ApplyBatchChanges(_ => - { - foreach (var h in hitObjects) - Remove(h); - }); + BeginChange(); + foreach (var h in hitObjects) + Remove(h); + EndChange(); } /// @@ -203,50 +204,45 @@ namespace osu.Game.Screens.Edit bindable.UnbindAll(); startTimeBindables.Remove(hitObject); - if (isBatchApplying) - batchPendingDeletes.Add(hitObject); - else - { - // must be run after any change to hitobject ordering - beatmapProcessor?.PreProcess(); - processHitObject(hitObject); - beatmapProcessor?.PostProcess(); - - HitObjectRemoved?.Invoke(hitObject); - } + BeginChange(); + batchPendingDeletes.Add(hitObject); + EndChange(); } - private readonly List batchPendingInserts = new List(); - - private readonly List batchPendingDeletes = new List(); - - /// - /// Apply a batch of operations in one go, without performing Pre/Postprocessing each time. - /// - /// The function which will apply the batch changes. - public void ApplyBatchChanges(Action applyFunction) + protected override void Update() { - if (isBatchApplying) - throw new InvalidOperationException("Attempting to perform a batch application from within an existing batch"); + base.Update(); - isBatchApplying = true; + if (batchPendingUpdates.Count > 0) + UpdateState(); + } - applyFunction(this); + protected override void UpdateState() + { + if (batchPendingUpdates.Count == 0 && batchPendingDeletes.Count == 0 && batchPendingInserts.Count == 0) + return; beatmapProcessor?.PreProcess(); foreach (var h in batchPendingDeletes) processHitObject(h); foreach (var h in batchPendingInserts) processHitObject(h); + foreach (var h in batchPendingUpdates) processHitObject(h); beatmapProcessor?.PostProcess(); - foreach (var h in batchPendingDeletes) HitObjectRemoved?.Invoke(h); - foreach (var h in batchPendingInserts) HitObjectAdded?.Invoke(h); - + // callbacks may modify the lists so let's be safe about it + var deletes = batchPendingDeletes.ToArray(); batchPendingDeletes.Clear(); + + var inserts = batchPendingInserts.ToArray(); batchPendingInserts.Clear(); - isBatchApplying = false; + var updates = batchPendingUpdates.ToArray(); + batchPendingUpdates.Clear(); + + foreach (var h in deletes) HitObjectRemoved?.Invoke(h); + foreach (var h in inserts) HitObjectAdded?.Invoke(h); + foreach (var h in updates) HitObjectUpdated?.Invoke(h); } /// @@ -254,28 +250,6 @@ namespace osu.Game.Screens.Edit /// public void Clear() => RemoveRange(HitObjects.ToArray()); - protected override void Update() - { - base.Update(); - - // debounce updates as they are common and may come from input events, which can run needlessly many times per update frame. - if (pendingUpdates.Count > 0) - { - beatmapProcessor?.PreProcess(); - - foreach (var hitObject in pendingUpdates) - processHitObject(hitObject); - - beatmapProcessor?.PostProcess(); - - // explicitly needs to be fired after PostProcess - foreach (var hitObject in pendingUpdates) - HitObjectUpdated?.Invoke(hitObject); - - pendingUpdates.Clear(); - } - } - private void processHitObject(HitObject hitObject) => hitObject.ApplyDefaults(ControlPointInfo, BeatmapInfo.BaseDifficulty); private void trackStartTime(HitObject hitObject) @@ -289,7 +263,7 @@ namespace osu.Game.Screens.Edit var insertionIndex = findInsertionIndex(PlayableBeatmap.HitObjects, hitObject.StartTime); mutableHitObjects.Insert(insertionIndex + 1, hitObject); - UpdateHitObject(hitObject); + Update(hitObject); }; } @@ -315,14 +289,5 @@ namespace osu.Game.Screens.Edit public double GetBeatLengthAtTime(double referenceTime) => ControlPointInfo.TimingPointAt(referenceTime).BeatLength / BeatDivisor; public int BeatDivisor => beatDivisor?.Value ?? 1; - - /// - /// Update all hit objects with potentially changed difficulty or control point data. - /// - public void UpdateBeatmap() - { - foreach (var h in HitObjects) - pendingUpdates.Add(h); - } } } diff --git a/osu.Game/Screens/Edit/EditorChangeHandler.cs b/osu.Game/Screens/Edit/EditorChangeHandler.cs index b69e9c4c51..62187aed24 100644 --- a/osu.Game/Screens/Edit/EditorChangeHandler.cs +++ b/osu.Game/Screens/Edit/EditorChangeHandler.cs @@ -16,7 +16,7 @@ namespace osu.Game.Screens.Edit /// /// Tracks changes to the . /// - public class EditorChangeHandler : IEditorChangeHandler + public class EditorChangeHandler : TransactionalCommitComponent, IEditorChangeHandler { public readonly Bindable CanUndo = new Bindable(); public readonly Bindable CanRedo = new Bindable(); @@ -41,7 +41,6 @@ namespace osu.Game.Screens.Edit } private readonly EditorBeatmap editorBeatmap; - private int bulkChangesStarted; private bool isRestoring; public const int MAX_SAVED_STATES = 50; @@ -54,9 +53,9 @@ namespace osu.Game.Screens.Edit { this.editorBeatmap = editorBeatmap; - editorBeatmap.HitObjectAdded += hitObjectAdded; - editorBeatmap.HitObjectRemoved += hitObjectRemoved; - editorBeatmap.HitObjectUpdated += hitObjectUpdated; + editorBeatmap.TransactionBegan += BeginChange; + editorBeatmap.TransactionEnded += EndChange; + editorBeatmap.SaveStateTriggered += SaveState; patcher = new LegacyEditorBeatmapPatcher(editorBeatmap); @@ -64,28 +63,8 @@ namespace osu.Game.Screens.Edit SaveState(); } - private void hitObjectAdded(HitObject obj) => SaveState(); - - private void hitObjectRemoved(HitObject obj) => SaveState(); - - private void hitObjectUpdated(HitObject obj) => SaveState(); - - public void BeginChange() => bulkChangesStarted++; - - public void EndChange() + protected override void UpdateState() { - if (bulkChangesStarted == 0) - throw new InvalidOperationException($"Cannot call {nameof(EndChange)} without a previous call to {nameof(BeginChange)}."); - - if (--bulkChangesStarted == 0) - SaveState(); - } - - public void SaveState() - { - if (bulkChangesStarted > 0) - return; - if (isRestoring) return; @@ -120,7 +99,7 @@ namespace osu.Game.Screens.Edit /// The direction to restore in. If less than 0, an older state will be used. If greater than 0, a newer state will be used. public void RestoreState(int direction) { - if (bulkChangesStarted > 0) + if (TransactionActive) return; if (savedStates.Count == 0) diff --git a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs index fb7d0dd826..72d3421755 100644 --- a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs +++ b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs @@ -68,19 +68,20 @@ namespace osu.Game.Screens.Edit toRemove.Sort(); toAdd.Sort(); - editorBeatmap.ApplyBatchChanges(eb => - { - // Apply the changes. - for (int i = toRemove.Count - 1; i >= 0; i--) - eb.RemoveAt(toRemove[i]); + editorBeatmap.BeginChange(); - if (toAdd.Count > 0) - { - IBeatmap newBeatmap = readBeatmap(newState); - foreach (var i in toAdd) - eb.Insert(i, newBeatmap.HitObjects[i]); - } - }); + // Apply the changes. + for (int i = toRemove.Count - 1; i >= 0; i--) + editorBeatmap.RemoveAt(toRemove[i]); + + if (toAdd.Count > 0) + { + IBeatmap newBeatmap = readBeatmap(newState); + foreach (var i in toAdd) + editorBeatmap.Insert(i, newBeatmap.HitObjects[i]); + } + + editorBeatmap.EndChange(); } private string readString(byte[] state) => Encoding.UTF8.GetString(state); diff --git a/osu.Game/Screens/Edit/Setup/DifficultySection.cs b/osu.Game/Screens/Edit/Setup/DifficultySection.cs index 2d8031c3c8..aa1d57db31 100644 --- a/osu.Game/Screens/Edit/Setup/DifficultySection.cs +++ b/osu.Game/Screens/Edit/Setup/DifficultySection.cs @@ -93,7 +93,7 @@ namespace osu.Game.Screens.Edit.Setup Beatmap.Value.BeatmapInfo.BaseDifficulty.ApproachRate = approachRateSlider.Current.Value; Beatmap.Value.BeatmapInfo.BaseDifficulty.OverallDifficulty = overallDifficultySlider.Current.Value; - editorBeatmap.UpdateBeatmap(); + editorBeatmap.UpdateAllHitObjects(); } } } diff --git a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs index 4121e1f7bb..c8982b819a 100644 --- a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs +++ b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs @@ -113,7 +113,6 @@ namespace osu.Game.Screens.Edit.Timing }; controlPoints = group.ControlPoints.GetBoundCopy(); - controlPoints.CollectionChanged += (_, __) => createChildren(); } [Resolved] @@ -125,6 +124,12 @@ namespace osu.Game.Screens.Edit.Timing createChildren(); } + protected override void LoadComplete() + { + base.LoadComplete(); + controlPoints.CollectionChanged += (_, __) => createChildren(); + } + private void createChildren() { fill.ChildrenEnumerable = controlPoints.Select(createAttribute).Where(c => c != null); diff --git a/osu.Game/Screens/Edit/Timing/GroupSection.cs b/osu.Game/Screens/Edit/Timing/GroupSection.cs index c77d48ef0a..d76b5e7406 100644 --- a/osu.Game/Screens/Edit/Timing/GroupSection.cs +++ b/osu.Game/Screens/Edit/Timing/GroupSection.cs @@ -111,7 +111,8 @@ namespace osu.Game.Screens.Edit.Timing foreach (var cp in currentGroupItems) Beatmap.Value.Beatmap.ControlPointInfo.Add(time, cp); - SelectedGroup.Value = Beatmap.Value.Beatmap.ControlPointInfo.GroupAt(time); + // the control point might not necessarily exist yet, if currentGroupItems was empty. + SelectedGroup.Value = Beatmap.Value.Beatmap.ControlPointInfo.GroupAt(time, true); changeHandler?.EndChange(); } diff --git a/osu.Game/Screens/Edit/TransactionalCommitComponent.cs b/osu.Game/Screens/Edit/TransactionalCommitComponent.cs new file mode 100644 index 0000000000..3d3539ee2f --- /dev/null +++ b/osu.Game/Screens/Edit/TransactionalCommitComponent.cs @@ -0,0 +1,73 @@ +// 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; + +namespace osu.Game.Screens.Edit +{ + /// + /// A component that tracks a batch change, only applying after all active changes are completed. + /// + public abstract class TransactionalCommitComponent : Component + { + /// + /// Fires whenever a transaction begins. Will not fire on nested transactions. + /// + public event Action TransactionBegan; + + /// + /// Fires when the last transaction completes. + /// + public event Action TransactionEnded; + + /// + /// Fires when is called and results in a non-transactional state save. + /// + public event Action SaveStateTriggered; + + public bool TransactionActive => bulkChangesStarted > 0; + + private int bulkChangesStarted; + + /// + /// Signal the beginning of a change. + /// + public void BeginChange() + { + if (bulkChangesStarted++ == 0) + TransactionBegan?.Invoke(); + } + + /// + /// Signal the end of a change. + /// + /// Throws if was not first called. + public void EndChange() + { + if (bulkChangesStarted == 0) + throw new InvalidOperationException($"Cannot call {nameof(EndChange)} without a previous call to {nameof(BeginChange)}."); + + if (--bulkChangesStarted == 0) + { + UpdateState(); + TransactionEnded?.Invoke(); + } + } + + /// + /// Force an update of the state with no attached transaction. + /// This is a no-op if a transaction is already active. Should generally be used as a safety measure to ensure granular changes are not left outside a transaction. + /// + public void SaveState() + { + if (bulkChangesStarted > 0) + return; + + SaveStateTriggered?.Invoke(); + UpdateState(); + } + + protected abstract void UpdateState(); + } +} diff --git a/osu.Game/Screens/Multi/Lounge/Components/DrawableRoom.cs b/osu.Game/Screens/Multi/Lounge/Components/DrawableRoom.cs index 8dd1b239e8..01a85382e4 100644 --- a/osu.Game/Screens/Multi/Lounge/Components/DrawableRoom.cs +++ b/osu.Game/Screens/Multi/Lounge/Components/DrawableRoom.cs @@ -21,10 +21,12 @@ using osu.Game.Online.Multiplayer; using osu.Game.Screens.Multi.Components; using osuTK; using osuTK.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; namespace osu.Game.Screens.Multi.Lounge.Components { - public class DrawableRoom : OsuClickableContainer, IStateful, IFilterable + public class DrawableRoom : OsuClickableContainer, IStateful, IFilterable, IHasContextMenu { public const float SELECTION_BORDER_WIDTH = 4; private const float corner_radius = 5; @@ -39,6 +41,9 @@ namespace osu.Game.Screens.Multi.Lounge.Components private readonly Box selectionBox; private CachedModelDependencyContainer dependencies; + [Resolved(canBeNull: true)] + private Multiplayer multiplayer { get; set; } + [Resolved] private BeatmapManager beatmaps { get; set; } @@ -232,5 +237,13 @@ namespace osu.Game.Screens.Multi.Lounge.Components Current = name; } } + + public MenuItem[] ContextMenuItems => new MenuItem[] + { + new OsuMenuItem("Create copy", MenuItemType.Standard, () => + { + multiplayer?.CreateRoom(Room.CreateCopy()); + }) + }; } } diff --git a/osu.Game/Screens/Multi/Lounge/Components/RoomsContainer.cs b/osu.Game/Screens/Multi/Lounge/Components/RoomsContainer.cs index 321d7b0a19..60c6aa1d8a 100644 --- a/osu.Game/Screens/Multi/Lounge/Components/RoomsContainer.cs +++ b/osu.Game/Screens/Multi/Lounge/Components/RoomsContainer.cs @@ -17,6 +17,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Online.Multiplayer; using osuTK; +using osu.Game.Graphics.Cursor; namespace osu.Game.Screens.Multi.Lounge.Components { @@ -38,17 +39,25 @@ namespace osu.Game.Screens.Multi.Lounge.Components [Resolved] private IRoomManager roomManager { get; set; } + [Resolved(CanBeNull = true)] + private LoungeSubScreen loungeSubScreen { get; set; } + public RoomsContainer() { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - InternalChild = roomFlow = new FillFlowContainer + InternalChild = new OsuContextMenuContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(2), + Child = roomFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(2), + } }; } diff --git a/osu.Game/Screens/Multi/Lounge/LoungeSubScreen.cs b/osu.Game/Screens/Multi/Lounge/LoungeSubScreen.cs index a1e99c83b2..dd40f4adc6 100644 --- a/osu.Game/Screens/Multi/Lounge/LoungeSubScreen.cs +++ b/osu.Game/Screens/Multi/Lounge/LoungeSubScreen.cs @@ -17,6 +17,7 @@ using osu.Game.Screens.Multi.Match; namespace osu.Game.Screens.Multi.Lounge { + [Cached] public class LoungeSubScreen : MultiplayerSubScreen { public override string Title => "Lounge"; @@ -125,7 +126,7 @@ namespace osu.Game.Screens.Multi.Lounge if (selectedRoom.Value?.RoomID.Value == null) selectedRoom.Value = new Room(); - music.EnsurePlayingSomething(); + music?.EnsurePlayingSomething(); onReturning(); } diff --git a/osu.Game/Screens/Multi/Multiplayer.cs b/osu.Game/Screens/Multi/Multiplayer.cs index 4912df17b1..27f774e9ec 100644 --- a/osu.Game/Screens/Multi/Multiplayer.cs +++ b/osu.Game/Screens/Multi/Multiplayer.cs @@ -51,7 +51,7 @@ namespace osu.Game.Screens.Multi [Cached] private readonly Bindable currentFilter = new Bindable(new FilterCriteria()); - [Resolved] + [Resolved(CanBeNull = true)] private MusicController music { get; set; } [Cached(Type = typeof(IRoomManager))] @@ -134,7 +134,7 @@ namespace osu.Game.Screens.Multi { Anchor = Anchor.TopRight, Origin = Anchor.TopRight, - Action = createRoom + Action = () => CreateRoom() }, roomManager = new RoomManager() } @@ -289,10 +289,11 @@ namespace osu.Game.Screens.Multi logo.Delay(WaveContainer.DISAPPEAR_DURATION / 2).FadeOut(); } - private void createRoom() - { - loungeSubScreen.Open(new Room { Name = { Value = $"{api.LocalUser}'s awesome room" } }); - } + /// + /// Create a new room. + /// + /// An optional template to use when creating the room. + public void CreateRoom(Room room = null) => loungeSubScreen.Open(room ?? new Room { Name = { Value = $"{api.LocalUser}'s awesome room" } }); private void beginHandlingTrack() { @@ -350,7 +351,7 @@ namespace osu.Game.Screens.Multi track.RestartPoint = Beatmap.Value.Metadata.PreviewTime; track.Looping = true; - music.EnsurePlayingSomething(); + music?.EnsurePlayingSomething(); } } else diff --git a/osu.Game/Screens/Play/GameplayClock.cs b/osu.Game/Screens/Play/GameplayClock.cs index eeea6777c6..4d0872e5bb 100644 --- a/osu.Game/Screens/Play/GameplayClock.cs +++ b/osu.Game/Screens/Play/GameplayClock.cs @@ -17,7 +17,7 @@ namespace osu.Game.Screens.Play /// , as this should only be done once to ensure accuracy. /// /// - public class GameplayClock : IFrameBasedClock, ISamplePlaybackDisabler + public class GameplayClock : IFrameBasedClock { private readonly IFrameBasedClock underlyingClock; @@ -28,8 +28,6 @@ namespace osu.Game.Screens.Play /// public virtual IEnumerable> NonGameplayAdjustments => Enumerable.Empty>(); - private readonly Bindable samplePlaybackDisabled = new Bindable(); - public GameplayClock(IFrameBasedClock underlyingClock) { this.underlyingClock = underlyingClock; @@ -66,13 +64,11 @@ namespace osu.Game.Screens.Play /// /// Whether nested samples supporting the interface should be paused. /// - protected virtual bool ShouldDisableSamplePlayback => IsPaused.Value; + public virtual bool ShouldDisableSamplePlayback => IsPaused.Value; public void ProcessFrame() { // intentionally not updating the underlying clock (handled externally). - - samplePlaybackDisabled.Value = ShouldDisableSamplePlayback; } public double ElapsedFrameTime => underlyingClock.ElapsedFrameTime; @@ -82,7 +78,5 @@ namespace osu.Game.Screens.Play public FrameTimeInfo TimeInfo => underlyingClock.TimeInfo; public IClock Source => underlyingClock; - - IBindable ISamplePlaybackDisabler.SamplePlaybackDisabled => samplePlaybackDisabled; } } diff --git a/osu.Game/Screens/Play/GameplayClockContainer.cs b/osu.Game/Screens/Play/GameplayClockContainer.cs index 9f8e55f577..6679e56871 100644 --- a/osu.Game/Screens/Play/GameplayClockContainer.cs +++ b/osu.Game/Screens/Play/GameplayClockContainer.cs @@ -54,7 +54,6 @@ namespace osu.Game.Screens.Play public GameplayClock GameplayClock => localGameplayClock; [Cached(typeof(GameplayClock))] - [Cached(typeof(ISamplePlaybackDisabler))] private readonly LocalGameplayClock localGameplayClock; private Bindable userAudioOffset; diff --git a/osu.Game/Screens/Play/HUD/ComboCounter.cs b/osu.Game/Screens/Play/HUD/ComboCounter.cs deleted file mode 100644 index ea50a4a578..0000000000 --- a/osu.Game/Screens/Play/HUD/ComboCounter.cs +++ /dev/null @@ -1,200 +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 osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics.Sprites; - -namespace osu.Game.Screens.Play.HUD -{ - public abstract class ComboCounter : Container - { - public BindableInt Current = new BindableInt - { - MinValue = 0, - }; - - public bool IsRolling { get; protected set; } - - protected SpriteText PopOutCount; - - protected virtual double PopOutDuration => 150; - protected virtual float PopOutScale => 2.0f; - protected virtual Easing PopOutEasing => Easing.None; - protected virtual float PopOutInitialAlpha => 0.75f; - - protected virtual double FadeOutDuration => 100; - - /// - /// Duration in milliseconds for the counter roll-up animation for each element. - /// - protected virtual double RollingDuration => 20; - - /// - /// Easing for the counter rollover animation. - /// - protected Easing RollingEasing => Easing.None; - - protected SpriteText DisplayedCountSpriteText; - - private int previousValue; - - /// - /// Base of all combo counters. - /// - protected ComboCounter() - { - AutoSizeAxes = Axes.Both; - - Children = new Drawable[] - { - DisplayedCountSpriteText = new OsuSpriteText - { - Alpha = 0, - }, - PopOutCount = new OsuSpriteText - { - Alpha = 0, - Margin = new MarginPadding(0.05f), - } - }; - - TextSize = 80; - - Current.ValueChanged += combo => updateCount(combo.NewValue == 0); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - DisplayedCountSpriteText.Text = FormatCount(Current.Value); - DisplayedCountSpriteText.Anchor = Anchor; - DisplayedCountSpriteText.Origin = Origin; - - StopRolling(); - } - - private int displayedCount; - - /// - /// Value shown at the current moment. - /// - public virtual int DisplayedCount - { - get => displayedCount; - protected set - { - if (displayedCount.Equals(value)) - return; - - updateDisplayedCount(displayedCount, value, IsRolling); - } - } - - private float textSize; - - public float TextSize - { - get => textSize; - set - { - textSize = value; - - DisplayedCountSpriteText.Font = DisplayedCountSpriteText.Font.With(size: TextSize); - PopOutCount.Font = PopOutCount.Font.With(size: TextSize); - } - } - - /// - /// Increments the combo by an amount. - /// - /// - public void Increment(int amount = 1) - { - Current.Value += amount; - } - - /// - /// Stops rollover animation, forcing the displayed count to be the actual count. - /// - public void StopRolling() - { - updateCount(false); - } - - protected virtual string FormatCount(int count) - { - return count.ToString(); - } - - protected virtual void OnCountRolling(int currentValue, int newValue) - { - transformRoll(currentValue, newValue); - } - - protected virtual void OnCountIncrement(int currentValue, int newValue) - { - DisplayedCount = newValue; - } - - protected virtual void OnCountChange(int currentValue, int newValue) - { - DisplayedCount = newValue; - } - - private double getProportionalDuration(int currentValue, int newValue) - { - double difference = currentValue > newValue ? currentValue - newValue : newValue - currentValue; - return difference * RollingDuration; - } - - private void updateDisplayedCount(int currentValue, int newValue, bool rolling) - { - displayedCount = newValue; - if (rolling) - OnDisplayedCountRolling(currentValue, newValue); - else if (currentValue + 1 == newValue) - OnDisplayedCountIncrement(newValue); - else - OnDisplayedCountChange(newValue); - } - - private void updateCount(bool rolling) - { - int prev = previousValue; - previousValue = Current.Value; - - if (!IsLoaded) - return; - - if (!rolling) - { - FinishTransforms(false, nameof(DisplayedCount)); - IsRolling = false; - DisplayedCount = prev; - - if (prev + 1 == Current.Value) - OnCountIncrement(prev, Current.Value); - else - OnCountChange(prev, Current.Value); - } - else - { - OnCountRolling(displayedCount, Current.Value); - IsRolling = true; - } - } - - private void transformRoll(int currentValue, int newValue) - { - this.TransformTo(nameof(DisplayedCount), newValue, getProportionalDuration(currentValue, newValue), RollingEasing); - } - - protected abstract void OnDisplayedCountRolling(int currentValue, int newValue); - protected abstract void OnDisplayedCountIncrement(int newValue); - protected abstract void OnDisplayedCountChange(int newValue); - } -} diff --git a/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs new file mode 100644 index 0000000000..d5d8ec570a --- /dev/null +++ b/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.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 osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Graphics.UserInterface; +using osuTK; + +namespace osu.Game.Screens.Play.HUD +{ + public class DefaultAccuracyCounter : PercentageCounter, IAccuracyCounter + { + private readonly Vector2 offset = new Vector2(-20, 5); + + public DefaultAccuracyCounter() + { + Origin = Anchor.TopRight; + Anchor = Anchor.TopRight; + } + + [Resolved(canBeNull: true)] + private HUDOverlay hud { get; set; } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Colour = colours.BlueLighter; + } + + protected override void Update() + { + base.Update(); + + if (hud?.ScoreCounter.Drawable is DefaultScoreCounter score) + { + // for now align with the score counter. eventually this will be user customisable. + Anchor = Anchor.TopLeft; + Position = Parent.ToLocalSpace(score.ScreenSpaceDrawQuad.TopLeft) + offset; + } + } + } +} diff --git a/osu.Game/Graphics/UserInterface/SimpleComboCounter.cs b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs similarity index 54% rename from osu.Game/Graphics/UserInterface/SimpleComboCounter.cs rename to osu.Game/Screens/Play/HUD/DefaultComboCounter.cs index c9790aed46..63e7a88550 100644 --- a/osu.Game/Graphics/UserInterface/SimpleComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs @@ -4,18 +4,21 @@ using System; using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK; -namespace osu.Game.Graphics.UserInterface +namespace osu.Game.Screens.Play.HUD { - /// - /// Used as an accuracy counter. Represented visually as a percentage. - /// - public class SimpleComboCounter : RollingCounter + public class DefaultComboCounter : RollingCounter, IComboCounter { - protected override double RollingDuration => 750; + private readonly Vector2 offset = new Vector2(20, 5); - public SimpleComboCounter() + [Resolved(canBeNull: true)] + private HUDOverlay hud { get; set; } + + public DefaultComboCounter() { Current.Value = DisplayedCount = 0; } @@ -23,6 +26,17 @@ namespace osu.Game.Graphics.UserInterface [BackgroundDependencyLoader] private void load(OsuColour colours) => Colour = colours.BlueLighter; + protected override void Update() + { + base.Update(); + + if (hud?.ScoreCounter.Drawable is DefaultScoreCounter score) + { + // for now align with the score counter. eventually this will be user customisable. + Position = Parent.ToLocalSpace(score.ScreenSpaceDrawQuad.TopRight) + offset; + } + } + protected override string FormatCount(int count) { return $@"{count}x"; diff --git a/osu.Game/Screens/Play/HUD/StandardHealthDisplay.cs b/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs similarity index 91% rename from osu.Game/Screens/Play/HUD/StandardHealthDisplay.cs rename to osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs index fc4a1a5d83..b550b469e9 100644 --- a/osu.Game/Screens/Play/HUD/StandardHealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/DefaultHealthDisplay.cs @@ -16,7 +16,7 @@ using osu.Framework.Utils; namespace osu.Game.Screens.Play.HUD { - public class StandardHealthDisplay : HealthDisplay, IHasAccentColour + public class DefaultHealthDisplay : HealthDisplay, IHasAccentColour { /// /// The base opacity of the glow. @@ -71,8 +71,12 @@ namespace osu.Game.Screens.Play.HUD } } - public StandardHealthDisplay() + public DefaultHealthDisplay() { + Size = new Vector2(1, 5); + RelativeSizeAxes = Axes.X; + Margin = new MarginPadding { Top = 20 }; + Children = new Drawable[] { new Box @@ -103,13 +107,7 @@ namespace osu.Game.Screens.Play.HUD GlowColour = colours.BlueDarker; } - public void Flash(JudgementResult result) - { - if (!result.IsHit) - return; - - Scheduler.AddOnce(flash); - } + public override void Flash(JudgementResult result) => Scheduler.AddOnce(flash); private void flash() { diff --git a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs new file mode 100644 index 0000000000..1dcfe2e067 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs @@ -0,0 +1,35 @@ +// 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.Game.Graphics; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Screens.Play.HUD +{ + public class DefaultScoreCounter : ScoreCounter + { + public DefaultScoreCounter() + : base(6) + { + Anchor = Anchor.TopCentre; + Origin = Anchor.TopCentre; + } + + [Resolved(canBeNull: true)] + private HUDOverlay hud { get; set; } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Colour = colours.BlueLighter; + + // todo: check if default once health display is skinnable + hud?.ShowHealthbar.BindValueChanged(healthBar => + { + this.MoveToY(healthBar.NewValue ? 30 : 0, HUDOverlay.FADE_DURATION, HUDOverlay.FADE_EASING); + }, true); + } + } +} diff --git a/osu.Game/Screens/Play/HUD/HealthDisplay.cs b/osu.Game/Screens/Play/HUD/HealthDisplay.cs index edc9dedf24..5c43e00192 100644 --- a/osu.Game/Screens/Play/HUD/HealthDisplay.cs +++ b/osu.Game/Screens/Play/HUD/HealthDisplay.cs @@ -3,6 +3,7 @@ using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; @@ -12,14 +13,18 @@ namespace osu.Game.Screens.Play.HUD /// A container for components displaying the current player health. /// Gets bound automatically to the when inserted to hierarchy. /// - public abstract class HealthDisplay : Container + public abstract class HealthDisplay : Container, IHealthDisplay { - public readonly BindableDouble Current = new BindableDouble(1) + public Bindable Current { get; } = new BindableDouble(1) { MinValue = 0, MaxValue = 1 }; + public virtual void Flash(JudgementResult result) + { + } + /// /// Bind the tracked fields of to this health display. /// diff --git a/osu.Game/Screens/Play/HUD/HitErrorDisplay.cs b/osu.Game/Screens/Play/HUD/HitErrorDisplay.cs index 4d28f00f39..37d10a5320 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorDisplay.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorDisplay.cs @@ -66,54 +66,69 @@ namespace osu.Game.Screens.Play.HUD switch (type.NewValue) { case ScoreMeterType.HitErrorBoth: - createBar(false); - createBar(true); + createBar(Anchor.CentreLeft); + createBar(Anchor.CentreRight); break; case ScoreMeterType.HitErrorLeft: - createBar(false); + createBar(Anchor.CentreLeft); break; case ScoreMeterType.HitErrorRight: - createBar(true); + createBar(Anchor.CentreRight); + break; + + case ScoreMeterType.HitErrorBottom: + createBar(Anchor.BottomCentre); break; case ScoreMeterType.ColourBoth: - createColour(false); - createColour(true); + createColour(Anchor.CentreLeft); + createColour(Anchor.CentreRight); break; case ScoreMeterType.ColourLeft: - createColour(false); + createColour(Anchor.CentreLeft); break; case ScoreMeterType.ColourRight: - createColour(true); + createColour(Anchor.CentreRight); + break; + + case ScoreMeterType.ColourBottom: + createColour(Anchor.BottomCentre); break; } } - private void createBar(bool rightAligned) + private void createBar(Anchor anchor) { + bool rightAligned = (anchor & Anchor.x2) > 0; + bool bottomAligned = (anchor & Anchor.y2) > 0; + var display = new BarHitErrorMeter(hitWindows, rightAligned) { Margin = new MarginPadding(margin), - Anchor = rightAligned ? Anchor.CentreRight : Anchor.CentreLeft, - Origin = rightAligned ? Anchor.CentreRight : Anchor.CentreLeft, + Anchor = anchor, + Origin = bottomAligned ? Anchor.CentreLeft : anchor, Alpha = 0, + Rotation = bottomAligned ? 270 : 0 }; completeDisplayLoading(display); } - private void createColour(bool rightAligned) + private void createColour(Anchor anchor) { + bool bottomAligned = (anchor & Anchor.y2) > 0; + var display = new ColourHitErrorMeter(hitWindows) { Margin = new MarginPadding(margin), - Anchor = rightAligned ? Anchor.CentreRight : Anchor.CentreLeft, - Origin = rightAligned ? Anchor.CentreRight : Anchor.CentreLeft, + Anchor = anchor, + Origin = bottomAligned ? Anchor.CentreLeft : anchor, Alpha = 0, + Rotation = bottomAligned ? 270 : 0 }; completeDisplayLoading(display); diff --git a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs index f99c84fc01..89f135de7f 100644 --- a/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs +++ b/osu.Game/Screens/Play/HUD/HitErrorMeters/BarHitErrorMeter.cs @@ -99,7 +99,9 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters Size = new Vector2(10), Icon = FontAwesome.Solid.ShippingFast, Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, + Origin = Anchor.Centre, + // undo any layout rotation to display the icon the correct orientation + Rotation = -Rotation, }, new SpriteIcon { @@ -107,7 +109,9 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters Size = new Vector2(10), Icon = FontAwesome.Solid.Bicycle, Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, + Origin = Anchor.Centre, + // undo any layout rotation to display the icon the correct orientation + Rotation = -Rotation, } } }, diff --git a/osu.Game/Screens/Play/HUD/IAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/IAccuracyCounter.cs new file mode 100644 index 0000000000..0199250a08 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/IAccuracyCounter.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; + +namespace osu.Game.Screens.Play.HUD +{ + /// + /// An interface providing a set of methods to update a accuracy counter. + /// + public interface IAccuracyCounter : IDrawable + { + /// + /// The current accuracy to be displayed. + /// + Bindable Current { get; } + } +} diff --git a/osu.Game/Screens/Play/HUD/IComboCounter.cs b/osu.Game/Screens/Play/HUD/IComboCounter.cs new file mode 100644 index 0000000000..ff235bf04e --- /dev/null +++ b/osu.Game/Screens/Play/HUD/IComboCounter.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; + +namespace osu.Game.Screens.Play.HUD +{ + /// + /// An interface providing a set of methods to update a combo counter. + /// + public interface IComboCounter : IDrawable + { + /// + /// The current combo to be displayed. + /// + Bindable Current { get; } + } +} diff --git a/osu.Game/Screens/Play/HUD/IHealthDisplay.cs b/osu.Game/Screens/Play/HUD/IHealthDisplay.cs new file mode 100644 index 0000000000..b1a64bd844 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/IHealthDisplay.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Rulesets.Judgements; + +namespace osu.Game.Screens.Play.HUD +{ + /// + /// An interface providing a set of methods to update a health display. + /// + public interface IHealthDisplay : IDrawable + { + /// + /// The current health to be displayed. + /// + Bindable Current { get; } + + /// + /// Flash the display for a specified result type. + /// + /// The result type. + void Flash(JudgementResult result); + } +} diff --git a/osu.Game/Screens/Play/HUD/IScoreCounter.cs b/osu.Game/Screens/Play/HUD/IScoreCounter.cs new file mode 100644 index 0000000000..7f5e81d5ef --- /dev/null +++ b/osu.Game/Screens/Play/HUD/IScoreCounter.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; + +namespace osu.Game.Screens.Play.HUD +{ + /// + /// An interface providing a set of methods to update a score counter. + /// + public interface IScoreCounter : IDrawable + { + /// + /// The current score to be displayed. + /// + Bindable Current { get; } + + /// + /// The number of digits required to display most sane scores. + /// This may be exceeded in very rare cases, but is useful to pad or space the display to avoid it jumping around. + /// + Bindable RequiredDisplayDigits { get; } + } +} diff --git a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs new file mode 100644 index 0000000000..4784bca7dd --- /dev/null +++ b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs @@ -0,0 +1,252 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics.Sprites; +using osu.Game.Skinning; +using osuTK; + +namespace osu.Game.Screens.Play.HUD +{ + /// + /// Uses the 'x' symbol and has a pop-out effect while rolling over. + /// + public class LegacyComboCounter : CompositeDrawable, IComboCounter + { + public Bindable Current { get; } = new BindableInt { MinValue = 0, }; + + private uint scheduledPopOutCurrentId; + + private const double pop_out_duration = 150; + + private const Easing pop_out_easing = Easing.None; + + private const double fade_out_duration = 100; + + /// + /// Duration in milliseconds for the counter roll-up animation for each element. + /// + private const double rolling_duration = 20; + + private Drawable popOutCount; + + private Drawable displayedCountSpriteText; + + private int previousValue; + + private int displayedCount; + + private bool isRolling; + + [Resolved] + private ISkinSource skin { get; set; } + + public LegacyComboCounter() + { + AutoSizeAxes = Axes.Both; + + Anchor = Anchor.BottomLeft; + Origin = Anchor.BottomLeft; + + Margin = new MarginPadding(10); + + Scale = new Vector2(1.2f); + } + + /// + /// Value shown at the current moment. + /// + public virtual int DisplayedCount + { + get => displayedCount; + private set + { + if (displayedCount.Equals(value)) + return; + + if (isRolling) + onDisplayedCountRolling(displayedCount, value); + else if (displayedCount + 1 == value) + onDisplayedCountIncrement(value); + else + onDisplayedCountChange(value); + + displayedCount = value; + } + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new[] + { + displayedCountSpriteText = createSpriteText().With(s => + { + s.Alpha = 0; + }), + popOutCount = createSpriteText().With(s => + { + s.Alpha = 0; + s.Margin = new MarginPadding(0.05f); + }) + }; + + Current.ValueChanged += combo => updateCount(combo.NewValue == 0); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ((IHasText)displayedCountSpriteText).Text = formatCount(Current.Value); + + displayedCountSpriteText.Anchor = Anchor; + displayedCountSpriteText.Origin = Origin; + popOutCount.Origin = Origin; + popOutCount.Anchor = Anchor; + + updateCount(false); + } + + private void updateCount(bool rolling) + { + int prev = previousValue; + previousValue = Current.Value; + + if (!IsLoaded) + return; + + if (!rolling) + { + FinishTransforms(false, nameof(DisplayedCount)); + isRolling = false; + DisplayedCount = prev; + + if (prev + 1 == Current.Value) + onCountIncrement(prev, Current.Value); + else + onCountChange(prev, Current.Value); + } + else + { + onCountRolling(displayedCount, Current.Value); + isRolling = true; + } + } + + private void transformPopOut(int newValue) + { + ((IHasText)popOutCount).Text = formatCount(newValue); + + popOutCount.ScaleTo(1.6f); + popOutCount.FadeTo(0.75f); + popOutCount.MoveTo(Vector2.Zero); + + popOutCount.ScaleTo(1, pop_out_duration, pop_out_easing); + popOutCount.FadeOut(pop_out_duration, pop_out_easing); + popOutCount.MoveTo(displayedCountSpriteText.Position, pop_out_duration, pop_out_easing); + } + + private void transformNoPopOut(int newValue) + { + ((IHasText)displayedCountSpriteText).Text = formatCount(newValue); + + displayedCountSpriteText.ScaleTo(1); + } + + private void transformPopOutSmall(int newValue) + { + ((IHasText)displayedCountSpriteText).Text = formatCount(newValue); + displayedCountSpriteText.ScaleTo(1.1f); + displayedCountSpriteText.ScaleTo(1, pop_out_duration, pop_out_easing); + } + + private void scheduledPopOutSmall(uint id) + { + // Too late; scheduled task invalidated + if (id != scheduledPopOutCurrentId) + return; + + DisplayedCount++; + } + + private void onCountIncrement(int currentValue, int newValue) + { + scheduledPopOutCurrentId++; + + if (DisplayedCount < currentValue) + DisplayedCount++; + + displayedCountSpriteText.Show(); + + transformPopOut(newValue); + + uint newTaskId = scheduledPopOutCurrentId; + + Scheduler.AddDelayed(delegate + { + scheduledPopOutSmall(newTaskId); + }, pop_out_duration); + } + + private void onCountRolling(int currentValue, int newValue) + { + scheduledPopOutCurrentId++; + + // Hides displayed count if was increasing from 0 to 1 but didn't finish + if (currentValue == 0 && newValue == 0) + displayedCountSpriteText.FadeOut(fade_out_duration); + + transformRoll(currentValue, newValue); + } + + private void onCountChange(int currentValue, int newValue) + { + scheduledPopOutCurrentId++; + + if (newValue == 0) + displayedCountSpriteText.FadeOut(); + + DisplayedCount = newValue; + } + + private void onDisplayedCountRolling(int currentValue, int newValue) + { + if (newValue == 0) + displayedCountSpriteText.FadeOut(fade_out_duration); + else + displayedCountSpriteText.Show(); + + transformNoPopOut(newValue); + } + + private void onDisplayedCountChange(int newValue) + { + displayedCountSpriteText.FadeTo(newValue == 0 ? 0 : 1); + transformNoPopOut(newValue); + } + + private void onDisplayedCountIncrement(int newValue) + { + displayedCountSpriteText.Show(); + transformPopOutSmall(newValue); + } + + private void transformRoll(int currentValue, int newValue) => + this.TransformTo(nameof(DisplayedCount), newValue, getProportionalDuration(currentValue, newValue), Easing.None); + + private string formatCount(int count) => $@"{count}x"; + + private double getProportionalDuration(int currentValue, int newValue) + { + double difference = currentValue > newValue ? currentValue - newValue : newValue - currentValue; + return difference * rolling_duration; + } + + private OsuSpriteText createSpriteText() => (OsuSpriteText)skin.GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.ComboText)); + } +} diff --git a/osu.Game/Screens/Play/HUD/ModDisplay.cs b/osu.Game/Screens/Play/HUD/ModDisplay.cs index 99c31241f1..68d019bf71 100644 --- a/osu.Game/Screens/Play/HUD/ModDisplay.cs +++ b/osu.Game/Screens/Play/HUD/ModDisplay.cs @@ -48,22 +48,29 @@ namespace osu.Game.Screens.Play.HUD { AutoSizeAxes = Axes.Both; - Children = new Drawable[] + Child = new FillFlowContainer { - iconsContainer = new ReverseChildIDFillFlowContainer + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, + iconsContainer = new ReverseChildIDFillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + }, + unrankedText = new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = @"/ UNRANKED /", + Font = OsuFont.Numeric.With(size: 12) + } }, - unrankedText = new OsuSpriteText - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.TopCentre, - Text = @"/ UNRANKED /", - Font = OsuFont.Numeric.With(size: 12) - } }; Current.ValueChanged += mods => diff --git a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs index fc80983834..ffcbb06fb3 100644 --- a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs +++ b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs @@ -20,14 +20,13 @@ namespace osu.Game.Screens.Play.HUD public readonly VisualSettings VisualSettings; - //public readonly CollectionSettings CollectionSettings; - - //public readonly DiscussionSettings DiscussionSettings; - public PlayerSettingsOverlay() { AlwaysPresent = true; - RelativeSizeAxes = Axes.Both; + + Anchor = Anchor.TopRight; + Origin = Anchor.TopRight; + AutoSizeAxes = Axes.Both; Child = new FillFlowContainer { @@ -36,7 +35,6 @@ namespace osu.Game.Screens.Play.HUD AutoSizeAxes = Axes.Both, Direction = FillDirection.Vertical, Spacing = new Vector2(0, 20), - Margin = new MarginPadding { Top = 100, Right = 10 }, Children = new PlayerSettingsGroup[] { //CollectionSettings = new CollectionSettings(), diff --git a/osu.Game/Screens/Play/HUD/SkinnableAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/SkinnableAccuracyCounter.cs new file mode 100644 index 0000000000..76c9c30813 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/SkinnableAccuracyCounter.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Game.Skinning; + +namespace osu.Game.Screens.Play.HUD +{ + public class SkinnableAccuracyCounter : SkinnableDrawable, IAccuracyCounter + { + public Bindable Current { get; } = new Bindable(); + + public SkinnableAccuracyCounter() + : base(new HUDSkinComponent(HUDSkinComponents.AccuracyCounter), _ => new DefaultAccuracyCounter()) + { + CentreComponent = false; + } + + private IAccuracyCounter skinnedCounter; + + protected override void SkinChanged(ISkinSource skin, bool allowFallback) + { + base.SkinChanged(skin, allowFallback); + + skinnedCounter = Drawable as IAccuracyCounter; + skinnedCounter?.Current.BindTo(Current); + } + } +} diff --git a/osu.Game/Screens/Play/HUD/SkinnableComboCounter.cs b/osu.Game/Screens/Play/HUD/SkinnableComboCounter.cs new file mode 100644 index 0000000000..c04c50141a --- /dev/null +++ b/osu.Game/Screens/Play/HUD/SkinnableComboCounter.cs @@ -0,0 +1,29 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Game.Skinning; + +namespace osu.Game.Screens.Play.HUD +{ + public class SkinnableComboCounter : SkinnableDrawable, IComboCounter + { + public Bindable Current { get; } = new Bindable(); + + public SkinnableComboCounter() + : base(new HUDSkinComponent(HUDSkinComponents.ComboCounter), skinComponent => new DefaultComboCounter()) + { + CentreComponent = false; + } + + private IComboCounter skinnedCounter; + + protected override void SkinChanged(ISkinSource skin, bool allowFallback) + { + base.SkinChanged(skin, allowFallback); + + skinnedCounter = Drawable as IComboCounter; + skinnedCounter?.Current.BindTo(Current); + } + } +} diff --git a/osu.Game/Screens/Play/HUD/SkinnableScoreCounter.cs b/osu.Game/Screens/Play/HUD/SkinnableScoreCounter.cs new file mode 100644 index 0000000000..b46f5684b1 --- /dev/null +++ b/osu.Game/Screens/Play/HUD/SkinnableScoreCounter.cs @@ -0,0 +1,61 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Game.Configuration; +using osu.Game.Rulesets.Scoring; +using osu.Game.Skinning; + +namespace osu.Game.Screens.Play.HUD +{ + public class SkinnableScoreCounter : SkinnableDrawable, IScoreCounter + { + public Bindable Current { get; } = new Bindable(); + + private Bindable scoreDisplayMode; + + public Bindable RequiredDisplayDigits { get; } = new Bindable(); + + public SkinnableScoreCounter() + : base(new HUDSkinComponent(HUDSkinComponents.ScoreCounter), _ => new DefaultScoreCounter()) + { + CentreComponent = false; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + scoreDisplayMode = config.GetBindable(OsuSetting.ScoreDisplayMode); + scoreDisplayMode.BindValueChanged(scoreMode => + { + switch (scoreMode.NewValue) + { + case ScoringMode.Standardised: + RequiredDisplayDigits.Value = 6; + break; + + case ScoringMode.Classic: + RequiredDisplayDigits.Value = 8; + break; + + default: + throw new ArgumentOutOfRangeException(nameof(scoreMode)); + } + }, true); + } + + private IScoreCounter skinnedCounter; + + protected override void SkinChanged(ISkinSource skin, bool allowFallback) + { + base.SkinChanged(skin, allowFallback); + + skinnedCounter = Drawable as IScoreCounter; + + skinnedCounter?.Current.BindTo(Current); + skinnedCounter?.RequiredDisplayDigits.BindTo(RequiredDisplayDigits); + } + } +} diff --git a/osu.Game/Screens/Play/HUD/StandardComboCounter.cs b/osu.Game/Screens/Play/HUD/StandardComboCounter.cs deleted file mode 100644 index 7301300b8d..0000000000 --- a/osu.Game/Screens/Play/HUD/StandardComboCounter.cs +++ /dev/null @@ -1,141 +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 osuTK; -using osu.Framework.Graphics; - -namespace osu.Game.Screens.Play.HUD -{ - /// - /// Uses the 'x' symbol and has a pop-out effect while rolling over. - /// - public class StandardComboCounter : ComboCounter - { - protected uint ScheduledPopOutCurrentId; - - protected virtual float PopOutSmallScale => 1.1f; - protected virtual bool CanPopOutWhileRolling => false; - - public new Vector2 PopOutScale = new Vector2(1.6f); - - protected override void LoadComplete() - { - base.LoadComplete(); - - PopOutCount.Origin = Origin; - PopOutCount.Anchor = Anchor; - } - - protected override string FormatCount(int count) - { - return $@"{count}x"; - } - - protected virtual void TransformPopOut(int newValue) - { - PopOutCount.Text = FormatCount(newValue); - - PopOutCount.ScaleTo(PopOutScale); - PopOutCount.FadeTo(PopOutInitialAlpha); - PopOutCount.MoveTo(Vector2.Zero); - - PopOutCount.ScaleTo(1, PopOutDuration, PopOutEasing); - PopOutCount.FadeOut(PopOutDuration, PopOutEasing); - PopOutCount.MoveTo(DisplayedCountSpriteText.Position, PopOutDuration, PopOutEasing); - } - - protected virtual void TransformPopOutRolling(int newValue) - { - TransformPopOut(newValue); - TransformPopOutSmall(newValue); - } - - protected virtual void TransformNoPopOut(int newValue) - { - DisplayedCountSpriteText.Text = FormatCount(newValue); - DisplayedCountSpriteText.ScaleTo(1); - } - - protected virtual void TransformPopOutSmall(int newValue) - { - DisplayedCountSpriteText.Text = FormatCount(newValue); - DisplayedCountSpriteText.ScaleTo(PopOutSmallScale); - DisplayedCountSpriteText.ScaleTo(1, PopOutDuration, PopOutEasing); - } - - protected virtual void ScheduledPopOutSmall(uint id) - { - // Too late; scheduled task invalidated - if (id != ScheduledPopOutCurrentId) - return; - - DisplayedCount++; - } - - protected override void OnCountRolling(int currentValue, int newValue) - { - ScheduledPopOutCurrentId++; - - // Hides displayed count if was increasing from 0 to 1 but didn't finish - if (currentValue == 0 && newValue == 0) - DisplayedCountSpriteText.FadeOut(FadeOutDuration); - - base.OnCountRolling(currentValue, newValue); - } - - protected override void OnCountIncrement(int currentValue, int newValue) - { - ScheduledPopOutCurrentId++; - - if (DisplayedCount < currentValue) - DisplayedCount++; - - DisplayedCountSpriteText.Show(); - - TransformPopOut(newValue); - - uint newTaskId = ScheduledPopOutCurrentId; - Scheduler.AddDelayed(delegate - { - ScheduledPopOutSmall(newTaskId); - }, PopOutDuration); - } - - protected override void OnCountChange(int currentValue, int newValue) - { - ScheduledPopOutCurrentId++; - - if (newValue == 0) - DisplayedCountSpriteText.FadeOut(); - - base.OnCountChange(currentValue, newValue); - } - - protected override void OnDisplayedCountRolling(int currentValue, int newValue) - { - if (newValue == 0) - DisplayedCountSpriteText.FadeOut(FadeOutDuration); - else - DisplayedCountSpriteText.Show(); - - if (CanPopOutWhileRolling) - TransformPopOutRolling(newValue); - else - TransformNoPopOut(newValue); - } - - protected override void OnDisplayedCountChange(int newValue) - { - DisplayedCountSpriteText.FadeTo(newValue == 0 ? 0 : 1); - - TransformNoPopOut(newValue); - } - - protected override void OnDisplayedCountIncrement(int newValue) - { - DisplayedCountSpriteText.Show(); - - TransformPopOutSmall(newValue); - } - } -} diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index 26aefa138b..c3de249bf8 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -10,28 +10,30 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; using osu.Game.Configuration; -using osu.Game.Graphics.UserInterface; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; using osuTK; using osuTK.Input; namespace osu.Game.Screens.Play { + [Cached] public class HUDOverlay : Container { - private const float fade_duration = 400; - private const Easing fade_easing = Easing.Out; + public const float FADE_DURATION = 400; + + public const Easing FADE_EASING = Easing.Out; public readonly KeyCounterDisplay KeyCounter; - public readonly RollingCounter ComboCounter; - public readonly ScoreCounter ScoreCounter; - public readonly RollingCounter AccuracyCounter; - public readonly HealthDisplay HealthDisplay; + public readonly SkinnableComboCounter ComboCounter; + public readonly SkinnableScoreCounter ScoreCounter; + public readonly SkinnableAccuracyCounter AccuracyCounter; + public readonly SkinnableHealthDisplay HealthDisplay; public readonly SongProgress Progress; public readonly ModDisplay ModDisplay; public readonly HitErrorDisplay HitErrorDisplay; @@ -61,7 +63,10 @@ namespace osu.Game.Screens.Play public Action RequestSeek; - private readonly Container topScoreContainer; + private readonly FillFlowContainer bottomRightElements; + private readonly FillFlowContainer topRightElements; + + private readonly Container mainUIElements; private IEnumerable hideTargets => new Drawable[] { visibilityContainer, KeyCounter }; @@ -80,35 +85,61 @@ namespace osu.Game.Screens.Play visibilityContainer = new Container { RelativeSizeAxes = Axes.Both, + Child = new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] + { + new Drawable[] + { + mainUIElements = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + HealthDisplay = CreateHealthDisplay(), + AccuracyCounter = CreateAccuracyCounter(), + ScoreCounter = CreateScoreCounter(), + ComboCounter = CreateComboCounter(), + HitErrorDisplay = CreateHitErrorDisplayOverlay(), + } + }, + }, + new Drawable[] + { + Progress = CreateProgress(), + } + }, + RowDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + } + }, + }, + topRightElements = new FillFlowContainer + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Margin = new MarginPadding(10), + Spacing = new Vector2(10), + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, Children = new Drawable[] { - HealthDisplay = CreateHealthDisplay(), - topScoreContainer = new Container - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - AutoSizeAxes = Axes.Both, - Children = new Drawable[] - { - AccuracyCounter = CreateAccuracyCounter(), - ScoreCounter = CreateScoreCounter(), - ComboCounter = CreateComboCounter(), - }, - }, - Progress = CreateProgress(), ModDisplay = CreateModsContainer(), - HitErrorDisplay = CreateHitErrorDisplayOverlay(), PlayerSettingsOverlay = CreatePlayerSettingsOverlay(), } }, - new FillFlowContainer + bottomRightElements = new FillFlowContainer { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, - Position = -new Vector2(5, TwoLayerButton.SIZE_RETRACTED.Y), + Margin = new MarginPadding(10), + Spacing = new Vector2(10), AutoSizeAxes = Axes.Both, - LayoutDuration = fade_duration / 2, - LayoutEasing = fade_easing, + LayoutDuration = FADE_DURATION / 2, + LayoutEasing = FADE_EASING, Direction = FillDirection.Vertical, Children = new Drawable[] { @@ -161,21 +192,8 @@ namespace osu.Game.Screens.Play { base.LoadComplete(); - ShowHud.BindValueChanged(visible => hideTargets.ForEach(d => d.FadeTo(visible.NewValue ? 1 : 0, fade_duration, fade_easing))); - - ShowHealthbar.BindValueChanged(healthBar => - { - if (healthBar.NewValue) - { - HealthDisplay.FadeIn(fade_duration, fade_easing); - topScoreContainer.MoveToY(30, fade_duration, fade_easing); - } - else - { - HealthDisplay.FadeOut(fade_duration, fade_easing); - topScoreContainer.MoveToY(0, fade_duration, fade_easing); - } - }, true); + ShowHealthbar.BindValueChanged(healthBar => HealthDisplay.FadeTo(healthBar.NewValue ? 1 : 0, FADE_DURATION, FADE_EASING), true); + ShowHud.BindValueChanged(visible => hideTargets.ForEach(d => d.FadeTo(visible.NewValue ? 1 : 0, FADE_DURATION, FADE_EASING))); configShowHud.BindValueChanged(visible => { @@ -186,6 +204,24 @@ namespace osu.Game.Screens.Play replayLoaded.BindValueChanged(replayLoadedValueChanged, true); } + protected override void Update() + { + base.Update(); + + float topRightOffset = 0; + + // fetch the bottom-most position of any main ui element that is anchored to the top of the screen. + // consider this kind of temporary. + foreach (var d in mainUIElements) + { + if (d is SkinnableDrawable sd && (sd.Drawable.Anchor & Anchor.y0) > 0) + topRightOffset = Math.Max(sd.Drawable.ScreenSpaceDrawQuad.BottomRight.Y, topRightOffset); + } + + topRightElements.Y = ToLocalSpace(new Vector2(0, topRightOffset)).Y; + bottomRightElements.Y = -Progress.Height; + } + private void replayLoadedValueChanged(ValueChangedEvent e) { PlayerSettingsOverlay.ReplayLoaded = e.NewValue; @@ -230,34 +266,13 @@ namespace osu.Game.Screens.Play return base.OnKeyDown(e); } - protected virtual RollingCounter CreateAccuracyCounter() => new PercentageCounter - { - BypassAutoSizeAxes = Axes.X, - Anchor = Anchor.TopLeft, - Origin = Anchor.TopRight, - Margin = new MarginPadding { Top = 5, Right = 20 }, - }; + protected virtual SkinnableAccuracyCounter CreateAccuracyCounter() => new SkinnableAccuracyCounter(); - protected virtual ScoreCounter CreateScoreCounter() => new ScoreCounter(6) - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - }; + protected virtual SkinnableScoreCounter CreateScoreCounter() => new SkinnableScoreCounter(); - protected virtual RollingCounter CreateComboCounter() => new SimpleComboCounter - { - BypassAutoSizeAxes = Axes.X, - Anchor = Anchor.TopRight, - Origin = Anchor.TopLeft, - Margin = new MarginPadding { Top = 5, Left = 20 }, - }; + protected virtual SkinnableComboCounter CreateComboCounter() => new SkinnableComboCounter(); - protected virtual HealthDisplay CreateHealthDisplay() => new StandardHealthDisplay - { - Size = new Vector2(1, 5), - RelativeSizeAxes = Axes.X, - Margin = new MarginPadding { Top = 20 } - }; + protected virtual SkinnableHealthDisplay CreateHealthDisplay() => new SkinnableHealthDisplay(); protected virtual FailingLayer CreateFailingLayer() => new FailingLayer { @@ -268,7 +283,6 @@ namespace osu.Game.Screens.Play { Anchor = Anchor.BottomRight, Origin = Anchor.BottomRight, - Margin = new MarginPadding(10), }; protected virtual SongProgress CreateProgress() => new SongProgress @@ -289,7 +303,6 @@ namespace osu.Game.Screens.Play Anchor = Anchor.TopRight, Origin = Anchor.TopRight, AutoSizeAxes = Axes.Both, - Margin = new MarginPadding { Top = 20, Right = 20 }, }; protected virtual HitErrorDisplay CreateHitErrorDisplayOverlay() => new HitErrorDisplay(scoreProcessor, drawableRuleset?.FirstAvailableHitWindows); @@ -302,8 +315,14 @@ namespace osu.Game.Screens.Play AccuracyCounter?.Current.BindTo(processor.Accuracy); ComboCounter?.Current.BindTo(processor.Combo); - if (HealthDisplay is StandardHealthDisplay shd) - processor.NewJudgement += shd.Flash; + if (HealthDisplay is IHealthDisplay shd) + { + processor.NewJudgement += judgement => + { + if (judgement.IsHit && judgement.Type != HitResult.IgnoreHit) + shd.Flash(judgement); + }; + } } protected virtual void BindHealthProcessor(HealthProcessor processor) diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 175722c44e..df0a52a0e8 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -35,7 +35,8 @@ using osu.Game.Users; namespace osu.Game.Screens.Play { [Cached] - public class Player : ScreenWithBeatmapBackground + [Cached(typeof(ISamplePlaybackDisabler))] + public class Player : ScreenWithBeatmapBackground, ISamplePlaybackDisabler { /// /// The delay upon completion of the beatmap before displaying the results screen. @@ -55,6 +56,8 @@ namespace osu.Game.Screens.Play // We are managing our own adjustments (see OnEntering/OnExiting). public override bool AllowRateAdjustments => false; + private readonly Bindable samplePlaybackDisabled = new Bindable(); + /// /// Whether gameplay should pause when the game window focus is lost. /// @@ -68,6 +71,8 @@ namespace osu.Game.Screens.Play private readonly Bindable storyboardReplacesBackground = new Bindable(); + protected readonly Bindable LocalUserPlaying = new Bindable(); + public int RestartCount; [Resolved] @@ -87,6 +92,11 @@ namespace osu.Game.Screens.Play public BreakOverlay BreakOverlay; + /// + /// Whether the gameplay is currently in a break. + /// + public readonly IBindable IsBreakTime = new BindableBool(); + private BreakTracker breakTracker; private SkipOverlay skipOverlay; @@ -155,8 +165,8 @@ namespace osu.Game.Screens.Play DrawableRuleset.SetRecordTarget(recordingReplay = new Replay()); } - [BackgroundDependencyLoader] - private void load(AudioManager audio, OsuConfigManager config) + [BackgroundDependencyLoader(true)] + private void load(AudioManager audio, OsuConfigManager config, OsuGame game) { Mods.Value = base.Mods.Value.Select(m => m.CreateCopy()).ToArray(); @@ -172,6 +182,9 @@ namespace osu.Game.Screens.Play mouseWheelDisabled = config.GetBindable(OsuSetting.MouseDisableWheel); + if (game != null) + LocalUserPlaying.BindTo(game.LocalUserPlaying); + DrawableRuleset = ruleset.CreateDrawableRulesetWith(playableBeatmap, Mods.Value); ScoreProcessor = ruleset.CreateScoreProcessor(); @@ -208,8 +221,12 @@ namespace osu.Game.Screens.Play createGameplayComponents(Beatmap.Value, playableBeatmap) }); + // also give the HUD a ruleset container to allow rulesets to potentially override HUD elements (used to disable combo counters etc.) + // we may want to limit this in the future to disallow rulesets from outright replacing elements the user expects to be there. + var hudRulesetContainer = new SkinProvidingContainer(ruleset.CreateLegacySkinProvider(beatmapSkinProvider, playableBeatmap)); + // add the overlay components as a separate step as they proxy some elements from the above underlay/gameplay components. - GameplayClockContainer.Add(createOverlayComponents(Beatmap.Value)); + GameplayClockContainer.Add(hudRulesetContainer.WithChild(createOverlayComponents(Beatmap.Value))); if (!DrawableRuleset.AllowGameplayOverlays) { @@ -219,9 +236,12 @@ namespace osu.Game.Screens.Play skipOverlay.Hide(); } - DrawableRuleset.IsPaused.BindValueChanged(_ => updateOverlayActivationMode()); - DrawableRuleset.HasReplayLoaded.BindValueChanged(_ => updateOverlayActivationMode()); - breakTracker.IsBreakTime.BindValueChanged(_ => updateOverlayActivationMode()); + DrawableRuleset.IsPaused.BindValueChanged(paused => + { + updateGameplayState(); + samplePlaybackDisabled.Value = paused.NewValue; + }); + DrawableRuleset.HasReplayLoaded.BindValueChanged(_ => updateGameplayState()); DrawableRuleset.HasReplayLoaded.BindValueChanged(_ => updatePauseOnFocusLostState(), true); @@ -251,7 +271,8 @@ namespace osu.Game.Screens.Play foreach (var mod in Mods.Value.OfType()) mod.ApplyToHealthProcessor(HealthProcessor); - breakTracker.IsBreakTime.BindValueChanged(onBreakTimeChanged, true); + IsBreakTime.BindTo(breakTracker.IsBreakTime); + IsBreakTime.BindValueChanged(onBreakTimeChanged, true); } private Drawable createUnderlayComponents() => @@ -349,18 +370,16 @@ namespace osu.Game.Screens.Play private void onBreakTimeChanged(ValueChangedEvent isBreakTime) { + updateGameplayState(); updatePauseOnFocusLostState(); HUDOverlay.KeyCounter.IsCounting = !isBreakTime.NewValue; } - private void updateOverlayActivationMode() + private void updateGameplayState() { - bool canTriggerOverlays = DrawableRuleset.IsPaused.Value || breakTracker.IsBreakTime.Value; - - if (DrawableRuleset.HasReplayLoaded.Value || canTriggerOverlays) - OverlayActivationMode.Value = OverlayActivation.UserTriggered; - else - OverlayActivationMode.Value = OverlayActivation.Disabled; + bool inGameplay = !DrawableRuleset.HasReplayLoaded.Value && !DrawableRuleset.IsPaused.Value && !breakTracker.IsBreakTime.Value; + OverlayActivationMode.Value = inGameplay ? OverlayActivation.Disabled : OverlayActivation.UserTriggered; + LocalUserPlaying.Value = inGameplay; } private void updatePauseOnFocusLostState() => @@ -441,6 +460,10 @@ namespace osu.Game.Screens.Play /// public void Restart() { + // at the point of restarting the track should either already be paused or the volume should be zero. + // stopping here is to ensure music doesn't become audible after exiting back to PlayerLoader. + musicController.Stop(); + sampleRestart?.Play(); RestartRequested?.Invoke(); @@ -657,7 +680,7 @@ namespace osu.Game.Screens.Play foreach (var mod in Mods.Value.OfType()) mod.ApplyToTrack(musicController.CurrentTrack); - updateOverlayActivationMode(); + updateGameplayState(); } public override void OnSuspending(IScreen next) @@ -740,5 +763,7 @@ namespace osu.Game.Screens.Play } #endregion + + IBindable ISamplePlaybackDisabler.SamplePlaybackDisabled => samplePlaybackDisabled; } } diff --git a/osu.Game/Screens/Play/SkinnableHealthDisplay.cs b/osu.Game/Screens/Play/SkinnableHealthDisplay.cs new file mode 100644 index 0000000000..d35d15d665 --- /dev/null +++ b/osu.Game/Screens/Play/SkinnableHealthDisplay.cs @@ -0,0 +1,51 @@ +// 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.Bindables; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD; +using osu.Game.Skinning; + +namespace osu.Game.Screens.Play +{ + public class SkinnableHealthDisplay : SkinnableDrawable, IHealthDisplay + { + public Bindable Current { get; } = new BindableDouble(1) + { + MinValue = 0, + MaxValue = 1 + }; + + public void Flash(JudgementResult result) => skinnedCounter?.Flash(result); + + private HealthProcessor processor; + + public void BindHealthProcessor(HealthProcessor processor) + { + if (this.processor != null) + throw new InvalidOperationException("Can't bind to a processor more than once"); + + this.processor = processor; + + Current.BindTo(processor.Health); + } + + public SkinnableHealthDisplay() + : base(new HUDSkinComponent(HUDSkinComponents.HealthDisplay), _ => new DefaultHealthDisplay()) + { + CentreComponent = false; + } + + private IHealthDisplay skinnedCounter; + + protected override void SkinChanged(ISkinSource skin, bool allowFallback) + { + base.SkinChanged(skin, allowFallback); + + skinnedCounter = Drawable as IHealthDisplay; + skinnedCounter?.Current.BindTo(Current); + } + } +} diff --git a/osu.Game/Screens/Play/SongProgress.cs b/osu.Game/Screens/Play/SongProgress.cs index aa745f5ba2..acf4640aa4 100644 --- a/osu.Game/Screens/Play/SongProgress.cs +++ b/osu.Game/Screens/Play/SongProgress.cs @@ -70,7 +70,6 @@ namespace osu.Game.Screens.Play public SongProgress() { Masking = true; - Height = bottom_bar_height + graph_height + handle_size.Y + info_height; Children = new Drawable[] { @@ -148,6 +147,8 @@ namespace osu.Game.Screens.Play bar.CurrentTime = gameplayTime; graph.Progress = (int)(graph.ColumnCount * progress); + + Height = bottom_bar_height + graph_height + handle_size.Y + info_height - graph.Y; } private void updateBarVisibility() diff --git a/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs index 95ece1a9fb..24f1116d0e 100644 --- a/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Contracted/ContractedPanelMiddleContent.cs @@ -3,7 +3,6 @@ using System.Linq; using osu.Framework.Allocation; -using osu.Framework.Extensions; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -117,7 +116,7 @@ namespace osu.Game.Screens.Ranking.Contracted AutoSizeAxes = Axes.Y, Direction = FillDirection.Vertical, Spacing = new Vector2(0, 5), - ChildrenEnumerable = score.GetStatisticsForDisplay().Select(s => createStatistic(s.result, s.count, s.maxCount)) + ChildrenEnumerable = score.GetStatisticsForDisplay().Where(s => !s.Result.IsBonus()).Select(createStatistic) }, new FillFlowContainer { @@ -199,8 +198,8 @@ namespace osu.Game.Screens.Ranking.Contracted }; } - private Drawable createStatistic(HitResult result, int count, int? maxCount) - => createStatistic(result.GetDescription(), maxCount == null ? $"{count}" : $"{count}/{maxCount}"); + private Drawable createStatistic(HitResultDisplayStatistic result) + => createStatistic(result.DisplayName, result.MaxCount == null ? $"{result.Count}" : $"{result.Count}/{result.MaxCount}"); private Drawable createStatistic(string key, string value) => new Container { diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs index 1d093c2aba..81fd174285 100644 --- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs @@ -64,155 +64,168 @@ namespace osu.Game.Screens.Ranking.Expanded new PerformanceStatistic(score), }; - var bottomStatistics = new List(); + var bottomStatistics = new List(); - foreach (var (key, value, maxCount) in score.GetStatisticsForDisplay()) - bottomStatistics.Add(new HitResultStatistic(key, value, maxCount)); + foreach (var result in score.GetStatisticsForDisplay()) + bottomStatistics.Add(new HitResultStatistic(result)); statisticDisplays.AddRange(topStatistics); statisticDisplays.AddRange(bottomStatistics); - InternalChild = new FillFlowContainer + InternalChildren = new Drawable[] { - RelativeSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(20), - Children = new Drawable[] + new FillFlowContainer { - new FillFlowContainer + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(20), + Children = new Drawable[] { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] + new FillFlowContainer { - new OsuSpriteText + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Text = new LocalisedString((metadata.TitleUnicode, metadata.Title)), - Font = OsuFont.Torus.With(size: 20, weight: FontWeight.SemiBold), - MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, - Truncate = true, - }, - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Text = new LocalisedString((metadata.ArtistUnicode, metadata.Artist)), - Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold), - MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, - Truncate = true, - }, - new Container - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Margin = new MarginPadding { Top = 40 }, - RelativeSizeAxes = Axes.X, - Height = 230, - Child = new AccuracyCircle(score) + new OsuSpriteText { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - FillMode = FillMode.Fit, - } - }, - scoreCounter = new TotalScoreCounter - { - Margin = new MarginPadding { Top = 0, Bottom = 5 }, - Current = { Value = 0 }, - Alpha = 0, - AlwaysPresent = true - }, - starAndModDisplay = new FillFlowContainer - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(5, 0), - Children = new Drawable[] + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = new LocalisedString((metadata.TitleUnicode, metadata.Title)), + Font = OsuFont.Torus.With(size: 20, weight: FontWeight.SemiBold), + MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, + Truncate = true, + }, + new OsuSpriteText { - new StarRatingDisplay(beatmap) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft - }, - } - }, - new FillFlowContainer - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Direction = FillDirection.Vertical, - AutoSizeAxes = Axes.Both, - Children = new Drawable[] + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = new LocalisedString((metadata.ArtistUnicode, metadata.Artist)), + Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold), + MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, + Truncate = true, + }, + new Container { - new OsuSpriteText + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Margin = new MarginPadding { Top = 40 }, + RelativeSizeAxes = Axes.X, + Height = 230, + Child = new AccuracyCircle(score) { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Text = beatmap.Version, - Font = OsuFont.Torus.With(size: 16, weight: FontWeight.SemiBold), - }, - new OsuTextFlowContainer(s => s.Font = OsuFont.Torus.With(size: 12)) + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + FillMode = FillMode.Fit, + } + }, + scoreCounter = new TotalScoreCounter + { + Margin = new MarginPadding { Top = 0, Bottom = 5 }, + Current = { Value = 0 }, + Alpha = 0, + AlwaysPresent = true + }, + starAndModDisplay = new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(5, 0), + Children = new Drawable[] { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - }.With(t => - { - if (!string.IsNullOrEmpty(creator)) + new StarRatingDisplay(beatmap) { - t.AddText("mapped by "); - t.AddText(creator, s => s.Font = s.Font.With(weight: FontWeight.SemiBold)); - } - }) - } - }, - } - }, - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 5), - Children = new Drawable[] + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + }, + } + }, + new FillFlowContainer + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Both, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Text = beatmap.Version, + Font = OsuFont.Torus.With(size: 16, weight: FontWeight.SemiBold), + }, + new OsuTextFlowContainer(s => s.Font = OsuFont.Torus.With(size: 12)) + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + }.With(t => + { + if (!string.IsNullOrEmpty(creator)) + { + t.AddText("mapped by "); + t.AddText(creator, s => s.Font = s.Font.With(weight: FontWeight.SemiBold)); + } + }) + } + }, + } + }, + new FillFlowContainer { - new GridContainer + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Content = new[] { topStatistics.Cast().ToArray() }, - RowDimensions = new[] + new GridContainer { - new Dimension(GridSizeMode.AutoSize), - } - }, - new GridContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Content = new[] { bottomStatistics.Cast().ToArray() }, - RowDimensions = new[] + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] { topStatistics.Cast().ToArray() }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + } + }, + new GridContainer { - new Dimension(GridSizeMode.AutoSize), + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] { bottomStatistics.Where(s => s.Result <= HitResult.Perfect).ToArray() }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + } + }, + new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Content = new[] { bottomStatistics.Where(s => s.Result > HitResult.Perfect).ToArray() }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + } } } } - }, - new OsuSpriteText - { - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre, - Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold), - Text = $"Played on {score.Date.ToLocalTime():d MMMM yyyy HH:mm}" } + }, + new OsuSpriteText + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Font = OsuFont.GetFont(size: 10, weight: FontWeight.SemiBold), + Text = $"Played on {score.Date.ToLocalTime():d MMMM yyyy HH:mm}" } }; diff --git a/osu.Game/Screens/Ranking/Expanded/Statistics/HitResultStatistic.cs b/osu.Game/Screens/Ranking/Expanded/Statistics/HitResultStatistic.cs index a86033713f..ada8dfabf0 100644 --- a/osu.Game/Screens/Ranking/Expanded/Statistics/HitResultStatistic.cs +++ b/osu.Game/Screens/Ranking/Expanded/Statistics/HitResultStatistic.cs @@ -2,26 +2,26 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; -using osu.Framework.Extensions; using osu.Game.Graphics; using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; namespace osu.Game.Screens.Ranking.Expanded.Statistics { public class HitResultStatistic : CounterStatistic { - private readonly HitResult result; + public readonly HitResult Result; - public HitResultStatistic(HitResult result, int count, int? maxCount = null) - : base(result.GetDescription(), count, maxCount) + public HitResultStatistic(HitResultDisplayStatistic result) + : base(result.DisplayName, result.Count, result.MaxCount) { - this.result = result; + Result = result.Result; } [BackgroundDependencyLoader] private void load(OsuColour colours) { - HeaderText.Colour = colours.ForHitResult(result); + HeaderText.Colour = colours.ForHitResult(Result); } } } diff --git a/osu.Game/Screens/Ranking/ResultsScreen.cs b/osu.Game/Screens/Ranking/ResultsScreen.cs index c48cd238c0..026ce01857 100644 --- a/osu.Game/Screens/Ranking/ResultsScreen.cs +++ b/osu.Game/Screens/Ranking/ResultsScreen.cs @@ -10,9 +10,11 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; using osu.Framework.Screens; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; using osu.Game.Online.API; using osu.Game.Scoring; using osu.Game.Screens.Backgrounds; @@ -22,7 +24,7 @@ using osuTK; namespace osu.Game.Screens.Ranking { - public abstract class ResultsScreen : OsuScreen + public abstract class ResultsScreen : OsuScreen, IKeyBindingHandler { protected const float BACKGROUND_BLUR = 20; private static readonly float screen_height = 768 - TwoLayerButton.SIZE_EXTENDED.Y; @@ -314,6 +316,22 @@ namespace osu.Game.Screens.Ranking } } + public bool OnPressed(GlobalAction action) + { + switch (action) + { + case GlobalAction.Select: + statisticsPanel.ToggleVisibility(); + return true; + } + + return false; + } + + public void OnReleased(GlobalAction action) + { + } + private class VerticalScrollContainer : OsuScrollContainer { protected override Container Content => content; diff --git a/osu.Game/Screens/Ranking/ScorePanel.cs b/osu.Game/Screens/Ranking/ScorePanel.cs index 1904da7094..ee97ee55eb 100644 --- a/osu.Game/Screens/Ranking/ScorePanel.cs +++ b/osu.Game/Screens/Ranking/ScorePanel.cs @@ -29,7 +29,7 @@ namespace osu.Game.Screens.Ranking /// /// Height of the panel when contracted. /// - private const float contracted_height = 355; + private const float contracted_height = 385; /// /// Width of the panel when expanded. @@ -39,7 +39,7 @@ namespace osu.Game.Screens.Ranking /// /// Height of the panel when expanded. /// - private const float expanded_height = 560; + private const float expanded_height = 586; /// /// Height of the top layer when the panel is expanded. @@ -105,11 +105,16 @@ namespace osu.Game.Screens.Ranking [BackgroundDependencyLoader] private void load() { + // ScorePanel doesn't include the top extruding area in its own size. + // Adding a manual offset here allows the expanded version to take on an "acceptable" vertical centre when at 100% UI scale. + const float vertical_fudge = 20; + InternalChild = content = new Container { Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(40), + Y = vertical_fudge, Children = new Drawable[] { topLayerContainer = new Container diff --git a/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs b/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs index aa2a83774e..93885b6e02 100644 --- a/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs +++ b/osu.Game/Screens/Ranking/Statistics/HitEventTimingDistributionGraph.cs @@ -66,7 +66,7 @@ namespace osu.Game.Screens.Ranking.Statistics foreach (var e in hitEvents) { - int binOffset = (int)(e.TimeOffset / binSize); + int binOffset = (int)Math.Round(e.TimeOffset / binSize, MidpointRounding.AwayFromZero); bins[timing_distribution_centre_bin_index + binOffset]++; } diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index 3892e02a8f..83e3c84f39 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -58,6 +58,14 @@ namespace osu.Game.Screens.Select.Carousel foreach (var criteriaTerm in criteria.SearchTerms) match &= terms.Any(term => term.IndexOf(criteriaTerm, StringComparison.InvariantCultureIgnoreCase) >= 0); + + // if a match wasn't found via text matching of terms, do a second catch-all check matching against online IDs. + // this should be done after text matching so we can prioritise matching numbers in metadata. + if (!match && criteria.SearchNumber.HasValue) + { + match = (Beatmap.OnlineBeatmapID == criteria.SearchNumber.Value) || + (Beatmap.BeatmapSet?.OnlineBeatmapSetID == criteria.SearchNumber.Value); + } } if (match) diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index 66f164bca8..f34f8f6505 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -43,6 +43,11 @@ namespace osu.Game.Screens.Select private string searchText; + /// + /// as a number (if it can be parsed as one). + /// + public int? SearchNumber { get; private set; } + public string SearchText { get => searchText; @@ -50,6 +55,11 @@ namespace osu.Game.Screens.Select { searchText = value; SearchTerms = searchText.Split(new[] { ',', ' ', '!' }, StringSplitOptions.RemoveEmptyEntries).ToArray(); + + SearchNumber = null; + + if (SearchTerms.Length == 1 && int.TryParse(SearchTerms[0], out int parsed)) + SearchNumber = parsed; } } diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index 39fa4f777d..4b6b3be45c 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -79,10 +79,10 @@ namespace osu.Game.Screens.Select } private static int getLengthScale(string value) => - value.EndsWith("ms") ? 1 : - value.EndsWith("s") ? 1000 : - value.EndsWith("m") ? 60000 : - value.EndsWith("h") ? 3600000 : 1000; + value.EndsWith("ms", StringComparison.Ordinal) ? 1 : + value.EndsWith('s') ? 1000 : + value.EndsWith('m') ? 60000 : + value.EndsWith('h') ? 3600000 : 1000; private static bool parseFloatWithPoint(string value, out float result) => float.TryParse(value, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out result); diff --git a/osu.Game/Skinning/GameplaySkinComponent.cs b/osu.Game/Skinning/GameplaySkinComponent.cs index 2aa380fa90..80f6efc07a 100644 --- a/osu.Game/Skinning/GameplaySkinComponent.cs +++ b/osu.Game/Skinning/GameplaySkinComponent.cs @@ -18,6 +18,6 @@ namespace osu.Game.Skinning protected virtual string ComponentName => Component.ToString(); public string LookupName => - string.Join("/", new[] { "Gameplay", RulesetPrefix, ComponentName }.Where(s => !string.IsNullOrEmpty(s))); + string.Join('/', new[] { "Gameplay", RulesetPrefix, ComponentName }.Where(s => !string.IsNullOrEmpty(s))); } } diff --git a/osu.Game/Skinning/HUDSkinComponent.cs b/osu.Game/Skinning/HUDSkinComponent.cs new file mode 100644 index 0000000000..cc053421b7 --- /dev/null +++ b/osu.Game/Skinning/HUDSkinComponent.cs @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; + +namespace osu.Game.Skinning +{ + public class HUDSkinComponent : ISkinComponent + { + public readonly HUDSkinComponents Component; + + public HUDSkinComponent(HUDSkinComponents component) + { + Component = component; + } + + protected virtual string ComponentName => Component.ToString(); + + public string LookupName => + string.Join('/', new[] { "HUD", ComponentName }.Where(s => !string.IsNullOrEmpty(s))); + } +} diff --git a/osu.Game/Skinning/HUDSkinComponents.cs b/osu.Game/Skinning/HUDSkinComponents.cs new file mode 100644 index 0000000000..b01be2d5a0 --- /dev/null +++ b/osu.Game/Skinning/HUDSkinComponents.cs @@ -0,0 +1,15 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Skinning +{ + public enum HUDSkinComponents + { + ComboCounter, + ScoreCounter, + AccuracyCounter, + HealthDisplay, + ScoreText, + ComboText, + } +} diff --git a/osu.Game/Skinning/LegacyAccuracyCounter.cs b/osu.Game/Skinning/LegacyAccuracyCounter.cs new file mode 100644 index 0000000000..5eda374337 --- /dev/null +++ b/osu.Game/Skinning/LegacyAccuracyCounter.cs @@ -0,0 +1,47 @@ +// 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.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Screens.Play; +using osu.Game.Screens.Play.HUD; +using osuTK; + +namespace osu.Game.Skinning +{ + public class LegacyAccuracyCounter : PercentageCounter, IAccuracyCounter + { + private readonly ISkin skin; + + public LegacyAccuracyCounter(ISkin skin) + { + Anchor = Anchor.TopRight; + Origin = Anchor.TopRight; + + Scale = new Vector2(0.6f); + Margin = new MarginPadding(10); + + this.skin = skin; + } + + [Resolved(canBeNull: true)] + private HUDOverlay hud { get; set; } + + protected sealed override OsuSpriteText CreateSpriteText() + => (OsuSpriteText)skin?.GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.ScoreText)) + ?.With(s => s.Anchor = s.Origin = Anchor.TopRight); + + protected override void Update() + { + base.Update(); + + if (hud?.ScoreCounter.Drawable is LegacyScoreCounter score) + { + // for now align with the score counter. eventually this will be user customisable. + Y = Parent.ToLocalSpace(score.ScreenSpaceDrawQuad.BottomRight).Y; + } + } + } +} diff --git a/osu.Game/Skinning/LegacyHealthDisplay.cs b/osu.Game/Skinning/LegacyHealthDisplay.cs new file mode 100644 index 0000000000..489e23ab7a --- /dev/null +++ b/osu.Game/Skinning/LegacyHealthDisplay.cs @@ -0,0 +1,266 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.Textures; +using osu.Framework.Utils; +using osu.Game.Rulesets.Judgements; +using osu.Game.Screens.Play.HUD; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Skinning +{ + public class LegacyHealthDisplay : CompositeDrawable, IHealthDisplay + { + private const double epic_cutoff = 0.5; + + private readonly Skin skin; + private LegacyHealthPiece fill; + private LegacyHealthPiece marker; + + private float maxFillWidth; + + private bool isNewStyle; + + public Bindable Current { get; } = new BindableDouble(1) + { + MinValue = 0, + MaxValue = 1 + }; + + public LegacyHealthDisplay(Skin skin) + { + this.skin = skin; + } + + [BackgroundDependencyLoader] + private void load() + { + AutoSizeAxes = Axes.Both; + + isNewStyle = getTexture(skin, "marker") != null; + + // background implementation is the same for both versions. + AddInternal(new Sprite { Texture = getTexture(skin, "bg") }); + + if (isNewStyle) + { + AddRangeInternal(new[] + { + fill = new LegacyNewStyleFill(skin), + marker = new LegacyNewStyleMarker(skin), + }); + } + else + { + AddRangeInternal(new[] + { + fill = new LegacyOldStyleFill(skin), + marker = new LegacyOldStyleMarker(skin), + }); + } + + fill.Current.BindTo(Current); + marker.Current.BindTo(Current); + + maxFillWidth = fill.Width; + } + + protected override void Update() + { + base.Update(); + + fill.Width = Interpolation.ValueAt( + Math.Clamp(Clock.ElapsedFrameTime, 0, 200), + fill.Width, (float)Current.Value * maxFillWidth, 0, 200, Easing.OutQuint); + + marker.Position = fill.Position + new Vector2(fill.DrawWidth, fill.DrawHeight / 2); + } + + public void Flash(JudgementResult result) => marker.Flash(result); + + private static Texture getTexture(Skin skin, string name) => skin.GetTexture($"scorebar-{name}"); + + private static Color4 getFillColour(double hp) + { + if (hp < 0.2) + return Interpolation.ValueAt(0.2 - hp, Color4.Black, Color4.Red, 0, 0.2); + + if (hp < epic_cutoff) + return Interpolation.ValueAt(0.5 - hp, Color4.White, Color4.Black, 0, 0.5); + + return Color4.White; + } + + public class LegacyOldStyleMarker : LegacyMarker + { + private readonly Texture normalTexture; + private readonly Texture dangerTexture; + private readonly Texture superDangerTexture; + + public LegacyOldStyleMarker(Skin skin) + { + normalTexture = getTexture(skin, "ki"); + dangerTexture = getTexture(skin, "kidanger"); + superDangerTexture = getTexture(skin, "kidanger2"); + } + + public override Sprite CreateSprite() => new Sprite + { + Texture = normalTexture, + Origin = Anchor.Centre, + }; + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(hp => + { + if (hp.NewValue < 0.2f) + Main.Texture = superDangerTexture; + else if (hp.NewValue < epic_cutoff) + Main.Texture = dangerTexture; + else + Main.Texture = normalTexture; + }); + } + } + + public class LegacyNewStyleMarker : LegacyMarker + { + private readonly Skin skin; + + public LegacyNewStyleMarker(Skin skin) + { + this.skin = skin; + } + + public override Sprite CreateSprite() => new Sprite + { + Texture = getTexture(skin, "marker"), + Origin = Anchor.Centre, + }; + + protected override void Update() + { + base.Update(); + + Main.Colour = getFillColour(Current.Value); + Main.Blending = Current.Value < epic_cutoff ? BlendingParameters.Inherit : BlendingParameters.Additive; + } + } + + internal class LegacyOldStyleFill : LegacyHealthPiece + { + public LegacyOldStyleFill(Skin skin) + { + // required for sizing correctly.. + var firstFrame = getTexture(skin, "colour-0"); + + if (firstFrame == null) + { + InternalChild = new Sprite { Texture = getTexture(skin, "colour") }; + Size = InternalChild.Size; + } + else + { + InternalChild = skin.GetAnimation("scorebar-colour", true, true, startAtCurrentTime: false, applyConfigFrameRate: true) ?? Drawable.Empty(); + Size = new Vector2(firstFrame.DisplayWidth, firstFrame.DisplayHeight); + } + + Position = new Vector2(3, 10) * 1.6f; + Masking = true; + } + } + + internal class LegacyNewStyleFill : LegacyHealthPiece + { + public LegacyNewStyleFill(Skin skin) + { + InternalChild = new Sprite + { + Texture = getTexture(skin, "colour"), + }; + + Size = InternalChild.Size; + Position = new Vector2(7.5f, 7.8f) * 1.6f; + Masking = true; + } + + protected override void Update() + { + base.Update(); + Colour = getFillColour(Current.Value); + } + } + + public abstract class LegacyMarker : LegacyHealthPiece + { + protected Sprite Main; + + private Sprite explode; + + protected LegacyMarker() + { + Origin = Anchor.Centre; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChildren = new Drawable[] + { + Main = CreateSprite(), + explode = CreateSprite().With(s => + { + s.Alpha = 0; + s.Blending = BlendingParameters.Additive; + }), + }; + } + + public abstract Sprite CreateSprite(); + + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(val => + { + if (val.NewValue > val.OldValue) + bulgeMain(); + }); + } + + public override void Flash(JudgementResult result) + { + bulgeMain(); + + bool isEpic = Current.Value >= epic_cutoff; + + explode.Blending = isEpic ? BlendingParameters.Additive : BlendingParameters.Inherit; + explode.ScaleTo(1).Then().ScaleTo(isEpic ? 2 : 1.6f, 120); + explode.FadeOutFromOne(120); + } + + private void bulgeMain() => + Main.ScaleTo(1.4f).Then().ScaleTo(1, 200, Easing.Out); + } + + public class LegacyHealthPiece : CompositeDrawable, IHealthDisplay + { + public Bindable Current { get; } = new Bindable(); + + public virtual void Flash(JudgementResult result) + { + } + } + } +} diff --git a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs index a9d88e77ad..3dbec23194 100644 --- a/osu.Game/Skinning/LegacyManiaSkinDecoder.cs +++ b/osu.Game/Skinning/LegacyManiaSkinDecoder.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; @@ -115,16 +116,16 @@ namespace osu.Game.Skinning currentConfig.MinimumColumnWidth = minWidth; break; - case string _ when pair.Key.StartsWith("Colour"): + case string _ when pair.Key.StartsWith("Colour", StringComparison.Ordinal): HandleColours(currentConfig, line); break; // Custom sprite paths - case string _ when pair.Key.StartsWith("NoteImage"): - case string _ when pair.Key.StartsWith("KeyImage"): - case string _ when pair.Key.StartsWith("Hit"): - case string _ when pair.Key.StartsWith("Stage"): - case string _ when pair.Key.StartsWith("Lighting"): + case string _ when pair.Key.StartsWith("NoteImage", StringComparison.Ordinal): + case string _ when pair.Key.StartsWith("KeyImage", StringComparison.Ordinal): + case string _ when pair.Key.StartsWith("Hit", StringComparison.Ordinal): + case string _ when pair.Key.StartsWith("Stage", StringComparison.Ordinal): + case string _ when pair.Key.StartsWith("Lighting", StringComparison.Ordinal): currentConfig.ImageLookups[pair.Key] = pair.Value; break; } diff --git a/osu.Game/Skinning/LegacyScoreCounter.cs b/osu.Game/Skinning/LegacyScoreCounter.cs new file mode 100644 index 0000000000..5bffeff5a8 --- /dev/null +++ b/osu.Game/Skinning/LegacyScoreCounter.cs @@ -0,0 +1,40 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osuTK; + +namespace osu.Game.Skinning +{ + public class LegacyScoreCounter : ScoreCounter + { + private readonly ISkin skin; + + protected override double RollingDuration => 1000; + protected override Easing RollingEasing => Easing.Out; + + public new Bindable Current { get; } = new Bindable(); + + public LegacyScoreCounter(ISkin skin) + : base(6) + { + Anchor = Anchor.TopRight; + Origin = Anchor.TopRight; + + this.skin = skin; + + // base class uses int for display, but externally we bind to ScoreProcessor as a double for now. + Current.BindValueChanged(v => base.Current.Value = (int)v.NewValue); + + Scale = new Vector2(0.96f); + Margin = new MarginPadding(10); + } + + protected sealed override OsuSpriteText CreateSpriteText() + => (OsuSpriteText)skin.GetDrawableComponent(new HUDSkinComponent(HUDSkinComponents.ScoreText)) + .With(s => s.Anchor = s.Origin = Anchor.TopRight); + } +} diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index e38913b13a..94b09684d3 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -18,6 +18,8 @@ using osu.Game.Audio; using osu.Game.Beatmaps.Formats; using osu.Game.IO; using osu.Game.Rulesets.Scoring; +using osu.Game.Screens.Play.HUD; +using osuTK; using osuTK.Graphics; namespace osu.Game.Skinning @@ -323,10 +325,51 @@ namespace osu.Game.Skinning return null; } + private string scorePrefix => GetConfig(LegacySkinConfiguration.LegacySetting.ScorePrefix)?.Value ?? "score"; + + private string comboPrefix => GetConfig(LegacySkinConfiguration.LegacySetting.ComboPrefix)?.Value ?? "score"; + + private bool hasScoreFont => this.HasFont(scorePrefix); + public override Drawable GetDrawableComponent(ISkinComponent component) { switch (component) { + case HUDSkinComponent hudComponent: + { + if (!hasScoreFont) + return null; + + switch (hudComponent.Component) + { + case HUDSkinComponents.ComboCounter: + return new LegacyComboCounter(); + + case HUDSkinComponents.ScoreCounter: + return new LegacyScoreCounter(this); + + case HUDSkinComponents.AccuracyCounter: + return new LegacyAccuracyCounter(this); + + case HUDSkinComponents.HealthDisplay: + return new LegacyHealthDisplay(this); + + case HUDSkinComponents.ComboText: + return new LegacySpriteText(this, comboPrefix) + { + Spacing = new Vector2(-(GetConfig(LegacySkinConfiguration.LegacySetting.ComboOverlap)?.Value ?? -2), 0) + }; + + case HUDSkinComponents.ScoreText: + return new LegacySpriteText(this, scorePrefix) + { + Spacing = new Vector2(-(GetConfig(LegacySkinConfiguration.LegacySetting.ScoreOverlap)?.Value ?? -2), 0) + }; + } + + return null; + } + case GameplaySkinComponent resultComponent: switch (resultComponent.Component) { @@ -397,7 +440,7 @@ namespace osu.Game.Skinning // Fall back to using the last piece for components coming from lazer (e.g. "Gameplay/osu/approachcircle" -> "approachcircle"). string lastPiece = componentName.Split('/').Last(); - yield return componentName.StartsWith("Gameplay/taiko/") ? "taiko-" + lastPiece : lastPiece; + yield return componentName.StartsWith("Gameplay/taiko/", StringComparison.Ordinal) ? "taiko-" + lastPiece : lastPiece; } private IEnumerable getLegacyLookupNames(HitSampleInfo hitSample) @@ -408,7 +451,7 @@ namespace osu.Game.Skinning // for compatibility with stable, exclude the lookup names with the custom sample bank suffix, if they are not valid for use in this skin. // using .EndsWith() is intentional as it ensures parity in all edge cases // (see LegacyTaikoSampleInfo for an example of one - prioritising the taiko prefix should still apply, but the sample bank should not). - lookupNames = hitSample.LookupNames.Where(name => !name.EndsWith(hitSample.Suffix)); + lookupNames = hitSample.LookupNames.Where(name => !name.EndsWith(hitSample.Suffix, StringComparison.Ordinal)); // also for compatibility, try falling back to non-bank samples (so-called "universal" samples) as the last resort. // going forward specifying banks shall always be required, even for elements that wouldn't require it on stable, diff --git a/osu.Game/Skinning/LegacySkinConfiguration.cs b/osu.Game/Skinning/LegacySkinConfiguration.cs index 828804b9cb..84a834ec22 100644 --- a/osu.Game/Skinning/LegacySkinConfiguration.cs +++ b/osu.Game/Skinning/LegacySkinConfiguration.cs @@ -17,6 +17,8 @@ namespace osu.Game.Skinning Version, ComboPrefix, ComboOverlap, + ScorePrefix, + ScoreOverlap, AnimationFramerate, LayeredHitSounds } diff --git a/osu.Game/Skinning/LegacySpriteText.cs b/osu.Game/Skinning/LegacySpriteText.cs index 773a9dc5c6..5d0e312f7c 100644 --- a/osu.Game/Skinning/LegacySpriteText.cs +++ b/osu.Game/Skinning/LegacySpriteText.cs @@ -12,12 +12,16 @@ namespace osu.Game.Skinning { private readonly LegacyGlyphStore glyphStore; - public LegacySpriteText(ISkin skin, string font) + protected override char FixedWidthReferenceCharacter => '5'; + + protected override char[] FixedWidthExcludeCharacters => new[] { ',', '.', '%', 'x' }; + + public LegacySpriteText(ISkin skin, string font = "score") { Shadow = false; UseFullGlyphHeight = false; - Font = new FontUsage(font, 1); + Font = new FontUsage(font, 1, fixedWidth: true); glyphStore = new LegacyGlyphStore(skin); } @@ -34,7 +38,9 @@ namespace osu.Game.Skinning public ITexturedCharacterGlyph Get(string fontName, char character) { - var texture = skin.GetTexture($"{fontName}-{character}"); + var lookup = getLookupName(character); + + var texture = skin.GetTexture($"{fontName}-{lookup}"); if (texture == null) return null; @@ -42,6 +48,24 @@ namespace osu.Game.Skinning return new TexturedCharacterGlyph(new CharacterGlyph(character, 0, 0, texture.Width, null), texture, 1f / texture.ScaleAdjust); } + private static string getLookupName(char character) + { + switch (character) + { + case ',': + return "comma"; + + case '.': + return "dot"; + + case '%': + return "percent"; + + default: + return character.ToString(); + } + } + public Task GetAsync(string fontName, char character) => Task.Run(() => Get(fontName, character)); } } diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index 7af400e807..37a2309e01 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -18,12 +18,14 @@ using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Framework.Platform; +using osu.Framework.Testing; using osu.Game.Audio; using osu.Game.Database; using osu.Game.IO.Archives; namespace osu.Game.Skinning { + [ExcludeFromDynamicCompile] public class SkinManager : ArchiveModelManager, ISkinSource { private readonly AudioManager audio; diff --git a/osu.Game/Skinning/SkinnableDrawable.cs b/osu.Game/Skinning/SkinnableDrawable.cs index d9a5036649..5a48bc4baf 100644 --- a/osu.Game/Skinning/SkinnableDrawable.cs +++ b/osu.Game/Skinning/SkinnableDrawable.cs @@ -19,6 +19,12 @@ namespace osu.Game.Skinning /// public Drawable Drawable { get; private set; } + /// + /// Whether the drawable component should be centered in available space. + /// Defaults to true. + /// + public bool CentreComponent { get; set; } = true; + public new Axes AutoSizeAxes { get => base.AutoSizeAxes; @@ -84,8 +90,13 @@ namespace osu.Game.Skinning if (Drawable != null) { scaling.Invalidate(); - Drawable.Origin = Anchor.Centre; - Drawable.Anchor = Anchor.Centre; + + if (CentreComponent) + { + Drawable.Origin = Anchor.Centre; + Drawable.Anchor = Anchor.Centre; + } + InternalChild = Drawable; } else diff --git a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs index e492069c5e..fcf20a2eb2 100644 --- a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs +++ b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs @@ -34,6 +34,12 @@ namespace osu.Game.Tests.Beatmaps var ourResult = convert(name, mods.Select(m => (Mod)Activator.CreateInstance(m)).ToArray()); var expectedResult = read(name); + foreach (var m in ourResult.Mappings) + m.PostProcess(); + + foreach (var m in expectedResult.Mappings) + m.PostProcess(); + Assert.Multiple(() => { int mappingCounter = 0; @@ -239,6 +245,13 @@ namespace osu.Game.Tests.Beatmaps set => Objects = value; } + /// + /// Invoked after this is populated to post-process the contained data. + /// + public virtual void PostProcess() + { + } + public virtual bool Equals(ConvertMapping other) => StartTime == other?.StartTime; } } diff --git a/osu.Game/Tests/Visual/SkinnableTestScene.cs b/osu.Game/Tests/Visual/SkinnableTestScene.cs index a856789d96..fe4f735325 100644 --- a/osu.Game/Tests/Visual/SkinnableTestScene.cs +++ b/osu.Game/Tests/Visual/SkinnableTestScene.cs @@ -35,12 +35,12 @@ namespace osu.Game.Tests.Visual } [BackgroundDependencyLoader] - private void load(AudioManager audio, SkinManager skinManager) + private void load(AudioManager audio, SkinManager skinManager, OsuGameBase game) { var dllStore = new DllResourceStore(DynamicCompilationOriginal.GetType().Assembly); metricsSkin = new TestLegacySkin(new SkinInfo { Name = "metrics-skin" }, new NamespacedResourceStore(dllStore, "Resources/metrics_skin"), audio, true); - defaultSkin = skinManager.GetSkin(DefaultLegacySkin.Info); + defaultSkin = new DefaultLegacySkin(new NamespacedResourceStore(game.Resources, "Skins/Legacy"), audio); specialSkin = new TestLegacySkin(new SkinInfo { Name = "special-skin" }, new NamespacedResourceStore(dllStore, "Resources/special_skin"), audio, true); oldSkin = new TestLegacySkin(new SkinInfo { Name = "old-skin" }, new NamespacedResourceStore(dllStore, "Resources/old_skin"), audio, true); } diff --git a/osu.Game/Updater/SimpleUpdateManager.cs b/osu.Game/Updater/SimpleUpdateManager.cs index 48c6722bd9..4ebf2a7368 100644 --- a/osu.Game/Updater/SimpleUpdateManager.cs +++ b/osu.Game/Updater/SimpleUpdateManager.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Threading.Tasks; using Newtonsoft.Json; @@ -30,7 +31,7 @@ namespace osu.Game.Updater version = game.Version; } - protected override async Task PerformUpdateCheck() + protected override async Task PerformUpdateCheck() { try { @@ -53,12 +54,17 @@ namespace osu.Game.Updater return true; } }); + + return true; } } catch { // we shouldn't crash on a web failure. or any failure for the matter. + return true; } + + return false; } private string getBestUrl(GitHubRelease release) @@ -68,15 +74,15 @@ namespace osu.Game.Updater switch (RuntimeInfo.OS) { case RuntimeInfo.Platform.Windows: - bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".exe")); + bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".exe", StringComparison.Ordinal)); break; case RuntimeInfo.Platform.MacOsx: - bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".app.zip")); + bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".app.zip", StringComparison.Ordinal)); break; case RuntimeInfo.Platform.Linux: - bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".AppImage")); + bestAsset = release.Assets?.Find(f => f.Name.EndsWith(".AppImage", StringComparison.Ordinal)); break; case RuntimeInfo.Platform.iOS: diff --git a/osu.Game/Updater/UpdateManager.cs b/osu.Game/Updater/UpdateManager.cs index 61775a26b7..f772c6d282 100644 --- a/osu.Game/Updater/UpdateManager.cs +++ b/osu.Game/Updater/UpdateManager.cs @@ -57,25 +57,31 @@ namespace osu.Game.Updater private readonly object updateTaskLock = new object(); - private Task updateCheckTask; + private Task updateCheckTask; - public async Task CheckForUpdateAsync() + public async Task CheckForUpdateAsync() { if (!CanCheckForUpdate) - return; + return false; - Task waitTask; + Task waitTask; lock (updateTaskLock) waitTask = (updateCheckTask ??= PerformUpdateCheck()); - await waitTask; + bool hasUpdates = await waitTask; lock (updateTaskLock) updateCheckTask = null; + + return hasUpdates; } - protected virtual Task PerformUpdateCheck() => Task.CompletedTask; + /// + /// Performs an asynchronous check for application updates. + /// + /// Whether any update is waiting. May return true if an error occured (there is potentially an update available). + protected virtual Task PerformUpdateCheck() => Task.FromResult(false); private class UpdateCompleteNotification : SimpleNotification { diff --git a/osu.Game/Users/User.cs b/osu.Game/Users/User.cs index f8bb8f4c6a..89786e3bd8 100644 --- a/osu.Game/Users/User.cs +++ b/osu.Game/Users/User.cs @@ -111,9 +111,6 @@ namespace osu.Game.Users [JsonProperty(@"twitter")] public string Twitter; - [JsonProperty(@"lastfm")] - public string Lastfm; - [JsonProperty(@"skype")] public string Skype; diff --git a/osu.Game/Utils/SentryLogger.cs b/osu.Game/Utils/SentryLogger.cs index 981251784e..e8e41cdbbe 100644 --- a/osu.Game/Utils/SentryLogger.cs +++ b/osu.Game/Utils/SentryLogger.cs @@ -45,7 +45,7 @@ namespace osu.Game.Utils // since we let unhandled exceptions go ignored at times, we want to ensure they don't get submitted on subsequent reports. if (lastException != null && - lastException.Message == exception.Message && exception.StackTrace.StartsWith(lastException.StackTrace)) + lastException.Message == exception.Message && exception.StackTrace.StartsWith(lastException.StackTrace, StringComparison.Ordinal)) return; lastException = exception; diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index fa2135580d..de7bde824f 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -24,8 +24,8 @@ - - + + diff --git a/osu.iOS.props b/osu.iOS.props index 20a51e5feb..9c22dec330 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -70,8 +70,8 @@ - - + + @@ -80,7 +80,7 @@ - + diff --git a/osu.sln.DotSettings b/osu.sln.DotSettings index 64f3d41acb..3ef419c572 100644 --- a/osu.sln.DotSettings +++ b/osu.sln.DotSettings @@ -199,7 +199,9 @@ WARNING WARNING WARNING + WARNING HINT + WARNING WARNING DO_NOT_SHOW DO_NOT_SHOW @@ -773,6 +775,7 @@ See the LICENCE file in the repository root for full licence text. <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + True True True True