diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 1f937e1837..8c8a3be771 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -15,7 +15,7 @@ ] }, "codefilesanity": { - "version": "0.0.36", + "version": "0.0.37", "commands": [ "CodeFileSanity" ] diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 47a6a4c3d3..ec57232126 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -2,7 +2,7 @@ blank_issues_enabled: false contact_links: - name: Help url: https://github.com/ppy/osu/discussions/categories/q-a - about: osu! not working as you'd expect? Not sure it's a bug? Check the Q&A section! + about: osu! not working or performing as you'd expect? Not sure it's a bug? Check the Q&A section! - name: Suggestions or feature request url: https://github.com/ppy/osu/discussions/categories/ideas about: Got something you think should change or be added? Search for or start a new discussion! diff --git a/README.md b/README.md index eb2fe6d0eb..cf7ce35791 100644 --- a/README.md +++ b/README.md @@ -16,21 +16,20 @@ The future of [osu!](https://osu.ppy.sh) and the beginning of an open era! Curre ## Status -This project is under heavy development, but is in a stable state. Users are encouraged to try it out and keep it installed alongside the stable *osu!* client. It will continue to evolve to the point of eventually replacing the existing stable client as an update. +This project is under constant development, but we aim to keep things in a stable state. Users are encouraged to try it out and keep it installed alongside the stable *osu!* client. It will continue to evolve to the point of eventually replacing the existing stable client as an update. -**IMPORTANT:** Gameplay mechanics (and other features which you may have come to know and love) are in a constant state of flux. Game balance and final quality-of-life passes come at the end of development, preceded by experimentation and changes which may potentially **reduce playability or usability**. This is done in order to allow us to move forward as developers and designers more efficiently. If this offends you, please consider sticking to the stable releases of osu! (found on the website). We are not yet open to heated discussion over game mechanics and will not be using github as a forum for such discussions just yet. +**IMPORTANT:** Gameplay mechanics (and other features which you may have come to know and love) are in a constant state of flux. Game balance and final quality-of-life passes come at the end of development, preceded by experimentation and changes which may potentially **reduce playability or usability**. This is done in order to allow us to move forward as developers and designers more efficiently. If this offends you, please consider sticking to a [stable release](https://osu.ppy.sh/home/download) of osu!. We are not yet open to heated discussion over game mechanics and will not be using github as a forum for such discussions just yet. We are accepting bug reports (please report with as much detail as possible and follow the existing issue templates). Feature requests are also welcome, but understand that our focus is on completing the game to feature parity before adding new features. A few resources are available as starting points to getting involved and understanding the project: - Detailed release changelogs are available on the [official osu! site](https://osu.ppy.sh/home/changelog/lazer). - You can learn more about our approach to [project management](https://github.com/ppy/osu/wiki/Project-management). -- Read peppy's [blog post](https://blog.ppy.sh/a-definitive-lazer-faq/) exploring where the project is currently and the roadmap going forward. ## Running osu! -If you are looking to install or test osu! without setting up a development environment, you can consume our [binary releases](https://github.com/ppy/osu/releases). Handy links below will download the latest version for your operating system of choice: +If you are looking to install or test osu! without setting up a development environment, you can consume our [releases](https://github.com/ppy/osu/releases). You can also generally download a version for your current device from the [osu! site](https://osu.ppy.sh/home/download). Failing that, you may use the links below to download the latest version for your operating system of choice: -**Latest build:** +**Latest release:** | [Windows 8.1+ (x64)](https://github.com/ppy/osu/releases/latest/download/install.exe) | macOS 10.15+ ([Intel](https://github.com/ppy/osu/releases/latest/download/osu.app.Intel.zip), [Apple Silicon](https://github.com/ppy/osu/releases/latest/download/osu.app.Apple.Silicon.zip)) | [Linux (x64)](https://github.com/ppy/osu/releases/latest/download/osu.AppImage) | [iOS 13.4+](https://osu.ppy.sh/home/testflight) | [Android 5+](https://github.com/ppy/osu/releases/latest/download/sh.ppy.osulazer.apk) | | ------------- | ------------- | ------------- | ------------- | ------------- | @@ -50,9 +49,8 @@ You can see some examples of custom rulesets by visiting the [custom ruleset dir Please make sure you have the following prerequisites: - A desktop platform with the [.NET 6.0 SDK](https://dotnet.microsoft.com/download) installed. -- When developing with mobile, [Xamarin](https://docs.microsoft.com/en-us/xamarin/) is required, which is shipped together with Visual Studio or [Visual Studio for Mac](https://visualstudio.microsoft.com/vs/mac/). -- When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio Code](https://code.visualstudio.com/). -- When running on Linux, please have a system-wide FFmpeg installation available to support video decoding. + +When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as the latest version of [Visual Studio](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio Code](https://code.visualstudio.com/). ### Downloading the source code @@ -89,7 +87,29 @@ _Due to a historical feature gap between .NET Core and Xamarin, running `dotnet` ### Testing with resource/framework modifications -Sometimes it may be necessary to cross-test changes in [osu-resources](https://github.com/ppy/osu-resources) or [osu-framework](https://github.com/ppy/osu-framework). This can be achieved by running some commands as documented on the [osu-resources](https://github.com/ppy/osu-resources/wiki/Testing-local-resources-checkout-with-other-projects) and [osu-framework](https://github.com/ppy/osu-framework/wiki/Testing-local-framework-checkout-with-other-projects) wiki pages. +Sometimes it may be necessary to cross-test changes in [osu-resources](https://github.com/ppy/osu-resources) or [osu-framework](https://github.com/ppy/osu-framework). This can be quickly achieved using included commands: + +Windows: + +```ps +UseLocalFramework.ps1 +UseLocalResources.ps1 +``` + +macOS / Linux: + +```ps +UseLocalFramework.sh +UseLocalResources.sh +``` + +Note that these commands assume you have the relevant project(s) checked out in adjacent directories: + +``` +|- osu // this repository +|- osu-framework +|- osu-resources +``` ### Code analysis diff --git a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/Drawables/DrawablePippidonHitObject.cs b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/Drawables/DrawablePippidonHitObject.cs index 29203f0a20..c5ada4288d 100644 --- a/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/Drawables/DrawablePippidonHitObject.cs +++ b/Templates/Rulesets/ruleset-example/osu.Game.Rulesets.Pippidon/Objects/Drawables/DrawablePippidonHitObject.cs @@ -8,7 +8,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game.Audio; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osuTK; @@ -44,7 +43,7 @@ namespace osu.Game.Rulesets.Pippidon.Objects.Drawables public override IEnumerable GetSamples() => new[] { - new HitSampleInfo(HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK) + new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }; protected override void CheckForResult(bool userTriggered, double timeOffset) diff --git a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Objects/Drawables/DrawablePippidonHitObject.cs b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Objects/Drawables/DrawablePippidonHitObject.cs index 554d03c79f..d198fa81cb 100644 --- a/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Objects/Drawables/DrawablePippidonHitObject.cs +++ b/Templates/Rulesets/ruleset-scrolling-example/osu.Game.Rulesets.Pippidon/Objects/Drawables/DrawablePippidonHitObject.cs @@ -8,7 +8,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Textures; using osu.Game.Audio; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Pippidon.UI; using osu.Game.Rulesets.Scoring; @@ -44,7 +43,7 @@ namespace osu.Game.Rulesets.Pippidon.Objects.Drawables public override IEnumerable GetSamples() => new[] { - new HitSampleInfo(HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK) + new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }; protected override void CheckForResult(bool userTriggered, double timeOffset) diff --git a/global.json b/global.json new file mode 100644 index 0000000000..5dcd5f425a --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "6.0.100", + "rollForward": "latestFeature" + } +} + diff --git a/osu.Android.props b/osu.Android.props index ff76e17184..522d28dca7 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -11,7 +11,7 @@ manifestmerger.jar - + diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index d92fea27bf..21cea3ba76 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -2,12 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Reflection; using System.Runtime.Versioning; -using System.Threading.Tasks; using Microsoft.Win32; using osu.Desktop.Security; using osu.Framework.Platform; @@ -17,7 +15,6 @@ using osu.Framework; using osu.Framework.Logging; using osu.Game.Updater; using osu.Desktop.Windows; -using osu.Framework.Threading; using osu.Game.IO; using osu.Game.IPC; using osu.Game.Utils; @@ -138,52 +135,10 @@ namespace osu.Desktop desktopWindow.CursorState |= CursorState.Hidden; desktopWindow.Title = Name; - desktopWindow.DragDrop += f => - { - // on macOS, URL associations are handled via SDL_DROPFILE events. - if (f.StartsWith(OSU_PROTOCOL, StringComparison.Ordinal)) - { - HandleLink(f); - return; - } - - fileDrop(new[] { f }); - }; } protected override BatteryInfo CreateBatteryInfo() => new SDL2BatteryInfo(); - private readonly List importableFiles = new List(); - private ScheduledDelegate? importSchedule; - - private void fileDrop(string[] filePaths) - { - lock (importableFiles) - { - importableFiles.AddRange(filePaths); - - Logger.Log($"Adding {filePaths.Length} files for import"); - - // File drag drop operations can potentially trigger hundreds or thousands of these calls on some platforms. - // In order to avoid spawning multiple import tasks for a single drop operation, debounce a touch. - importSchedule?.Cancel(); - importSchedule = Scheduler.AddDelayed(handlePendingImports, 100); - } - } - - private void handlePendingImports() - { - lock (importableFiles) - { - Logger.Log($"Handling batch import of {importableFiles.Count} files"); - - string[] paths = importableFiles.ToArray(); - importableFiles.Clear(); - - Task.Factory.StartNew(() => Import(paths), TaskCreationOptions.LongRunning); - } - } - protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Desktop/Updater/SquirrelUpdateManager.cs b/osu.Desktop/Updater/SquirrelUpdateManager.cs index 3d4db88471..941ab335e8 100644 --- a/osu.Desktop/Updater/SquirrelUpdateManager.cs +++ b/osu.Desktop/Updater/SquirrelUpdateManager.cs @@ -9,6 +9,7 @@ using osu.Framework.Logging; using osu.Game; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; +using osu.Game.Screens.Play; using Squirrel; using Squirrel.SimpleSplat; using LogLevel = Squirrel.SimpleSplat.LogLevel; @@ -36,6 +37,9 @@ namespace osu.Desktop.Updater [Resolved] private OsuGameBase game { get; set; } = null!; + [Resolved] + private ILocalUserPlayInfo? localUserInfo { get; set; } + [BackgroundDependencyLoader] private void load(INotificationOverlay notifications) { @@ -55,6 +59,10 @@ namespace osu.Desktop.Updater try { + // Avoid any kind of update checking while gameplay is running. + if (localUserInfo?.IsPlaying.Value == true) + return false; + updateManager ??= new GithubUpdateManager(@"https://github.com/ppy/osu", false, github_token, @"osulazer"); var info = await updateManager.CheckForUpdate(!useDeltaPatching).ConfigureAwait(false); diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs index 40dc7d2403..f21825668f 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneAutoJuiceStream.cs @@ -45,7 +45,7 @@ namespace osu.Game.Rulesets.Catch.Tests NewCombo = i % 8 == 0, Samples = new List(new[] { - new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "normal", volume: 100) + new HitSampleInfo(HitSampleInfo.HIT_NORMAL) }) }); } diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.cs index 5f22ef5c12..1e63d32c41 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/BananaShowerPlacementBlueprint.cs @@ -17,6 +17,8 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints private double placementStartTime; private double placementEndTime; + protected override bool IsValidForPlacement => HitObject.Duration > 0; + public BananaShowerPlacementBlueprint() { InternalChild = outline = new TimeSpanOutline(); @@ -49,7 +51,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints case PlacementState.Active: if (e.Button != MouseButton.Right) break; - EndPlacement(HitObject.Duration > 0); + EndPlacement(true); return true; } diff --git a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs index 03ec674abb..9e50b5a80f 100644 --- a/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Catch/Edit/Blueprints/JuiceStreamPlacementBlueprint.cs @@ -24,6 +24,8 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints private InputManager inputManager = null!; + protected override bool IsValidForPlacement => HitObject.Duration > 0; + public JuiceStreamPlacementBlueprint() { InternalChildren = new Drawable[] @@ -70,7 +72,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints return true; case MouseButton.Right: - EndPlacement(HitObject.Duration > 0); + EndPlacement(true); return true; } diff --git a/osu.Game.Rulesets.Catch/Edit/CatchBeatSnapGrid.cs b/osu.Game.Rulesets.Catch/Edit/CatchBeatSnapGrid.cs new file mode 100644 index 0000000000..6862696b3a --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/CatchBeatSnapGrid.cs @@ -0,0 +1,180 @@ +// 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.Allocation; +using osu.Framework.Caching; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Screens.Edit; +using osuTK.Graphics; + +namespace osu.Game.Rulesets.Catch.Edit +{ + /// + /// A grid which displays coloured beat divisor lines in proximity to the selection or placement cursor. + /// + /// + /// This class heavily borrows from osu!mania's implementation (ManiaBeatSnapGrid). + /// If further changes are to be made, they should also be applied there. + /// If the scale of the changes are large enough, abstracting may be a good path. + /// + public partial class CatchBeatSnapGrid : Component + { + private const double visible_range = 750; + + /// + /// The range of time values of the current selection. + /// + public (double start, double end)? SelectionTimeRange + { + set + { + if (value == selectionTimeRange) + return; + + selectionTimeRange = value; + lineCache.Invalidate(); + } + } + + [Resolved] + private EditorBeatmap beatmap { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private BindableBeatDivisor beatDivisor { get; set; } = null!; + + private readonly Cached lineCache = new Cached(); + + private (double start, double end)? selectionTimeRange; + + private ScrollingHitObjectContainer lineContainer = null!; + + [BackgroundDependencyLoader] + private void load(HitObjectComposer composer) + { + lineContainer = new ScrollingHitObjectContainer(); + + ((CatchPlayfield)composer.Playfield).UnderlayElements.Add(lineContainer); + + beatDivisor.BindValueChanged(_ => createLines(), true); + } + + protected override void Update() + { + base.Update(); + + if (!lineCache.IsValid) + { + lineCache.Validate(); + createLines(); + } + } + + private readonly Stack availableLines = new Stack(); + + private void createLines() + { + foreach (var line in lineContainer.Objects.OfType()) + availableLines.Push(line); + + lineContainer.Clear(); + + if (selectionTimeRange == null) + return; + + var range = selectionTimeRange.Value; + + var timingPoint = beatmap.ControlPointInfo.TimingPointAt(range.start - visible_range); + + double time = timingPoint.Time; + int beat = 0; + + // progress time until in the visible range. + while (time < range.start - visible_range) + { + time += timingPoint.BeatLength / beatDivisor.Value; + beat++; + } + + while (time < range.end + visible_range) + { + var nextTimingPoint = beatmap.ControlPointInfo.TimingPointAt(time); + + // switch to the next timing point if we have reached it. + if (nextTimingPoint.Time > timingPoint.Time) + { + beat = 0; + time = nextTimingPoint.Time; + timingPoint = nextTimingPoint; + } + + Color4 colour = BindableBeatDivisor.GetColourFor( + BindableBeatDivisor.GetDivisorForBeatIndex(beat, beatDivisor.Value), colours); + + if (!availableLines.TryPop(out var line)) + line = new DrawableGridLine(); + + line.HitObject.StartTime = time; + line.Colour = colour; + + lineContainer.Add(line); + + beat++; + time += timingPoint.BeatLength / beatDivisor.Value; + } + + // required to update ScrollingHitObjectContainer's cache. + lineContainer.UpdateSubTree(); + + foreach (var line in lineContainer.Objects.OfType()) + { + time = line.HitObject.StartTime; + + if (time >= range.start && time <= range.end) + line.Alpha = 1; + else + { + double timeSeparation = time < range.start ? range.start - time : time - range.end; + line.Alpha = (float)Math.Max(0, 1 - timeSeparation / visible_range); + } + } + } + + private partial class DrawableGridLine : DrawableHitObject + { + public DrawableGridLine() + : base(new HitObject()) + { + RelativeSizeAxes = Axes.X; + Height = 2; + + AddInternal(new Box { RelativeSizeAxes = Axes.Both }); + } + + [BackgroundDependencyLoader] + private void load() + { + Origin = Anchor.BottomLeft; + Anchor = Anchor.BottomLeft; + } + + protected override void UpdateInitialTransforms() + { + // don't perform any fading – we are handling that ourselves. + LifetimeEnd = HitObject.StartTime + visible_range; + } + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs index cd8894753f..f2877572e8 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs @@ -33,6 +33,8 @@ namespace osu.Game.Rulesets.Catch.Edit private InputManager inputManager = null!; + private CatchBeatSnapGrid beatSnapGrid = null!; + private readonly BindableDouble timeRangeMultiplier = new BindableDouble(1) { MinValue = 1, @@ -65,6 +67,8 @@ namespace osu.Game.Rulesets.Catch.Edit Catcher.BASE_DASH_SPEED, -Catcher.BASE_DASH_SPEED, Catcher.BASE_WALK_SPEED, -Catcher.BASE_WALK_SPEED, })); + + AddInternal(beatSnapGrid = new CatchBeatSnapGrid()); } protected override void LoadComplete() @@ -74,6 +78,29 @@ namespace osu.Game.Rulesets.Catch.Edit inputManager = GetContainingInputManager(); } + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + if (BlueprintContainer.CurrentTool is SelectTool) + { + if (EditorBeatmap.SelectedHitObjects.Any()) + { + beatSnapGrid.SelectionTimeRange = (EditorBeatmap.SelectedHitObjects.Min(h => h.StartTime), EditorBeatmap.SelectedHitObjects.Max(h => h.GetEndTime())); + } + else + beatSnapGrid.SelectionTimeRange = null; + } + else + { + var result = FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position); + if (result.Time is double time) + beatSnapGrid.SelectionTimeRange = (time, time); + else + beatSnapGrid.SelectionTimeRange = null; + } + } + protected override double ReadCurrentDistanceSnap(HitObject before, HitObject after) { // osu!catch's distance snap implementation is limited, in that a custom spacing cannot be specified. @@ -132,7 +159,7 @@ namespace osu.Game.Rulesets.Catch.Edit result.ScreenSpacePosition.X = screenSpacePosition.X; - if (snapType.HasFlagFast(SnapType.Grids)) + if (snapType.HasFlagFast(SnapType.RelativeGrids)) { if (distanceSnapGrid.IsPresent && distanceSnapGrid.GetSnappedPosition(result.ScreenSpacePosition) is SnapResult snapResult && Vector2.Distance(snapResult.ScreenSpacePosition, result.ScreenSpacePosition) < distance_snap_radius) diff --git a/osu.Game.Rulesets.Catch/Objects/Banana.cs b/osu.Game.Rulesets.Catch/Objects/Banana.cs index 4c66c054e1..b80527f379 100644 --- a/osu.Game.Rulesets.Catch/Objects/Banana.cs +++ b/osu.Game.Rulesets.Catch/Objects/Banana.cs @@ -58,7 +58,7 @@ namespace osu.Game.Rulesets.Catch.Objects { } - public sealed override HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default) + public sealed override HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default) => new BananaHitSampleInfo(newVolume.GetOr(Volume)); public bool Equals(BananaHitSampleInfo? other) diff --git a/osu.Game.Rulesets.Catch/Objects/BananaShower.cs b/osu.Game.Rulesets.Catch/Objects/BananaShower.cs index 5bd4ac86f5..b05c8e5f77 100644 --- a/osu.Game.Rulesets.Catch/Objects/BananaShower.cs +++ b/osu.Game.Rulesets.Catch/Objects/BananaShower.cs @@ -41,7 +41,7 @@ namespace osu.Game.Rulesets.Catch.Objects { StartTime = time, BananaIndex = i, - Samples = new List { new Banana.BananaHitSampleInfo(GetSampleInfo().Volume) } + Samples = new List { new Banana.BananaHitSampleInfo(CreateHitSampleInfo().Volume) } }); time += spacing; diff --git a/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs b/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs index b6a42407da..9323296b7f 100644 --- a/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs +++ b/osu.Game.Rulesets.Catch/Scoring/CatchScoreProcessor.cs @@ -1,17 +1,30 @@ // 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.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Catch.Scoring { public partial class CatchScoreProcessor : ScoreProcessor { + private const int combo_cap = 200; + private const double combo_base = 4; + public CatchScoreProcessor() : base(new CatchRuleset()) { } - protected override double ClassicScoreMultiplier => 28; + protected override double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion) + { + return 600000 * comboProgress + + 400000 * Accuracy.Value * accuracyProgress + + bonusPortion; + } + + protected override double GetComboScoreChange(JudgementResult result) + => Judgement.ToNumericResult(result.Type) * Math.Min(Math.Max(0.5, Math.Log(result.ComboAfterJudgement, combo_base)), Math.Log(combo_cap, combo_base)); } } diff --git a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs index cf7337fd0d..f091dee845 100644 --- a/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs +++ b/osu.Game.Rulesets.Catch/UI/CatchPlayfield.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Objects.Drawables; @@ -41,6 +42,8 @@ namespace osu.Game.Rulesets.Catch.UI internal CatcherArea CatcherArea { get; private set; } = null!; + public Container UnderlayElements { get; private set; } = null!; + private readonly IBeatmapDifficultyInfo difficulty; public CatchPlayfield(IBeatmapDifficultyInfo difficulty) @@ -62,6 +65,10 @@ namespace osu.Game.Rulesets.Catch.UI AddRangeInternal(new[] { + UnderlayElements = new Container + { + RelativeSizeAxes = Axes.Both, + }, droppedObjectContainer, Catcher.CreateProxiedContent(), HitObjectContainer.CreateProxy(), diff --git a/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs b/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs index f85e303940..6485cbb76b 100644 --- a/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs +++ b/osu.Game.Rulesets.Mania.Tests/Skinning/TestScenePlayfield.cs @@ -8,6 +8,7 @@ using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.UI; +using osuTK; namespace osu.Game.Rulesets.Mania.Tests.Skinning { @@ -25,22 +26,35 @@ namespace osu.Game.Rulesets.Mania.Tests.Skinning new StageDefinition(2) }; - SetContents(_ => new ManiaPlayfield(stageDefinitions)); + SetContents(_ => new ManiaInputManager(new ManiaRuleset().RulesetInfo, 2) + { + Child = new ManiaPlayfield(stageDefinitions) + }); }); } - [Test] - public void TestDualStages() + [TestCase(2)] + [TestCase(3)] + [TestCase(5)] + public void TestDualStages(int columnCount) { AddStep("create stage", () => { stageDefinitions = new List { - new StageDefinition(2), - new StageDefinition(2) + new StageDefinition(columnCount), + new StageDefinition(columnCount) }; - SetContents(_ => new ManiaPlayfield(stageDefinitions)); + SetContents(_ => new ManiaInputManager(new ManiaRuleset().RulesetInfo, (int)PlayfieldType.Dual + 2 * columnCount) + { + Child = new ManiaPlayfield(stageDefinitions) + { + // bit of a hack to make sure the dual stages fit on screen without overlapping each other. + Size = new Vector2(1.5f), + Scale = new Vector2(1 / 1.5f) + } + }); }); } diff --git a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs index 632b7cdcc7..bdc5a00583 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/ManiaBeatmapConverter.cs @@ -119,14 +119,12 @@ namespace osu.Game.Rulesets.Mania.Beatmaps yield return obj; } - private readonly List prevNoteTimes = new List(max_notes_for_density); + private readonly LimitedCapacityQueue prevNoteTimes = new LimitedCapacityQueue(max_notes_for_density); private double density = int.MaxValue; private void computeDensity(double newNoteTime) { - if (prevNoteTimes.Count == max_notes_for_density) - prevNoteTimes.RemoveAt(0); - prevNoteTimes.Add(newNoteTime); + prevNoteTimes.Enqueue(newNoteTime); if (prevNoteTimes.Count >= 2) density = (prevNoteTimes[^1] - prevNoteTimes[0]) / prevNoteTimes.Count; diff --git a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs index 99a80ef28d..f975c7f1d4 100644 --- a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs +++ b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs @@ -21,18 +21,29 @@ namespace osu.Game.Rulesets.Mania.Configuration { base.InitialiseDefaults(); - SetDefault(ManiaRulesetSetting.ScrollTime, 1500.0, DrawableManiaRuleset.MIN_TIME_RANGE, DrawableManiaRuleset.MAX_TIME_RANGE, 5); + SetDefault(ManiaRulesetSetting.ScrollSpeed, 8, 1, 40); SetDefault(ManiaRulesetSetting.ScrollDirection, ManiaScrollingDirection.Down); SetDefault(ManiaRulesetSetting.TimingBasedNoteColouring, false); + +#pragma warning disable CS0618 + // Although obsolete, this is still required to populate the bindable from the database in case migration is required. + SetDefault(ManiaRulesetSetting.ScrollTime, null); + + if (Get(ManiaRulesetSetting.ScrollTime) is double scrollTime) + { + SetValue(ManiaRulesetSetting.ScrollSpeed, (int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / scrollTime)); + SetValue(ManiaRulesetSetting.ScrollTime, null); + } +#pragma warning restore CS0618 } public override TrackedSettings CreateTrackedSettings() => new TrackedSettings { - new TrackedSetting(ManiaRulesetSetting.ScrollTime, - scrollTime => new SettingDescription( - rawValue: scrollTime, + new TrackedSetting(ManiaRulesetSetting.ScrollSpeed, + speed => new SettingDescription( + rawValue: speed, name: RulesetSettingsStrings.ScrollSpeed, - value: RulesetSettingsStrings.ScrollSpeedTooltip(scrollTime, (int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / scrollTime)) + value: RulesetSettingsStrings.ScrollSpeedTooltip((int)DrawableManiaRuleset.ComputeScrollTime(speed), speed) ) ) }; @@ -40,7 +51,9 @@ namespace osu.Game.Rulesets.Mania.Configuration public enum ManiaRulesetSetting { + [Obsolete("Use ScrollSpeed instead.")] // Can be removed 2023-11-30 ScrollTime, + ScrollSpeed, ScrollDirection, TimingBasedNoteColouring } diff --git a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs index 21beee0769..381af8be7f 100644 --- a/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs +++ b/osu.Game.Rulesets.Mania/Edit/Blueprints/HoldNotePlacementBlueprint.cs @@ -25,6 +25,8 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints [Resolved] private IScrollingInfo scrollingInfo { get; set; } + protected override bool IsValidForPlacement => HitObject.Duration > 0; + public HoldNotePlacementBlueprint() : base(new HoldNote()) { @@ -75,7 +77,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints return; base.OnMouseUp(e); - EndPlacement(HitObject.Duration > 0); + EndPlacement(true); } private double originalStartTime; diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs b/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs index d1d5492b7a..2d4b5f718c 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs @@ -129,7 +129,7 @@ namespace osu.Game.Rulesets.Mania.Edit } Color4 colour = BindableBeatDivisor.GetColourFor( - BindableBeatDivisor.GetDivisorForBeatIndex(Math.Max(1, beat), beatDivisor.Value), colours); + BindableBeatDivisor.GetDivisorForBeatIndex(beat, beatDivisor.Value), colours); foreach (var grid in grids) { diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index d324682989..e8fda3ec80 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -389,41 +389,23 @@ namespace osu.Game.Rulesets.Mania return base.GetDisplayNameForHitResult(result); } - public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[] + public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[] { - new StatisticRow + new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap) { - Columns = new[] - { - new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap) - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y - }), - } - }, - new StatisticRow + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + }), + new StatisticItem("Timing Distribution", () => new HitEventTimingDistributionGraph(score.HitEvents) { - Columns = new[] - { - new StatisticItem("Timing Distribution", () => new HitEventTimingDistributionGraph(score.HitEvents) - { - RelativeSizeAxes = Axes.X, - Height = 250 - }, true), - } - }, - new StatisticRow + RelativeSizeAxes = Axes.X, + Height = 250 + }, true), + new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[] { - Columns = new[] - { - new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[] - { - new AverageHitError(score.HitEvents), - new UnstableRate(score.HitEvents) - }), true) - } - } + new AverageHitError(score.HitEvents), + new UnstableRate(score.HitEvents) + }), true) }; public override IRulesetFilterCriteria CreateRulesetFilterCriteria() diff --git a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs index fc0b4a9ed9..065534eec4 100644 --- a/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs +++ b/osu.Game.Rulesets.Mania/ManiaSettingsSubsection.cs @@ -1,7 +1,6 @@ // 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.Graphics; using osu.Framework.Localisation; @@ -34,10 +33,10 @@ namespace osu.Game.Rulesets.Mania LabelText = RulesetSettingsStrings.ScrollingDirection, Current = config.GetBindable(ManiaRulesetSetting.ScrollDirection) }, - new SettingsSlider + new SettingsSlider { LabelText = RulesetSettingsStrings.ScrollSpeed, - Current = config.GetBindable(ManiaRulesetSetting.ScrollTime), + Current = config.GetBindable(ManiaRulesetSetting.ScrollSpeed), KeyboardStep = 5 }, new SettingsCheckbox @@ -48,9 +47,9 @@ namespace osu.Game.Rulesets.Mania }; } - private partial class ManiaScrollSlider : RoundedSliderBar + private partial class ManiaScrollSlider : RoundedSliderBar { - public override LocalisableString TooltipText => RulesetSettingsStrings.ScrollSpeedTooltip(Current.Value, (int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / Current.Value)); + public override LocalisableString TooltipText => RulesetSettingsStrings.ScrollSpeedTooltip((int)DrawableManiaRuleset.ComputeScrollTime(Current.Value), Current.Value); } } } diff --git a/osu.Game.Rulesets.Mania/Objects/BarLine.cs b/osu.Game.Rulesets.Mania/Objects/BarLine.cs index 3f04a4fafe..09a746042b 100644 --- a/osu.Game.Rulesets.Mania/Objects/BarLine.cs +++ b/osu.Game.Rulesets.Mania/Objects/BarLine.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; diff --git a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs index ce34addeff..a8563d65c4 100644 --- a/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/Drawables/DrawableHoldNote.cs @@ -242,15 +242,23 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables bodyPiece.Y = (Direction.Value == ScrollingDirection.Up ? 1 : -1) * Head.Height / 2; bodyPiece.Height = DrawHeight - Head.Height / 2 + Tail.Height / 2; - // As the note is being held, adjust the size of the sizing container. This has two effects: - // 1. The contained masking container will mask the body and ticks. - // 2. The head note will move along with the new "head position" in the container. - if (Head.IsHit && releaseTime == null && DrawHeight > 0) + if (Time.Current >= HitObject.StartTime) { - // How far past the hit target this hold note is. - float yOffset = Direction.Value == ScrollingDirection.Up ? -Y : Y; - sizingContainer.Height = 1 - yOffset / DrawHeight; + // As the note is being held, adjust the size of the sizing container. This has two effects: + // 1. The contained masking container will mask the body and ticks. + // 2. The head note will move along with the new "head position" in the container. + // + // As per stable, this should not apply for early hits, waiting until the object starts to touch the + // judgement area first. + if (Head.IsHit && releaseTime == null && DrawHeight > 0) + { + // How far past the hit target this hold note is. + float yOffset = Direction.Value == ScrollingDirection.Up ? -Y : Y; + sizingContainer.Height = 1 - yOffset / DrawHeight; + } } + else + sizingContainer.Height = 1; } protected override void CheckForResult(bool userTriggered, double timeOffset) diff --git a/osu.Game.Rulesets.Mania/Objects/HeadNote.cs b/osu.Game.Rulesets.Mania/Objects/HeadNote.cs index fb5c7b4ddd..e69cc62aed 100644 --- a/osu.Game.Rulesets.Mania/Objects/HeadNote.cs +++ b/osu.Game.Rulesets.Mania/Objects/HeadNote.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Rulesets.Mania.Objects { public class HeadNote : Note diff --git a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs index f724972a29..6292ed75cd 100644 --- a/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs +++ b/osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs @@ -1,23 +1,29 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Mania.Scoring { - internal partial class ManiaScoreProcessor : ScoreProcessor + public partial class ManiaScoreProcessor : ScoreProcessor { + private const double combo_base = 4; + public ManiaScoreProcessor() : base(new ManiaRuleset()) { } - protected override double DefaultAccuracyPortion => 0.99; + protected override double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion) + { + return 10000 * comboProgress + + 990000 * Math.Pow(Accuracy.Value, 2 + 2 * Accuracy.Value) * accuracyProgress + + bonusPortion; + } - protected override double DefaultComboPortion => 0.01; - - protected override double ClassicScoreMultiplier => 16; + protected override double GetComboScoreChange(JudgementResult result) + => Judgement.ToNumericResult(result.Type) * Math.Min(Math.Max(0.5, Math.Log(result.ComboAfterJudgement, combo_base)), Math.Log(400, combo_base)); } } diff --git a/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs index 007d02400a..ddd6365c25 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Argon/ManiaArgonSkinTransformer.cs @@ -139,11 +139,11 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon case 3: switch (columnIndex) { - case 0: return colour_pink; + case 0: return colour_green; - case 1: return colour_orange; + case 1: return colour_special_column; - case 2: return colour_yellow; + case 2: return colour_cyan; default: throw new ArgumentOutOfRangeException(); } @@ -185,11 +185,11 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon case 1: return colour_orange; - case 2: return colour_yellow; + case 2: return colour_green; case 3: return colour_cyan; - case 4: return colour_purple; + case 4: return colour_orange; case 5: return colour_pink; @@ -201,17 +201,17 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon { case 0: return colour_pink; - case 1: return colour_cyan; + case 1: return colour_orange; case 2: return colour_pink; case 3: return colour_special_column; - case 4: return colour_green; + case 4: return colour_pink; - case 5: return colour_cyan; + case 5: return colour_orange; - case 6: return colour_green; + case 6: return colour_pink; default: throw new ArgumentOutOfRangeException(); } @@ -225,9 +225,9 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon case 2: return colour_orange; - case 3: return colour_yellow; + case 3: return colour_green; - case 4: return colour_yellow; + case 4: return colour_cyan; case 5: return colour_orange; @@ -273,9 +273,9 @@ namespace osu.Game.Rulesets.Mania.Skinning.Argon case 3: return colour_yellow; - case 4: return colour_cyan; + case 4: return colour_green; - case 5: return colour_green; + case 5: return colour_cyan; case 6: return colour_yellow; diff --git a/osu.Game.Rulesets.Mania/Skinning/Default/ManiaTrianglesSkinTransformer.cs b/osu.Game.Rulesets.Mania/Skinning/Default/ManiaTrianglesSkinTransformer.cs index eb51179cea..3e0fe8ed4b 100644 --- a/osu.Game.Rulesets.Mania/Skinning/Default/ManiaTrianglesSkinTransformer.cs +++ b/osu.Game.Rulesets.Mania/Skinning/Default/ManiaTrianglesSkinTransformer.cs @@ -35,10 +35,12 @@ namespace osu.Game.Rulesets.Mania.Skinning.Default var stage = beatmap.GetStageForColumnIndex(column); - if (stage.IsSpecialColumn(column)) + int columnInStage = column % stage.Columns; + + if (stage.IsSpecialColumn(columnInStage)) return SkinUtils.As(new Bindable(colourSpecial)); - int distanceToEdge = Math.Min(column, (stage.Columns - 1) - column); + int distanceToEdge = Math.Min(columnInStage, (stage.Columns - 1) - columnInStage); return SkinUtils.As(new Bindable(distanceToEdge % 2 == 0 ? colourOdd : colourEven)); } } diff --git a/osu.Game.Rulesets.Mania/UI/Column.cs b/osu.Game.Rulesets.Mania/UI/Column.cs index 6ca830a82f..f38571a6d3 100644 --- a/osu.Game.Rulesets.Mania/UI/Column.cs +++ b/osu.Game.Rulesets.Mania/UI/Column.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Pooling; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Platform; +using osu.Game.Extensions; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects.Drawables; @@ -39,7 +40,11 @@ namespace osu.Game.Rulesets.Mania.UI public readonly Bindable Action = new Bindable(); public readonly ColumnHitObjectArea HitObjectArea; + + internal readonly Container BackgroundContainer = new Container { RelativeSizeAxes = Axes.Both }; + internal readonly Container TopLevelContainer = new Container { RelativeSizeAxes = Axes.Both }; + private DrawablePool hitExplosionPool; private readonly OrderedHitPolicy hitPolicy; public Container UnderlayElements => HitObjectArea.UnderlayElements; @@ -76,30 +81,31 @@ namespace osu.Game.Rulesets.Mania.UI skin.SourceChanged += onSourceChanged; onSourceChanged(); - Drawable background = new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.ColumnBackground), _ => new DefaultColumnBackground()) - { - RelativeSizeAxes = Axes.Both, - }; - - InternalChildren = new[] + InternalChildren = new Drawable[] { hitExplosionPool = new DrawablePool(5), sampleTriggerSource = new GameplaySampleTriggerSource(HitObjectContainer), - // For input purposes, the background is added at the highest depth, but is then proxied back below all other elements - background.CreateProxy(), HitObjectArea, keyArea = new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.KeyArea), _ => new DefaultKeyArea()) { RelativeSizeAxes = Axes.Both, }, - background, + // For input purposes, the background is added at the highest depth, but is then proxied back below all other elements externally + // (see `Stage.columnBackgrounds`). + BackgroundContainer, TopLevelContainer, new ColumnTouchInputArea(this) }; - applyGameWideClock(background); - applyGameWideClock(keyArea); + var background = new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.ColumnBackground), _ => new DefaultColumnBackground()) + { + RelativeSizeAxes = Axes.Both, + }; + background.ApplyGameWideClock(host); + keyArea.ApplyGameWideClock(host); + + BackgroundContainer.Add(background); TopLevelContainer.Add(HitObjectArea.Explosions.CreateProxy()); RegisterPool(10, 50); @@ -107,18 +113,6 @@ namespace osu.Game.Rulesets.Mania.UI RegisterPool(10, 50); RegisterPool(10, 50); RegisterPool(50, 250); - - // Some elements don't handle rewind correctly and fixing them is non-trivial. - // In the future we need a better solution to this, but as a temporary work-around, give these components the game-wide - // clock so they don't need to worry about rewind. - // This only works because they handle OnPressed/OnReleased which results in a correct state while rewinding. - // - // This is kinda dodgy (and will cause weirdness when pausing gameplay) but is better than completely broken rewind. - void applyGameWideClock(Drawable drawable) - { - drawable.Clock = host.UpdateThread.Clock; - drawable.ProcessCustomClock = false; - } } private void onSourceChanged() diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs index af8758fb5e..2d373c0471 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs @@ -33,12 +33,12 @@ namespace osu.Game.Rulesets.Mania.UI public partial class DrawableManiaRuleset : DrawableScrollingRuleset { /// - /// The minimum time range. This occurs at a of 40. + /// The minimum time range. This occurs at a of 40. /// public const double MIN_TIME_RANGE = 290; /// - /// The maximum time range. This occurs at a of 1. + /// The maximum time range. This occurs with a of 1. /// public const double MAX_TIME_RANGE = 11485; @@ -69,7 +69,8 @@ namespace osu.Game.Rulesets.Mania.UI protected override ScrollVisualisationMethod VisualisationMethod => scrollMethod; private readonly Bindable configDirection = new Bindable(); - private readonly BindableDouble configTimeRange = new BindableDouble(); + private readonly BindableInt configScrollSpeed = new BindableInt(); + private double smoothTimeRange; // Stores the current speed adjustment active in gameplay. private readonly Track speedAdjustmentTrack = new TrackVirtual(0); @@ -78,6 +79,9 @@ namespace osu.Game.Rulesets.Mania.UI : base(ruleset, beatmap, mods) { BarLines = new BarLineGenerator(Beatmap).BarLines; + + TimeRange.MinValue = 1; + TimeRange.MaxValue = MAX_TIME_RANGE; } [BackgroundDependencyLoader] @@ -104,30 +108,28 @@ namespace osu.Game.Rulesets.Mania.UI Config.BindWith(ManiaRulesetSetting.ScrollDirection, configDirection); configDirection.BindValueChanged(direction => Direction.Value = (ScrollingDirection)direction.NewValue, true); - Config.BindWith(ManiaRulesetSetting.ScrollTime, configTimeRange); - TimeRange.MinValue = configTimeRange.MinValue; - TimeRange.MaxValue = configTimeRange.MaxValue; + Config.BindWith(ManiaRulesetSetting.ScrollSpeed, configScrollSpeed); + configScrollSpeed.BindValueChanged(speed => this.TransformTo(nameof(smoothTimeRange), ComputeScrollTime(speed.NewValue), 200, Easing.OutQuint)); + + TimeRange.Value = smoothTimeRange = ComputeScrollTime(configScrollSpeed.Value); } - protected override void AdjustScrollSpeed(int amount) - { - this.TransformTo(nameof(relativeTimeRange), relativeTimeRange + amount, 200, Easing.OutQuint); - } - - private double relativeTimeRange - { - get => MAX_TIME_RANGE / configTimeRange.Value; - set => configTimeRange.Value = MAX_TIME_RANGE / value; - } + protected override void AdjustScrollSpeed(int amount) => configScrollSpeed.Value += amount; protected override void Update() { base.Update(); - updateTimeRange(); } - private void updateTimeRange() => TimeRange.Value = configTimeRange.Value * speedAdjustmentTrack.AggregateTempo.Value * speedAdjustmentTrack.AggregateFrequency.Value; + private void updateTimeRange() => TimeRange.Value = smoothTimeRange * speedAdjustmentTrack.AggregateTempo.Value * speedAdjustmentTrack.AggregateFrequency.Value; + + /// + /// Computes a scroll time (in milliseconds) from a scroll speed in the range of 1-40. + /// + /// The scroll speed. + /// The scroll time. + public static double ComputeScrollTime(int scrollSpeed) => MAX_TIME_RANGE / scrollSpeed; public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new ManiaPlayfieldAdjustmentContainer(); diff --git a/osu.Game.Rulesets.Mania/UI/Stage.cs b/osu.Game.Rulesets.Mania/UI/Stage.cs index c1d3e85bf1..879c704450 100644 --- a/osu.Game.Rulesets.Mania/UI/Stage.cs +++ b/osu.Game.Rulesets.Mania/UI/Stage.cs @@ -60,6 +60,7 @@ namespace osu.Game.Rulesets.Mania.UI RelativeSizeAxes = Axes.Y; AutoSizeAxes = Axes.X; + Container columnBackgrounds; Container topLevelContainer; InternalChildren = new Drawable[] @@ -77,9 +78,10 @@ namespace osu.Game.Rulesets.Mania.UI { RelativeSizeAxes = Axes.Both }, - columnFlow = new ColumnFlow(definition) + columnBackgrounds = new Container { - RelativeSizeAxes = Axes.Y, + Name = "Column backgrounds", + RelativeSizeAxes = Axes.Both, }, new Container { @@ -98,6 +100,10 @@ namespace osu.Game.Rulesets.Mania.UI RelativeSizeAxes = Axes.Y, } }, + columnFlow = new ColumnFlow(definition) + { + RelativeSizeAxes = Axes.Y, + }, new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.StageForeground), _ => null) { RelativeSizeAxes = Axes.Both @@ -126,6 +132,7 @@ namespace osu.Game.Rulesets.Mania.UI }; topLevelContainer.Add(column.TopLevelContainer.CreateProxy()); + columnBackgrounds.Add(column.BackgroundContainer.CreateProxy()); columnFlow.SetContentForColumn(i, column); AddNested(column); } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectObjectSnap.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectObjectSnap.cs index 3b8a5a90a5..9af1855167 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectObjectSnap.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneObjectObjectSnap.cs @@ -43,7 +43,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor AddStep("place first object", () => InputManager.Click(MouseButton.Left)); - AddStep("move mouse slightly", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(playfield.ScreenSpaceDrawQuad.Width * 0.02f, 0))); + AddStep("move mouse slightly", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(playfield.ScreenSpaceDrawQuad.Width * 0.01f, 0))); AddStep("place second object", () => InputManager.Click(MouseButton.Left)); @@ -75,7 +75,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor AddStep("enter circle placement mode", () => InputManager.Key(Key.Number2)); - AddStep("move mouse slightly", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(playfield.ScreenSpaceDrawQuad.Width * 0.235f, 0))); + AddStep("move mouse slightly", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(playfield.ScreenSpaceDrawQuad.Width * 0.205f, 0))); AddStep("place second object", () => InputManager.Click(MouseButton.Left)); @@ -122,7 +122,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor AddStep("begin drag", () => InputManager.PressButton(MouseButton.Left)); - AddStep("move mouse slightly off centre", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(playfield.ScreenSpaceDrawQuad.Width * 0.02f, 0))); + AddStep("move mouse slightly off centre", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(playfield.ScreenSpaceDrawQuad.Width * 0.01f, 0))); AddAssert("object 3 snapped to 1", () => { @@ -134,7 +134,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor return Precision.AlmostEquals(first.EndPosition, third.Position); }); - AddStep("move mouse slightly off centre", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(playfield.ScreenSpaceDrawQuad.Width * -0.22f, playfield.ScreenSpaceDrawQuad.Width * 0.21f))); + AddStep("move mouse slightly off centre", () => InputManager.MoveMouseTo(playfield.ScreenSpaceDrawQuad.Centre + new Vector2(playfield.ScreenSpaceDrawQuad.Width * -0.21f, playfield.ScreenSpaceDrawQuad.Width * 0.205f))); AddAssert("object 2 snapped to 1", () => { diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs index a104433ea9..605771fb20 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneSliderSplitting.cs @@ -181,7 +181,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor { if (slider is null) return; - sample = new HitSampleInfo("hitwhistle", "soft", volume: 70); + sample = new HitSampleInfo("hitwhistle", HitSampleInfo.BANK_SOFT, volume: 70); slider.Samples.Add(sample.With()); }); diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs index 8fdab9f1f9..32028823ae 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs @@ -1,22 +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 System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Skinning.Default; using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Scoring; +using osuTK; namespace osu.Game.Rulesets.Osu.Tests.Mods { public partial class TestSceneOsuModAutoplay : OsuModTestScene { + protected override bool AllowFail => true; + + [Test] + public void TestCursorPositionStoredToJudgement() + { + CreateModTest(new ModTestData + { + Autoplay = true, + PassCondition = () => + Player.ScoreProcessor.JudgedHits >= 1 + && Player.ScoreProcessor.HitEvents.Any(e => e.Position != null) + }); + } + [Test] public void TestSpmUnaffectedByRateAdjust() => runSpmTest(new OsuModDaycore @@ -32,6 +51,36 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods FinalRate = { Value = 1.3 } }); + [Test] + public void TestPerfectScoreOnShortSliderWithRepeat() + { + AddStep("set score to standardised", () => LocalConfig.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised)); + + CreateModTest(new ModTestData + { + Autoplay = true, + Beatmap = new Beatmap + { + HitObjects = new List + { + new Slider + { + StartTime = 500, + Position = new Vector2(256, 192), + Path = new SliderPath(new[] + { + new PathControlPoint(), + new PathControlPoint(new Vector2(0, 6.25f)) + }), + RepeatCount = 1, + SliderVelocity = 10 + } + } + }, + PassCondition = () => Player.ScoreProcessor.TotalScore.Value == 1_000_000 + }); + } + private void runSpmTest(Mod mod) { SpinnerSpmCalculator? spmCalculator = null; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs index 74d0fb42a3..8cfd674f88 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinner.cs @@ -74,7 +74,7 @@ namespace osu.Game.Rulesets.Osu.Tests EndTime = Time.Current + delay + length, Samples = new List { - new HitSampleInfo("hitnormal") + new HitSampleInfo(HitSampleInfo.HIT_NORMAL) } }; diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs index 17d0fc457a..c56ffcb140 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs @@ -309,7 +309,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components } else { - var result = snapProvider?.FindSnappedPositionAndTime(Parent.ToScreenSpace(e.MousePosition)); + var result = snapProvider?.FindSnappedPositionAndTime(Parent.ToScreenSpace(e.MousePosition), SnapType.GlobalGrids); Vector2 movementDelta = Parent.ToLocalSpace(result?.ScreenSpacePosition ?? Parent.ToScreenSpace(e.MousePosition)) - dragStartPositions[draggedControlPointIndex] - hitObject.Position; diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs index 50514865e1..966092c6fe 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs @@ -41,6 +41,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders [Resolved(CanBeNull = true)] private IDistanceSnapProvider snapProvider { get; set; } + protected override bool IsValidForPlacement => HitObject.Path.HasValidLength; + public SliderPlacementBlueprint() : base(new Slider()) { @@ -150,7 +152,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders private void endCurve() { updateSlider(); - EndPlacement(HitObject.Path.HasValidLength); + EndPlacement(true); } protected override void Update() @@ -196,7 +198,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders } // Update the cursor position. - var result = snapProvider?.FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position); + var result = snapProvider?.FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position, state == SliderPlacementState.Body ? SnapType.GlobalGrids : SnapType.All); cursor.Position = ToLocalSpace(result?.ScreenSpacePosition ?? inputManager.CurrentState.Mouse.Position) - HitObject.Position; } else if (cursor != null) diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPosition.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPosition.cs index 92071d4a57..616bb17e05 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPosition.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPosition.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { public enum SliderPosition diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs index 73ee5df9dc..f59be0e0e9 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Spinners/SpinnerPlacementBlueprint.cs @@ -1,10 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Input.Events; @@ -24,9 +21,8 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners private bool isPlacingEnd; - [Resolved(CanBeNull = true)] - [CanBeNull] - private IBeatSnapProvider beatSnapProvider { get; set; } + [Resolved] + private IBeatSnapProvider? beatSnapProvider { get; set; } public SpinnerPlacementBlueprint() : base(new Spinner { Position = OsuPlayfield.BASE_SIZE / 2 }) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index ff1e208186..aac5f6ffb1 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -143,7 +143,7 @@ namespace osu.Game.Rulesets.Osu.Edit // We want to ensure that in this particular case, the time-snapping component of distance snap is still applied. // The easiest way to ensure this is to attempt application of distance snap after a nearby object is found, and copy over // the time value if the proposed positions are roughly the same. - if (snapType.HasFlagFast(SnapType.Grids) && DistanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) + if (snapType.HasFlagFast(SnapType.RelativeGrids) && DistanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) { (Vector2 distanceSnappedPosition, double distanceSnappedTime) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(snapResult.ScreenSpacePosition)); if (Precision.AlmostEquals(distanceSnapGrid.ToScreenSpace(distanceSnappedPosition), snapResult.ScreenSpacePosition, 1)) @@ -155,7 +155,7 @@ namespace osu.Game.Rulesets.Osu.Edit SnapResult result = base.FindSnappedPositionAndTime(screenSpacePosition, snapType); - if (snapType.HasFlagFast(SnapType.Grids)) + if (snapType.HasFlagFast(SnapType.RelativeGrids)) { if (DistanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) { @@ -164,7 +164,10 @@ namespace osu.Game.Rulesets.Osu.Edit result.ScreenSpacePosition = distanceSnapGrid.ToScreenSpace(pos); result.Time = time; } + } + if (snapType.HasFlagFast(SnapType.GlobalGrids)) + { if (rectangularGridSnapToggle.Value == TernaryState.True) { Vector2 pos = rectangularPositionSnapGrid.GetSnappedPosition(rectangularPositionSnapGrid.ToLocalSpace(result.ScreenSpacePosition)); @@ -184,7 +187,7 @@ namespace osu.Game.Rulesets.Osu.Edit var playfield = PlayfieldAtScreenSpacePosition(screenSpacePosition); float snapRadius = - playfield.GamefieldToScreenSpace(new Vector2(OsuHitObject.OBJECT_RADIUS / 5)).X - + playfield.GamefieldToScreenSpace(new Vector2(OsuHitObject.OBJECT_RADIUS * 0.10f)).X - playfield.GamefieldToScreenSpace(Vector2.Zero).X; foreach (var b in blueprints) diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs index 4782a0e49c..3841c9c716 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModAutopilot.cs @@ -24,7 +24,17 @@ namespace osu.Game.Rulesets.Osu.Mods public override ModType Type => ModType.Automation; public override LocalisableString Description => @"Automatic cursor movement - just follow the rhythm."; public override double ScoreMultiplier => 0.1; - public override Type[] IncompatibleMods => new[] { typeof(OsuModSpunOut), typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail), typeof(ModAutoplay), typeof(OsuModMagnetised), typeof(OsuModRepel) }; + + public override Type[] IncompatibleMods => new[] + { + typeof(OsuModSpunOut), + typeof(ModRelax), + typeof(ModFailCondition), + typeof(ModNoFail), + typeof(ModAutoplay), + typeof(OsuModMagnetised), + typeof(OsuModRepel) + }; public bool PerformFail() => false; @@ -34,7 +44,7 @@ namespace osu.Game.Rulesets.Osu.Mods private List replayFrames = null!; - private int currentFrame; + private int currentFrame = -1; public void Update(Playfield playfield) { @@ -43,8 +53,9 @@ namespace osu.Game.Rulesets.Osu.Mods double time = playfield.Clock.CurrentTime; // Very naive implementation of autopilot based on proximity to replay frames. + // Special case for the first frame is required to ensure the mouse is in a sane position until the actual time of the first frame is hit. // TODO: this needs to be based on user interactions to better match stable (pausing until judgement is registered). - if (Math.Abs(replayFrames[currentFrame + 1].Time - time) <= Math.Abs(replayFrames[currentFrame].Time - time)) + if (currentFrame < 0 || Math.Abs(replayFrames[currentFrame + 1].Time - time) <= Math.Abs(replayFrames[currentFrame].Time - time)) { currentFrame++; new MousePositionAbsoluteInput { Position = playfield.ToScreenSpace(replayFrames[currentFrame].Position) }.Apply(inputManager.CurrentState, inputManager); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs b/osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs index 12e2090f89..b74b722bad 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModBubbles.cs @@ -42,14 +42,14 @@ namespace osu.Game.Rulesets.Osu.Mods private PlayfieldAdjustmentContainer bubbleContainer = null!; + private DrawablePool bubblePool = null!; + private readonly Bindable currentCombo = new BindableInt(); private float maxSize; private float bubbleSize; private double bubbleFade; - private readonly DrawablePool bubblePool = new DrawablePool(100); - public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank; public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) @@ -72,6 +72,7 @@ namespace osu.Game.Rulesets.Osu.Mods bubbleContainer = drawableRuleset.CreatePlayfieldAdjustmentContainer(); drawableRuleset.Overlays.Add(bubbleContainer); + drawableRuleset.Overlays.Add(bubblePool = new DrawablePool(100)); } public void ApplyToDrawableHitObject(DrawableHitObject drawableObject) diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs index 307d731fd4..3224ff9eaf 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRandom.cs @@ -9,7 +9,6 @@ using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Configuration; -using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Beatmaps; @@ -28,7 +27,7 @@ namespace osu.Game.Rulesets.Osu.Mods public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(OsuModTargetPractice)).ToArray(); - [SettingSource("Angle sharpness", "How sharp angles should be", SettingControlType = typeof(SettingsSlider))] + [SettingSource("Angle sharpness", "How sharp angles should be")] public BindableFloat AngleSharpness { get; } = new BindableFloat(7) { MinValue = 1, diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs index 32ffb545e0..aaa7c70a8d 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModRelax.cs @@ -18,7 +18,7 @@ using static osu.Game.Input.Handlers.ReplayInputHandler; namespace osu.Game.Rulesets.Osu.Mods { - public class OsuModRelax : ModRelax, IUpdatableByPlayfield, IApplicableToDrawableRuleset, IApplicableToPlayer + public class OsuModRelax : ModRelax, IUpdatableByPlayfield, IApplicableToDrawableRuleset, IApplicableToPlayer, IHasNoTimedInputs { public override LocalisableString Description => @"You don't need to click. Give your clicking/tapping fingers a break from the heat of things."; diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index 664a8146e7..01174d4d61 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -75,18 +75,24 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables [BackgroundDependencyLoader] private void load() { + tailContainer = new Container { RelativeSizeAxes = Axes.Both }; + AddRangeInternal(new Drawable[] { shakeContainer = new ShakeContainer { ShakeDuration = 30, RelativeSizeAxes = Axes.Both, - Children = new Drawable[] + Children = new[] { Body = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.SliderBody), _ => new DefaultSliderBody(), confineMode: ConfineMode.NoScaling), - tailContainer = new Container { RelativeSizeAxes = Axes.Both }, + // proxied here so that the tail is drawn under repeats/ticks - legacy skins rely on this + tailContainer.CreateProxy(), tickContainer = new Container { RelativeSizeAxes = Axes.Both }, repeatContainer = new Container { RelativeSizeAxes = Axes.Both }, + // actual tail container is placed here to ensure that tail hitobjects are processed after ticks/repeats. + // this is required for the correct operation of Score V2. + tailContainer, } }, // slider head is not included in shake as it handles hit detection, and handles its own shaking. diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/IRequireTracking.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/IRequireTracking.cs index 55de5a0e8d..b1815b23c9 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/IRequireTracking.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/IRequireTracking.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Rulesets.Osu.Objects.Drawables { public interface IRequireTracking diff --git a/osu.Game.Rulesets.Osu/Objects/ISliderProgress.cs b/osu.Game.Rulesets.Osu/Objects/ISliderProgress.cs index eddd251bda..7594f7c2e0 100644 --- a/osu.Game.Rulesets.Osu/Objects/ISliderProgress.cs +++ b/osu.Game.Rulesets.Osu/Objects/ISliderProgress.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Rulesets.Osu.Objects { public interface ISliderProgress diff --git a/osu.Game.Rulesets.Osu/Objects/Spinner.cs b/osu.Game.Rulesets.Osu/Objects/Spinner.cs index df5898fd67..ba0981e781 100644 --- a/osu.Game.Rulesets.Osu/Objects/Spinner.cs +++ b/osu.Game.Rulesets.Osu/Objects/Spinner.cs @@ -72,7 +72,7 @@ namespace osu.Game.Rulesets.Osu.Objects AddNested(i < SpinsRequired ? new SpinnerTick { StartTime = startTime, SpinnerDuration = Duration } - : new SpinnerBonusTick { StartTime = startTime, SpinnerDuration = Duration, Samples = new[] { GetSampleInfo("spinnerbonus") } }); + : new SpinnerBonusTick { StartTime = startTime, SpinnerDuration = Duration, Samples = new[] { CreateHitSampleInfo("spinnerbonus") } }); } } diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index c05e640022..4cff16b46f 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -292,56 +292,32 @@ namespace osu.Game.Rulesets.Osu return base.GetDisplayNameForHitResult(result); } - public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) + public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) { var timedHitEvents = score.HitEvents.Where(e => e.HitObject is HitCircle && !(e.HitObject is SliderTailCircle)).ToList(); return new[] { - new StatisticRow + new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap) { - Columns = new[] - { - new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap) - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y - }), - } - }, - new StatisticRow + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + }), + new StatisticItem("Timing Distribution", () => new HitEventTimingDistributionGraph(timedHitEvents) { - Columns = new[] - { - new StatisticItem("Timing Distribution", () => new HitEventTimingDistributionGraph(timedHitEvents) - { - RelativeSizeAxes = Axes.X, - Height = 250 - }, true), - } - }, - new StatisticRow + RelativeSizeAxes = Axes.X, + Height = 250 + }, true), + new StatisticItem("Accuracy Heatmap", () => new AccuracyHeatmap(score, playableBeatmap) { - Columns = new[] - { - new StatisticItem("Accuracy Heatmap", () => new AccuracyHeatmap(score, playableBeatmap) - { - RelativeSizeAxes = Axes.X, - Height = 250 - }, true), - } - }, - new StatisticRow + RelativeSizeAxes = Axes.X, + Height = 250 + }, true), + new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[] { - Columns = new[] - { - new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[] - { - new AverageHitError(timedHitEvents), - new UnstableRate(timedHitEvents) - }), true) - } - } + new AverageHitError(timedHitEvents), + new UnstableRate(timedHitEvents) + }), true) }; } diff --git a/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs b/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs index 50d4eb6258..f97be0d7ff 100644 --- a/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs +++ b/osu.Game.Rulesets.Osu/Scoring/OsuScoreProcessor.cs @@ -1,12 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System; using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Judgements; -using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Scoring @@ -18,21 +15,14 @@ namespace osu.Game.Rulesets.Osu.Scoring { } - protected override double ClassicScoreMultiplier => 36; - protected override HitEvent CreateHitEvent(JudgementResult result) => base.CreateHitEvent(result).With((result as OsuHitCircleJudgementResult)?.CursorPositionAtHit); - protected override JudgementResult CreateResult(HitObject hitObject, Judgement judgement) + protected override double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion) { - switch (hitObject) - { - case HitCircle: - return new OsuHitCircleJudgementResult(hitObject, judgement); - - default: - return new OsuJudgementResult(hitObject, judgement); - } + return 700000 * comboProgress + + 300000 * Math.Pow(Accuracy.Value, 10) * accuracyProgress + + bonusPortion; } } } diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonMainCirclePiece.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonMainCirclePiece.cs index 1c5cf49625..3427031dc8 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonMainCirclePiece.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonMainCirclePiece.cs @@ -48,21 +48,26 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon private Bindable configHitLighting = null!; + private static readonly Vector2 circle_size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + [Resolved] private DrawableHitObject drawableObject { get; set; } = null!; public ArgonMainCirclePiece(bool withOuterFill) { - Size = new Vector2(OsuHitObject.OBJECT_RADIUS * 2); + Size = circle_size; Anchor = Anchor.Centre; Origin = Anchor.Centre; InternalChildren = new Drawable[] { - outerFill = new Circle // renders white outer border and dark fill + outerFill = new Circle // renders dark fill { - Size = Size, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + // Slightly inset to prevent bleeding outside the ring + Size = circle_size - new Vector2(1), Alpha = withOuterFill ? 1 : 0, }, outerGradient = new Circle // renders the outer bright gradient @@ -88,7 +93,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon Masking = true, Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = Size, + Size = circle_size, Child = new KiaiFlash { RelativeSizeAxes = Axes.Both, diff --git a/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs b/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs index 0249b6d9b1..5d2f6a14c7 100644 --- a/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs +++ b/osu.Game.Rulesets.Osu/Statistics/AccuracyHeatmap.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Diagnostics; using System.Linq; @@ -13,6 +11,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Scoring; using osuTK; @@ -36,8 +35,8 @@ namespace osu.Game.Rulesets.Osu.Statistics private const float rotation = 45; - private BufferedContainer bufferedGrid; - private GridContainer pointGrid; + private BufferedContainer bufferedGrid = null!; + private GridContainer pointGrid = null!; private readonly ScoreInfo score; private readonly IBeatmap playableBeatmap; @@ -58,6 +57,8 @@ namespace osu.Game.Rulesets.Osu.Statistics [BackgroundDependencyLoader] private void load() { + const float line_extension = 0.2f; + InternalChild = new Container { Anchor = Anchor.Centre, @@ -66,76 +67,99 @@ namespace osu.Game.Rulesets.Osu.Statistics FillMode = FillMode.Fit, Children = new Drawable[] { - new CircularContainer - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - Size = new Vector2(inner_portion), - Masking = true, - BorderThickness = line_thickness, - BorderColour = Color4.White, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4Extensions.FromHex("#202624") - } - }, new Container { RelativeSizeAxes = Axes.Both, Children = new Drawable[] { + new CircularContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(inner_portion), + Masking = true, + BorderThickness = line_thickness, + BorderColour = Color4.White, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4Extensions.FromHex("#202624") + } + }, new Container { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding(1), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Rotation = rotation, Child = new Container { RelativeSizeAxes = Axes.Both, - Masking = true, Children = new Drawable[] { - new Box + new Circle { Anchor = Anchor.Centre, Origin = Anchor.Centre, - EdgeSmoothness = new Vector2(1), RelativeSizeAxes = Axes.Y, - Height = 2, // We're rotating along a diagonal - we don't really care how big this is. - Width = line_thickness / 2, - Rotation = -rotation, - Alpha = 0.3f, + Width = line_thickness, + Height = inner_portion + line_extension, + Rotation = -rotation * 2, + Alpha = 0.6f, }, - new Box + new Circle { Anchor = Anchor.Centre, Origin = Anchor.Centre, - EdgeSmoothness = new Vector2(1), RelativeSizeAxes = Axes.Y, - Height = 2, // We're rotating along a diagonal - we don't really care how big this is. - Width = line_thickness / 2, // adjust for edgesmoothness - Rotation = rotation + Width = line_thickness, + Height = inner_portion + line_extension, }, + new OsuSpriteText + { + Text = "Overshoot", + Anchor = Anchor.Centre, + Origin = Anchor.BottomCentre, + Padding = new MarginPadding(3), + RelativePositionAxes = Axes.Both, + Y = -(inner_portion + line_extension) / 2, + }, + new OsuSpriteText + { + Text = "Undershoot", + Anchor = Anchor.Centre, + Origin = Anchor.TopCentre, + Padding = new MarginPadding(3), + RelativePositionAxes = Axes.Both, + Y = (inner_portion + line_extension) / 2, + }, + new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.TopCentre, + RelativePositionAxes = Axes.Both, + Y = -(inner_portion + line_extension) / 2, + Margin = new MarginPadding(-line_thickness / 2), + Width = line_thickness, + Height = 10, + Rotation = 45, + }, + new Circle + { + Anchor = Anchor.Centre, + Origin = Anchor.TopCentre, + RelativePositionAxes = Axes.Both, + Y = -(inner_portion + line_extension) / 2, + Margin = new MarginPadding(-line_thickness / 2), + Width = line_thickness, + Height = 10, + Rotation = -45, + } } }, }, - new Box - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Width = 10, - EdgeSmoothness = new Vector2(1), - Height = line_thickness / 2, // adjust for edgesmoothness - }, - new Box - { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - EdgeSmoothness = new Vector2(1), - Width = line_thickness / 2, // adjust for edgesmoothness - Height = 10, - } } }, bufferedGrid = new BufferedContainer(cachedFrameBuffer: true) diff --git a/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneDrumRollJudgements.cs b/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneDrumRollJudgements.cs index a9231b4783..21f2b8f1be 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneDrumRollJudgements.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Judgements/TestSceneDrumRollJudgements.cs @@ -15,187 +15,175 @@ namespace osu.Game.Rulesets.Taiko.Tests.Judgements [Test] public void TestHitAllDrumRoll() { - const double hit_time = 1000; - PerformTest(new List { new TaikoReplayFrame(0), new TaikoReplayFrame(1000, TaikoAction.LeftCentre), new TaikoReplayFrame(1001), + new TaikoReplayFrame(1250, TaikoAction.LeftCentre), + new TaikoReplayFrame(1251), + new TaikoReplayFrame(1500, TaikoAction.LeftCentre), + new TaikoReplayFrame(1501), + new TaikoReplayFrame(1750, TaikoAction.LeftCentre), + new TaikoReplayFrame(1751), new TaikoReplayFrame(2000, TaikoAction.LeftCentre), new TaikoReplayFrame(2001), - }, CreateBeatmap(new DrumRoll - { - StartTime = hit_time, - Duration = 1000 - })); + }, CreateBeatmap(createDrumRoll(false))); - AssertJudgementCount(3); + AssertJudgementCount(6); AssertResult(0, HitResult.SmallBonus); AssertResult(1, HitResult.SmallBonus); + AssertResult(2, HitResult.SmallBonus); + AssertResult(3, HitResult.SmallBonus); + AssertResult(4, HitResult.SmallBonus); AssertResult(0, HitResult.IgnoreHit); } [Test] public void TestHitSomeDrumRoll() { - const double hit_time = 1000; - PerformTest(new List { new TaikoReplayFrame(0), new TaikoReplayFrame(2000, TaikoAction.LeftCentre), new TaikoReplayFrame(2001), - }, CreateBeatmap(new DrumRoll - { - StartTime = hit_time, - Duration = 1000 - })); + }, CreateBeatmap(createDrumRoll(false))); - AssertJudgementCount(3); + AssertJudgementCount(6); AssertResult(0, HitResult.IgnoreMiss); - AssertResult(1, HitResult.SmallBonus); + AssertResult(1, HitResult.IgnoreMiss); + AssertResult(2, HitResult.IgnoreMiss); + AssertResult(3, HitResult.IgnoreMiss); + AssertResult(4, HitResult.SmallBonus); AssertResult(0, HitResult.IgnoreHit); } [Test] public void TestHitNoneDrumRoll() { - const double hit_time = 1000; - PerformTest(new List { new TaikoReplayFrame(0), - }, CreateBeatmap(new DrumRoll - { - StartTime = hit_time, - Duration = 1000 - })); + }, CreateBeatmap(createDrumRoll(false))); - AssertJudgementCount(3); + AssertJudgementCount(6); AssertResult(0, HitResult.IgnoreMiss); AssertResult(1, HitResult.IgnoreMiss); + AssertResult(2, HitResult.IgnoreMiss); + AssertResult(3, HitResult.IgnoreMiss); + AssertResult(4, HitResult.IgnoreMiss); AssertResult(0, HitResult.IgnoreHit); } [Test] public void TestHitAllStrongDrumRollWithOneKey() { - const double hit_time = 1000; - PerformTest(new List { new TaikoReplayFrame(0), new TaikoReplayFrame(1000, TaikoAction.LeftCentre), new TaikoReplayFrame(1001), + new TaikoReplayFrame(1250, TaikoAction.LeftCentre), + new TaikoReplayFrame(1251), + new TaikoReplayFrame(1500, TaikoAction.LeftCentre), + new TaikoReplayFrame(1501), + new TaikoReplayFrame(1750, TaikoAction.LeftCentre), + new TaikoReplayFrame(1751), new TaikoReplayFrame(2000, TaikoAction.LeftCentre), new TaikoReplayFrame(2001), - }, CreateBeatmap(new DrumRoll + }, CreateBeatmap(createDrumRoll(true))); + + AssertJudgementCount(12); + + for (int i = 0; i < 5; i++) { - StartTime = hit_time, - Duration = 1000, - IsStrong = true - })); - - AssertJudgementCount(6); - - AssertResult(0, HitResult.SmallBonus); - AssertResult(0, HitResult.LargeBonus); - - AssertResult(1, HitResult.SmallBonus); - AssertResult(1, HitResult.LargeBonus); + AssertResult(i, HitResult.SmallBonus); + AssertResult(i, HitResult.LargeBonus); + } AssertResult(0, HitResult.IgnoreHit); - AssertResult(2, HitResult.IgnoreHit); + AssertResult(5, HitResult.IgnoreHit); } [Test] public void TestHitSomeStrongDrumRollWithOneKey() { - const double hit_time = 1000; - PerformTest(new List { new TaikoReplayFrame(0), new TaikoReplayFrame(2000, TaikoAction.LeftCentre), new TaikoReplayFrame(2001), - }, CreateBeatmap(new DrumRoll - { - StartTime = hit_time, - Duration = 1000, - IsStrong = true - })); + }, CreateBeatmap(createDrumRoll(true))); - AssertJudgementCount(6); + AssertJudgementCount(12); AssertResult(0, HitResult.IgnoreMiss); AssertResult(0, HitResult.IgnoreMiss); - AssertResult(1, HitResult.SmallBonus); - AssertResult(1, HitResult.LargeBonus); + AssertResult(4, HitResult.SmallBonus); + AssertResult(4, HitResult.LargeBonus); AssertResult(0, HitResult.IgnoreHit); - AssertResult(2, HitResult.IgnoreHit); + AssertResult(5, HitResult.IgnoreHit); } [Test] public void TestHitAllStrongDrumRollWithBothKeys() { - const double hit_time = 1000; - PerformTest(new List { new TaikoReplayFrame(0), new TaikoReplayFrame(1000, TaikoAction.LeftCentre, TaikoAction.RightCentre), new TaikoReplayFrame(1001), + new TaikoReplayFrame(1250, TaikoAction.LeftCentre, TaikoAction.RightCentre), + new TaikoReplayFrame(1251), + new TaikoReplayFrame(1500, TaikoAction.LeftCentre, TaikoAction.RightCentre), + new TaikoReplayFrame(1501), + new TaikoReplayFrame(1750, TaikoAction.LeftCentre, TaikoAction.RightCentre), + new TaikoReplayFrame(1751), new TaikoReplayFrame(2000, TaikoAction.LeftCentre, TaikoAction.RightCentre), new TaikoReplayFrame(2001), - }, CreateBeatmap(new DrumRoll + }, CreateBeatmap(createDrumRoll(true))); + + AssertJudgementCount(12); + + for (int i = 0; i < 5; i++) { - StartTime = hit_time, - Duration = 1000, - IsStrong = true - })); - - AssertJudgementCount(6); - - AssertResult(0, HitResult.SmallBonus); - AssertResult(0, HitResult.LargeBonus); - - AssertResult(1, HitResult.SmallBonus); - AssertResult(1, HitResult.LargeBonus); + AssertResult(i, HitResult.SmallBonus); + AssertResult(i, HitResult.LargeBonus); + } AssertResult(0, HitResult.IgnoreHit); - AssertResult(2, HitResult.IgnoreHit); + AssertResult(5, HitResult.IgnoreHit); } [Test] public void TestHitSomeStrongDrumRollWithBothKeys() { - const double hit_time = 1000; - PerformTest(new List { new TaikoReplayFrame(0), new TaikoReplayFrame(2000, TaikoAction.LeftCentre, TaikoAction.RightCentre), new TaikoReplayFrame(2001), - }, CreateBeatmap(new DrumRoll - { - StartTime = hit_time, - Duration = 1000, - IsStrong = true - })); + }, CreateBeatmap(createDrumRoll(true))); - AssertJudgementCount(6); + AssertJudgementCount(12); AssertResult(0, HitResult.IgnoreMiss); AssertResult(0, HitResult.IgnoreMiss); - AssertResult(1, HitResult.SmallBonus); - AssertResult(1, HitResult.LargeBonus); + AssertResult(4, HitResult.SmallBonus); + AssertResult(4, HitResult.LargeBonus); AssertResult(0, HitResult.IgnoreHit); - AssertResult(2, HitResult.IgnoreHit); + AssertResult(5, HitResult.IgnoreHit); } + + private DrumRoll createDrumRoll(bool strong) => new DrumRoll + { + StartTime = 1000, + Duration = 1000, + IsStrong = strong + }; } } diff --git a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs index e4e68c7207..dd8748f6e3 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Skinning/TestSceneDrawableTaikoMascot.cs @@ -91,8 +91,9 @@ namespace osu.Game.Rulesets.Taiko.Tests.Skinning { prepareDrawableRulesetAndBeatmap(false); - assertStateAfterResult(new JudgementResult(new Hit(), new TaikoJudgement()) { Type = HitResult.Great }, TaikoMascotAnimationState.Idle); - assertStateAfterResult(new JudgementResult(new Hit.StrongNestedHit(), new TaikoStrongJudgement()) { Type = HitResult.IgnoreMiss }, TaikoMascotAnimationState.Idle); + var hit = new Hit(); + assertStateAfterResult(new JudgementResult(hit, new TaikoJudgement()) { Type = HitResult.Great }, TaikoMascotAnimationState.Idle); + assertStateAfterResult(new JudgementResult(new Hit.StrongNestedHit(hit), new TaikoStrongJudgement()) { Type = HitResult.IgnoreMiss }, TaikoMascotAnimationState.Idle); } [Test] diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumSampleTriggerSource.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumSampleTriggerSource.cs new file mode 100644 index 0000000000..287d90b406 --- /dev/null +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumSampleTriggerSource.cs @@ -0,0 +1,337 @@ +// 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.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Timing; +using osu.Game.Audio; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Taiko.Objects; +using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Taiko.Tests +{ + public partial class TestSceneDrumSampleTriggerSource : OsuTestScene + { + private readonly ManualClock manualClock = new ManualClock(); + + [Cached(typeof(IScrollingInfo))] + private ScrollingTestContainer.TestScrollingInfo info = new ScrollingTestContainer.TestScrollingInfo + { + Direction = { Value = ScrollingDirection.Left }, + TimeRange = { Value = 200 }, + }; + + private ScrollingHitObjectContainer hitObjectContainer = null!; + private TestDrumSampleTriggerSource triggerSource = null!; + + [SetUp] + public void SetUp() => Schedule(() => + { + hitObjectContainer = new ScrollingHitObjectContainer(); + manualClock.CurrentTime = 0; + + Child = new Container + { + Clock = new FramedClock(manualClock), + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + hitObjectContainer, + triggerSource = new TestDrumSampleTriggerSource(hitObjectContainer) + } + }; + }); + + [Test] + public void TestNormalHit() + { + AddStep("add hit with normal samples", () => + { + var hit = new Hit + { + StartTime = 100, + Samples = new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL) + } + }; + hit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + var drawableHit = new DrawableHit(hit); + hitObjectContainer.Add(drawableHit); + }); + + AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + + AddStep("seek past hit", () => manualClock.CurrentTime = 200); + AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + } + + [Test] + public void TestSoftHit() + { + AddStep("add hit with soft samples", () => + { + var hit = new Hit + { + StartTime = 100, + Samples = new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT) + } + }; + hit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + var drawableHit = new DrawableHit(hit); + hitObjectContainer.Add(drawableHit); + }); + + AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); + + AddStep("seek past hit", () => manualClock.CurrentTime = 200); + AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); + } + + [Test] + public void TestDrumStrongHit() + { + AddStep("add strong hit with drum samples", () => + { + var hit = new Hit + { + StartTime = 100, + Samples = new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "drum"), + new HitSampleInfo(HitSampleInfo.HIT_FINISH, "drum") // implies strong + } + }; + hit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + var drawableHit = new DrawableHit(hit); + hitObjectContainer.Add(drawableHit); + }); + + AddAssert("most valid object is strong nested hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + + AddStep("seek past hit", () => manualClock.CurrentTime = 200); + AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + } + + [Test] + public void TestNormalDrumRoll() + { + AddStep("add drum roll with normal samples", () => + { + var drumRoll = new DrumRoll + { + StartTime = 100, + EndTime = 1100, + Samples = new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL) + } + }; + drumRoll.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + var drawableDrumRoll = new DrawableDrumRoll(drumRoll); + hitObjectContainer.Add(drawableDrumRoll); + }); + + AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + + AddStep("seek to middle of drum roll", () => manualClock.CurrentTime = 600); + AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + + AddStep("seek past drum roll", () => manualClock.CurrentTime = 1200); + AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + } + + [Test] + public void TestSoftDrumRoll() + { + AddStep("add drum roll with soft samples", () => + { + var drumRoll = new DrumRoll + { + StartTime = 100, + EndTime = 1100, + Samples = new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT) + } + }; + drumRoll.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + var drawableDrumRoll = new DrawableDrumRoll(drumRoll); + hitObjectContainer.Add(drawableDrumRoll); + }); + + AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); + + AddStep("seek to middle of drum roll", () => manualClock.CurrentTime = 600); + AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); + + AddStep("seek past drum roll", () => manualClock.CurrentTime = 1200); + AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); + } + + [Test] + public void TestDrumStrongDrumRoll() + { + AddStep("add strong drum roll with drum samples", () => + { + var drumRoll = new DrumRoll + { + StartTime = 100, + EndTime = 1100, + Samples = new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "drum"), + new HitSampleInfo(HitSampleInfo.HIT_FINISH, "drum") // implies strong + } + }; + drumRoll.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + var drawableDrumRoll = new DrawableDrumRoll(drumRoll); + hitObjectContainer.Add(drawableDrumRoll); + }); + + AddAssert("most valid object is drum roll tick's nested strong hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + + AddStep("seek to middle of drum roll", () => manualClock.CurrentTime = 600); + AddAssert("most valid object is drum roll tick's nested strong hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + + AddStep("seek past drum roll", () => manualClock.CurrentTime = 1200); + AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + } + + [Test] + public void TestNormalSwell() + { + AddStep("add swell with normal samples", () => + { + var swell = new Swell + { + StartTime = 100, + EndTime = 1100, + Samples = new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL) + } + }; + swell.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + var drawableSwell = new DrawableSwell(swell); + hitObjectContainer.Add(drawableSwell); + }); + + AddAssert("most valid object is swell tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + + AddStep("seek to middle of swell", () => manualClock.CurrentTime = 600); + AddAssert("most valid object is swell tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + + AddStep("seek past swell", () => manualClock.CurrentTime = 1200); + AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + } + + [Test] + public void TestDrumSwell() + { + AddStep("add swell with drum samples", () => + { + var swell = new Swell + { + StartTime = 100, + EndTime = 1100, + Samples = new List + { + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "drum") + } + }; + swell.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); + var drawableSwell = new DrawableSwell(swell); + hitObjectContainer.Add(drawableSwell); + }); + + AddAssert("most valid object is swell tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + + AddStep("seek to middle of swell", () => manualClock.CurrentTime = 600); + AddAssert("most valid object is swell tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + + AddStep("seek past swell", () => manualClock.CurrentTime = 1200); + AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf); + checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); + checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + } + + private void checkSound(HitType hitType, string expectedName, string expectedBank) + { + AddStep($"hit {hitType}", () => triggerSource.Play(hitType)); + AddAssert($"last played sample is {expectedName}", () => triggerSource.LastPlayedSamples!.OfType().Single().Name, () => Is.EqualTo(expectedName)); + AddAssert($"last played sample has {expectedBank} bank", () => triggerSource.LastPlayedSamples!.OfType().Single().Bank, () => Is.EqualTo(expectedBank)); + } + + private partial class TestDrumSampleTriggerSource : DrumSampleTriggerSource + { + public ISampleInfo[]? LastPlayedSamples { get; private set; } + + public TestDrumSampleTriggerSource(HitObjectContainer hitObjectContainer) + : base(hitObjectContainer) + { + } + + protected override void PlaySamples(ISampleInfo[] samples) + { + base.PlaySamples(samples); + LastPlayedSamples = samples; + } + + public new HitObject GetMostValidObject() => base.GetMostValidObject(); + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs index e298e313df..1c2e7abafe 100644 --- a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs +++ b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs @@ -92,6 +92,14 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps }).ToList(); } + // TODO: stable makes the last tick of a drumroll non-required when the next object is too close. + // This probably needs to be reimplemented: + // + // List hitobjects = hitObjectManager.hitObjects; + // int ind = hitobjects.IndexOf(this); + // if (i < hitobjects.Count - 1 && hitobjects[i + 1].HittableStartTime - (EndTime + (int)TickSpacing) <= (int)TickSpacing) + // lastTickHittable = false; + return converted; } @@ -133,7 +141,6 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps StartTime = obj.StartTime, Samples = obj.Samples, Duration = taikoDuration, - TickRate = beatmap.Difficulty.SliderTickRate == 3 ? 3 : 4, SliderVelocity = obj is IHasSliderVelocity velocityData ? velocityData.SliderVelocity : 1 }; } diff --git a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs index fcf2573d64..bc4129c982 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Blueprints/TaikoSpanPlacementBlueprint.cs @@ -25,6 +25,8 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints private readonly IHasDuration spanPlacementObject; + protected override bool IsValidForPlacement => spanPlacementObject.Duration > 0; + public TaikoSpanPlacementBlueprint(HitObject hitObject) : base(hitObject) { @@ -73,7 +75,7 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints return; base.OnMouseUp(e); - EndPlacement(spanPlacementObject.Duration > 0); + EndPlacement(true); } public override void UpdateTimeAndPosition(SnapResult result) diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs index f695c505a4..1b5d641612 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs @@ -118,6 +118,9 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables { public override bool RemoveWhenNotAlive => false; } + + // Most osu!taiko hitsounds are managed by the drum (see DrumSampleTriggerSource). + public override IEnumerable GetSamples() => Enumerable.Empty(); } public abstract partial class DrawableTaikoHitObject : DrawableTaikoHitObject @@ -157,9 +160,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables Content.Add(MainPiece = CreateMainPiece()); } - // Most osu!taiko hitsounds are managed by the drum (see DrumSampleMapping). - public override IEnumerable GetSamples() => Enumerable.Empty(); - protected abstract SkinnableDrawable CreateMainPiece(); } } diff --git a/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs index b4a12fd314..79d17b4a1f 100644 --- a/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs @@ -1,13 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using System.Linq; using osu.Game.Rulesets.Objects.Types; using System.Threading; using osu.Framework.Bindables; -using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Formats; @@ -71,6 +67,8 @@ namespace osu.Game.Rulesets.Taiko.Objects double scoringDistance = base_distance * difficulty.SliderMultiplier * SliderVelocity; Velocity = scoringDistance / timingPoint.BeatLength; + TickRate = difficulty.SliderTickRate == 3 ? 3 : 4; + tickSpacing = timingPoint.BeatLength / TickRate; } @@ -98,7 +96,7 @@ namespace osu.Game.Rulesets.Taiko.Objects TickSpacing = tickSpacing, StartTime = t, IsStrong = IsStrong, - Samples = Samples.Where(s => s.Name == HitSampleInfo.HIT_FINISH).ToList() + Samples = Samples }); first = false; @@ -109,12 +107,21 @@ namespace osu.Game.Rulesets.Taiko.Objects protected override HitWindows CreateHitWindows() => HitWindows.Empty; - protected override StrongNestedHitObject CreateStrongNestedHit(double startTime) => new StrongNestedHit { StartTime = startTime }; + protected override StrongNestedHitObject CreateStrongNestedHit(double startTime) => new StrongNestedHit(this) + { + StartTime = startTime, + Samples = Samples + }; public class StrongNestedHit : StrongNestedHitObject { // The strong hit of the drum roll doesn't actually provide any score. public override Judgement CreateJudgement() => new IgnoreJudgement(); + + public StrongNestedHit(TaikoHitObject parent) + : base(parent) + { + } } #region LegacyBeatmapEncoder diff --git a/osu.Game.Rulesets.Taiko/Objects/DrumRollTick.cs b/osu.Game.Rulesets.Taiko/Objects/DrumRollTick.cs index 6bcb8674e6..206e8ecb5a 100644 --- a/osu.Game.Rulesets.Taiko/Objects/DrumRollTick.cs +++ b/osu.Game.Rulesets.Taiko/Objects/DrumRollTick.cs @@ -33,10 +33,18 @@ namespace osu.Game.Rulesets.Taiko.Objects public override double MaximumJudgementOffset => HitWindow; - protected override StrongNestedHitObject CreateStrongNestedHit(double startTime) => new StrongNestedHit { StartTime = startTime }; + protected override StrongNestedHitObject CreateStrongNestedHit(double startTime) => new StrongNestedHit(this) + { + StartTime = startTime, + Samples = Samples + }; public class StrongNestedHit : StrongNestedHitObject { + public StrongNestedHit(TaikoHitObject parent) + : base(parent) + { + } } } } diff --git a/osu.Game.Rulesets.Taiko/Objects/Hit.cs b/osu.Game.Rulesets.Taiko/Objects/Hit.cs index 787079bfee..ec23079ed9 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Hit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Hit.cs @@ -63,7 +63,7 @@ namespace osu.Game.Rulesets.Taiko.Objects if (isRimType != rimSamples.Any()) { if (isRimType) - Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_CLAP)); + Samples.Add(CreateHitSampleInfo(HitSampleInfo.HIT_CLAP)); else { foreach (var sample in rimSamples) @@ -72,10 +72,18 @@ namespace osu.Game.Rulesets.Taiko.Objects } } - protected override StrongNestedHitObject CreateStrongNestedHit(double startTime) => new StrongNestedHit { StartTime = startTime }; + protected override StrongNestedHitObject CreateStrongNestedHit(double startTime) => new StrongNestedHit(this) + { + StartTime = startTime, + Samples = Samples + }; public class StrongNestedHit : StrongNestedHitObject { + public StrongNestedHit(TaikoHitObject parent) + : base(parent) + { + } } } } diff --git a/osu.Game.Rulesets.Taiko/Objects/HitType.cs b/osu.Game.Rulesets.Taiko/Objects/HitType.cs index eae7fa683a..17b3fdbd04 100644 --- a/osu.Game.Rulesets.Taiko/Objects/HitType.cs +++ b/osu.Game.Rulesets.Taiko/Objects/HitType.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Rulesets.Taiko.Objects { /// diff --git a/osu.Game.Rulesets.Taiko/Objects/StrongNestedHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/StrongNestedHitObject.cs index 628c41d878..316115f44d 100644 --- a/osu.Game.Rulesets.Taiko/Objects/StrongNestedHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/StrongNestedHitObject.cs @@ -15,6 +15,13 @@ namespace osu.Game.Rulesets.Taiko.Objects /// public abstract class StrongNestedHitObject : TaikoHitObject { + public readonly TaikoHitObject Parent; + + protected StrongNestedHitObject(TaikoHitObject parent) + { + Parent = parent; + } + public override Judgement CreateJudgement() => new TaikoStrongJudgement(); protected override HitWindows CreateHitWindows() => HitWindows.Empty; diff --git a/osu.Game.Rulesets.Taiko/Objects/Swell.cs b/osu.Game.Rulesets.Taiko/Objects/Swell.cs index cb91c46b4d..9ad783ba7e 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Swell.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Swell.cs @@ -33,7 +33,10 @@ namespace osu.Game.Rulesets.Taiko.Objects for (int i = 0; i < RequiredHits; i++) { cancellationToken.ThrowIfCancellationRequested(); - AddNested(new SwellTick()); + AddNested(new SwellTick + { + Samples = Samples + }); } } diff --git a/osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs index 0043f231d2..479ad8369a 100644 --- a/osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/TaikoStrongableHitObject.cs @@ -56,7 +56,7 @@ namespace osu.Game.Rulesets.Taiko.Objects if (IsStrongBindable.Value != strongSamples.Any()) { if (IsStrongBindable.Value) - Samples.Add(GetSampleInfo(HitSampleInfo.HIT_FINISH)); + Samples.Add(CreateHitSampleInfo(HitSampleInfo.HIT_FINISH)); else { foreach (var sample in strongSamples) diff --git a/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs b/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs index 4b60ee3ccb..a77e6db6f3 100644 --- a/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs +++ b/osu.Game.Rulesets.Taiko/Scoring/TaikoScoreProcessor.cs @@ -1,23 +1,44 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System; +using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Objects; namespace osu.Game.Rulesets.Taiko.Scoring { - internal partial class TaikoScoreProcessor : ScoreProcessor + public partial class TaikoScoreProcessor : ScoreProcessor { + private const double combo_base = 4; + public TaikoScoreProcessor() : base(new TaikoRuleset()) { } - protected override double DefaultAccuracyPortion => 0.75; + protected override double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion) + { + return 250000 * comboProgress + + 750000 * Math.Pow(Accuracy.Value, 3.6) * accuracyProgress + + bonusPortion; + } - protected override double DefaultComboPortion => 0.25; + protected override double GetBonusScoreChange(JudgementResult result) => base.GetBonusScoreChange(result) * strongScaleValue(result); - protected override double ClassicScoreMultiplier => 22; + protected override double GetComboScoreChange(JudgementResult result) + { + return Judgement.ToNumericResult(result.Type) + * Math.Min(Math.Max(0.5, Math.Log(result.ComboAfterJudgement, combo_base)), Math.Log(400, combo_base)) + * strongScaleValue(result); + } + + private double strongScaleValue(JudgementResult result) + { + if (result.HitObject is StrongNestedHitObject strong) + return strong.Parent is DrumRollTick ? 3 : 7; + + return 1; + } } } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonCirclePiece.cs index d7e37899ce..cecb99c690 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonCirclePiece.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonCirclePiece.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon private const double pre_beat_transition_time = 80; - private const float flash_opacity = 0.3f; + private const float kiai_flash_opacity = 0.15f; private ColourInfo accentColour; @@ -107,7 +107,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon if (drawableHitObject.State.Value == ArmedState.Idle) { flash - .FadeTo(flash_opacity) + .FadeTo(kiai_flash_opacity) .Then() .FadeOut(timingPoint.BeatLength * 0.75, Easing.OutSine); } diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonHitExplosion.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonHitExplosion.cs index a47fd7e62e..cddae7f05b 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonHitExplosion.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonHitExplosion.cs @@ -1,9 +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 osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; @@ -18,7 +16,9 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon public partial class ArgonHitExplosion : CompositeDrawable, IAnimatableHitExplosion { private readonly TaikoSkinComponents component; + private readonly Circle outer; + private readonly Circle inner; public ArgonHitExplosion(TaikoSkinComponents component) { @@ -34,13 +34,9 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientVertical( - new Color4(255, 227, 236, 255), - new Color4(255, 198, 211, 255) - ), Masking = true, }, - new Circle + inner = new Circle { Name = "Inner circle", Anchor = Anchor.Centre, @@ -48,12 +44,6 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon RelativeSizeAxes = Axes.Both, Colour = Color4.White, Size = new Vector2(0.85f), - EdgeEffect = new EdgeEffectParameters - { - Type = EdgeEffectType.Glow, - Colour = new Color4(255, 132, 191, 255).Opacity(0.5f), - Radius = 45, - }, Masking = true, }, }; @@ -63,6 +53,16 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon { this.FadeOut(); + bool isRim = (drawableHitObject.HitObject as Hit)?.Type == HitType.Rim; + + outer.Colour = isRim ? ArgonInputDrum.RIM_HIT_GRADIENT : ArgonInputDrum.CENTRE_HIT_GRADIENT; + inner.EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Glow, + Colour = (isRim ? ArgonInputDrum.RIM_HIT_GLOW : ArgonInputDrum.CENTRE_HIT_GLOW).Opacity(0.5f), + Radius = 45, + }; + switch (component) { case TaikoSkinComponents.TaikoExplosionGreat: diff --git a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonInputDrum.cs b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonInputDrum.cs index e7b0a5537a..f7b7105bdc 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonInputDrum.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Argon/ArgonInputDrum.cs @@ -19,6 +19,20 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon { public partial class ArgonInputDrum : AspectContainer { + public static readonly ColourInfo RIM_HIT_GRADIENT = ColourInfo.GradientHorizontal( + new Color4(227, 248, 255, 255), + new Color4(198, 245, 255, 255) + ); + + public static readonly Colour4 RIM_HIT_GLOW = new Color4(126, 215, 253, 255); + + public static readonly ColourInfo CENTRE_HIT_GRADIENT = ColourInfo.GradientHorizontal( + new Color4(255, 227, 236, 255), + new Color4(255, 198, 211, 255) + ); + + public static readonly Colour4 CENTRE_HIT_GLOW = new Color4(255, 147, 199, 255); + private const float rim_size = 0.3f; public ArgonInputDrum() @@ -141,14 +155,11 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon Anchor = anchor, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientHorizontal( - new Color4(227, 248, 255, 255), - new Color4(198, 245, 255, 255) - ), + Colour = RIM_HIT_GRADIENT, EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow, - Colour = new Color4(126, 215, 253, 170), + Colour = RIM_HIT_GLOW.Opacity(0.66f), Radius = 50, }, Alpha = 0, @@ -166,14 +177,11 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon Anchor = anchor, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientHorizontal( - new Color4(255, 227, 236, 255), - new Color4(255, 198, 211, 255) - ), + Colour = CENTRE_HIT_GRADIENT, EdgeEffect = new EdgeEffectParameters { Type = EdgeEffectType.Glow, - Colour = new Color4(255, 147, 199, 255), + Colour = CENTRE_HIT_GLOW, Radius = 50, }, Size = new Vector2(1 - rim_size), diff --git a/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs index bde502bbed..b3833d372c 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Default/CirclePiece.cs @@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default private const double pre_beat_transition_time = 80; - private const float flash_opacity = 0.3f; + private const float kiai_flash_opacity = 0.15f; [Resolved] private DrawableHitObject drawableHitObject { get; set; } = null!; @@ -187,7 +187,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default if (drawableHitObject.State.Value == ArmedState.Idle) { flashBox - .FadeTo(flash_opacity) + .FadeTo(kiai_flash_opacity) .Then() .FadeOut(timingPoint.BeatLength * 0.75, Easing.OutSine); } diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index a35fdb890d..d6824109b3 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -229,45 +229,27 @@ namespace osu.Game.Rulesets.Taiko return base.GetDisplayNameForHitResult(result); } - public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) + public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) { var timedHitEvents = score.HitEvents.Where(e => e.HitObject is Hit).ToList(); return new[] { - new StatisticRow + new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap) { - Columns = new[] - { - new StatisticItem("Performance Breakdown", () => new PerformanceBreakdownChart(score, playableBeatmap) - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y - }), - } - }, - new StatisticRow + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y + }), + new StatisticItem("Timing Distribution", () => new HitEventTimingDistributionGraph(timedHitEvents) { - Columns = new[] - { - new StatisticItem("Timing Distribution", () => new HitEventTimingDistributionGraph(timedHitEvents) - { - RelativeSizeAxes = Axes.X, - Height = 250 - }, true), - } - }, - new StatisticRow + RelativeSizeAxes = Axes.X, + Height = 250 + }, true), + new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[] { - Columns = new[] - { - new StatisticItem(string.Empty, () => new SimpleStatisticTable(3, new SimpleStatisticItem[] - { - new AverageHitError(timedHitEvents), - new UnstableRate(timedHitEvents) - }), true) - } - } + new AverageHitError(timedHitEvents), + new UnstableRate(timedHitEvents) + }), true) }; } } diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs index 2c4f193327..970b6aaf60 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs @@ -253,17 +253,17 @@ namespace osu.Game.Tests.Beatmaps.Formats var soundPoint = controlPoints.SamplePointAt(0); Assert.AreEqual(956, soundPoint.Time); - Assert.AreEqual("soft", soundPoint.SampleBank); + Assert.AreEqual(HitSampleInfo.BANK_SOFT, soundPoint.SampleBank); Assert.AreEqual(60, soundPoint.SampleVolume); soundPoint = controlPoints.SamplePointAt(53373); Assert.AreEqual(53373, soundPoint.Time); - Assert.AreEqual("soft", soundPoint.SampleBank); + Assert.AreEqual(HitSampleInfo.BANK_SOFT, soundPoint.SampleBank); Assert.AreEqual(60, soundPoint.SampleVolume); soundPoint = controlPoints.SamplePointAt(119637); Assert.AreEqual(119637, soundPoint.Time); - Assert.AreEqual("soft", soundPoint.SampleBank); + Assert.AreEqual(HitSampleInfo.BANK_SOFT, soundPoint.SampleBank); Assert.AreEqual(80, soundPoint.SampleVolume); var effectPoint = controlPoints.EffectPointAt(0); @@ -305,10 +305,10 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.That(controlPoints.EffectPointAt(2500).KiaiMode, Is.False); Assert.That(controlPoints.EffectPointAt(3500).KiaiMode, Is.True); - Assert.That(controlPoints.SamplePointAt(500).SampleBank, Is.EqualTo("drum")); - Assert.That(controlPoints.SamplePointAt(1500).SampleBank, Is.EqualTo("drum")); - Assert.That(controlPoints.SamplePointAt(2500).SampleBank, Is.EqualTo("normal")); - Assert.That(controlPoints.SamplePointAt(3500).SampleBank, Is.EqualTo("drum")); + Assert.That(controlPoints.SamplePointAt(500).SampleBank, Is.EqualTo(HitSampleInfo.BANK_DRUM)); + Assert.That(controlPoints.SamplePointAt(1500).SampleBank, Is.EqualTo(HitSampleInfo.BANK_DRUM)); + Assert.That(controlPoints.SamplePointAt(2500).SampleBank, Is.EqualTo(HitSampleInfo.BANK_NORMAL)); + Assert.That(controlPoints.SamplePointAt(3500).SampleBank, Is.EqualTo(HitSampleInfo.BANK_DRUM)); Assert.That(controlPoints.TimingPointAt(500).BeatLength, Is.EqualTo(500).Within(0.1)); Assert.That(controlPoints.TimingPointAt(1500).BeatLength, Is.EqualTo(500).Within(0.1)); diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs index 09130ac57d..fac5e098b9 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs @@ -231,7 +231,7 @@ namespace osu.Game.Tests.Beatmaps.Formats protected override IBeatmap GetBeatmap() => beatmap; - protected override Texture GetBackground() => throw new NotImplementedException(); + public override Texture GetBackground() => throw new NotImplementedException(); protected override Track GetBeatmapTrack() => throw new NotImplementedException(); diff --git a/osu.Game.Tests/Editing/Checks/CheckBackgroundQualityTest.cs b/osu.Game.Tests/Editing/Checks/CheckBackgroundQualityTest.cs index 295a10ba5b..3d1f7c5b17 100644 --- a/osu.Game.Tests/Editing/Checks/CheckBackgroundQualityTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckBackgroundQualityTest.cs @@ -131,7 +131,7 @@ namespace osu.Game.Tests.Editing.Checks var mock = new Mock(); mock.SetupGet(w => w.Beatmap).Returns(beatmap); - mock.SetupGet(w => w.Background).Returns(background); + mock.Setup(w => w.GetBackground()).Returns(background); mock.Setup(w => w.GetStream(It.IsAny())).Returns(stream); return mock; diff --git a/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs b/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs index 90c7688443..a261185473 100644 --- a/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs +++ b/osu.Game.Tests/Gameplay/TestSceneScoreProcessor.cs @@ -76,22 +76,38 @@ namespace osu.Game.Tests.Gameplay // Reset with a miss instead. scoreProcessor.ResetFromReplayFrame(new OsuReplayFrame { - Header = new FrameHeader(0, 0, 0, new Dictionary { { HitResult.Miss, 1 } }, DateTimeOffset.Now) + Header = new FrameHeader(0, 0, 0, 0, new Dictionary { { HitResult.Miss, 1 } }, new ScoreProcessorStatistics + { + MaximumBaseScore = 300, + BaseScore = 0, + AccuracyJudgementCount = 1, + ComboPortion = 0, + BonusPortion = 0 + }, DateTimeOffset.Now) }); Assert.That(scoreProcessor.TotalScore.Value, Is.Zero); Assert.That(scoreProcessor.JudgedHits, Is.EqualTo(1)); Assert.That(scoreProcessor.Combo.Value, Is.EqualTo(0)); + Assert.That(scoreProcessor.Accuracy.Value, Is.EqualTo(0)); // Reset with no judged hit. scoreProcessor.ResetFromReplayFrame(new OsuReplayFrame { - Header = new FrameHeader(0, 0, 0, new Dictionary(), DateTimeOffset.Now) + Header = new FrameHeader(0, 0, 0, 0, new Dictionary(), new ScoreProcessorStatistics + { + MaximumBaseScore = 0, + BaseScore = 0, + AccuracyJudgementCount = 0, + ComboPortion = 0, + BonusPortion = 0 + }, DateTimeOffset.Now) }); Assert.That(scoreProcessor.TotalScore.Value, Is.Zero); Assert.That(scoreProcessor.JudgedHits, Is.Zero); Assert.That(scoreProcessor.Combo.Value, Is.EqualTo(0)); + Assert.That(scoreProcessor.Accuracy.Value, Is.EqualTo(1)); } [Test] diff --git a/osu.Game.Tests/Resources/TestResources.cs b/osu.Game.Tests/Resources/TestResources.cs index a2d81c0a75..a77dc8d49b 100644 --- a/osu.Game.Tests/Resources/TestResources.cs +++ b/osu.Game.Tests/Resources/TestResources.cs @@ -179,7 +179,7 @@ namespace osu.Game.Tests.Resources BeatmapHash = beatmap.Hash, Ruleset = beatmap.Ruleset, Mods = new Mod[] { new TestModHardRock(), new TestModDoubleTime() }, - TotalScore = 2845370, + TotalScore = 284537, Accuracy = 0.95, MaxCombo = 999, Position = 1, diff --git a/osu.Game.Tests/Resources/storyboard_only_video.osu b/osu.Game.Tests/Resources/storyboard_only_video.osu new file mode 100644 index 0000000000..25f1ff6361 --- /dev/null +++ b/osu.Game.Tests/Resources/storyboard_only_video.osu @@ -0,0 +1,31 @@ +osu file format v14 + +[Events] +//Background and Video events +0,0,"BG.jpg",0,0 +Video,0,"video.avi" +//Break Periods +//Storyboard Layer 0 (Background) +//Storyboard Layer 1 (Fail) +//Storyboard Layer 2 (Pass) +//Storyboard Layer 3 (Foreground) +//Storyboard Layer 4 (Overlay) +//Storyboard Sound Samples + +[TimingPoints] +1674,333.333333333333,4,2,1,70,1,0 +1674,-100,4,2,1,70,0,0 +3340,-100,4,2,1,70,0,0 +3507,-100,4,2,1,70,0,0 +3673,-100,4,2,1,70,0,0 + +[Colours] +Combo1 : 240,80,80 +Combo2 : 171,252,203 +Combo3 : 128,128,255 +Combo4 : 249,254,186 + +[HitObjects] +148,303,1674,5,6,3:2:0:0: +378,252,1840,1,0,0:0:0:0: +389,270,2340,5,2,0:1:0:0: diff --git a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs index 826c610f56..e5e96d2033 100644 --- a/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs +++ b/osu.Game.Tests/Rulesets/Scoring/ScoreProcessorTest.cs @@ -14,11 +14,12 @@ using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; -using osu.Game.Scoring; +using osu.Game.Scoring.Legacy; using osu.Game.Tests.Beatmaps; namespace osu.Game.Tests.Rulesets.Scoring @@ -31,7 +32,7 @@ namespace osu.Game.Tests.Rulesets.Scoring [SetUp] public void SetUp() { - scoreProcessor = new ScoreProcessor(new TestRuleset()); + scoreProcessor = new ScoreProcessor(new OsuRuleset()); beatmap = new TestBeatmap(new RulesetInfo()) { HitObjects = new List @@ -41,15 +42,14 @@ namespace osu.Game.Tests.Rulesets.Scoring }; } - [TestCase(ScoringMode.Standardised, HitResult.Meh, 750_000)] - [TestCase(ScoringMode.Standardised, HitResult.Ok, 800_000)] + [TestCase(ScoringMode.Standardised, HitResult.Meh, 116_667)] + [TestCase(ScoringMode.Standardised, HitResult.Ok, 233_338)] [TestCase(ScoringMode.Standardised, HitResult.Great, 1_000_000)] - [TestCase(ScoringMode.Classic, HitResult.Meh, 20)] - [TestCase(ScoringMode.Classic, HitResult.Ok, 23)] + [TestCase(ScoringMode.Classic, HitResult.Meh, 0)] + [TestCase(ScoringMode.Classic, HitResult.Ok, 2)] [TestCase(ScoringMode.Classic, HitResult.Great, 36)] public void TestSingleOsuHit(ScoringMode scoringMode, HitResult hitResult, int expectedScore) { - scoreProcessor.Mode.Value = scoringMode; scoreProcessor.ApplyBeatmap(beatmap); var judgementResult = new JudgementResult(beatmap.HitObjects.Single(), new OsuJudgement()) @@ -58,7 +58,7 @@ namespace osu.Game.Tests.Rulesets.Scoring }; scoreProcessor.ApplyResult(judgementResult); - Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(expectedScore).Within(0.5d)); + Assert.That(scoreProcessor.GetDisplayScore(scoringMode), Is.EqualTo(expectedScore).Within(0.5d)); } /// @@ -70,39 +70,29 @@ namespace osu.Game.Tests.Rulesets.Scoring /// 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, 492_857)] // (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, 1_000_030)] // 1 * 300_000 + 700_000 (max combo 0) + 3 * 10 (bonus points) - [TestCase(ScoringMode.Standardised, HitResult.LargeBonus, HitResult.LargeBonus, 1_000_150)] // 1 * 300_000 + 700_000 (max combo 0) + 3 * 50 (bonus points) + [TestCase(ScoringMode.Standardised, HitResult.Miss, HitResult.Great, 0)] + [TestCase(ScoringMode.Standardised, HitResult.Meh, HitResult.Great, 79_333)] + [TestCase(ScoringMode.Standardised, HitResult.Ok, HitResult.Great, 158_667)] + [TestCase(ScoringMode.Standardised, HitResult.Good, HitResult.Perfect, 302_402)] + [TestCase(ScoringMode.Standardised, HitResult.Great, HitResult.Great, 492_894)] + [TestCase(ScoringMode.Standardised, HitResult.Perfect, HitResult.Perfect, 492_894)] + [TestCase(ScoringMode.Standardised, HitResult.SmallTickMiss, HitResult.SmallTickHit, 0)] + [TestCase(ScoringMode.Standardised, HitResult.SmallTickHit, HitResult.SmallTickHit, 541_894)] + [TestCase(ScoringMode.Standardised, HitResult.LargeTickMiss, HitResult.LargeTickHit, 0)] + [TestCase(ScoringMode.Standardised, HitResult.LargeTickHit, HitResult.LargeTickHit, 492_894)] + [TestCase(ScoringMode.Standardised, HitResult.SmallBonus, HitResult.SmallBonus, 1_000_030)] + [TestCase(ScoringMode.Standardised, HitResult.LargeBonus, HitResult.LargeBonus, 1_000_150)] [TestCase(ScoringMode.Classic, HitResult.Miss, HitResult.Great, 0)] - [TestCase(ScoringMode.Classic, HitResult.Meh, HitResult.Great, 86)] - [TestCase(ScoringMode.Classic, HitResult.Ok, HitResult.Great, 104)] - [TestCase(ScoringMode.Classic, HitResult.Good, HitResult.Perfect, 140)] - [TestCase(ScoringMode.Classic, HitResult.Great, HitResult.Great, 190)] - [TestCase(ScoringMode.Classic, HitResult.Perfect, HitResult.Perfect, 190)] - [TestCase(ScoringMode.Classic, HitResult.SmallTickMiss, HitResult.SmallTickHit, 18)] - [TestCase(ScoringMode.Classic, HitResult.SmallTickHit, HitResult.SmallTickHit, 31)] + [TestCase(ScoringMode.Classic, HitResult.Meh, HitResult.Great, 4)] + [TestCase(ScoringMode.Classic, HitResult.Ok, HitResult.Great, 15)] + [TestCase(ScoringMode.Classic, HitResult.Good, HitResult.Perfect, 53)] + [TestCase(ScoringMode.Classic, HitResult.Great, HitResult.Great, 140)] + [TestCase(ScoringMode.Classic, HitResult.Perfect, HitResult.Perfect, 140)] + [TestCase(ScoringMode.Classic, HitResult.SmallTickMiss, HitResult.SmallTickHit, 0)] + [TestCase(ScoringMode.Classic, HitResult.SmallTickHit, HitResult.SmallTickHit, 11)] [TestCase(ScoringMode.Classic, HitResult.LargeTickMiss, HitResult.LargeTickHit, 0)] - [TestCase(ScoringMode.Classic, HitResult.LargeTickHit, HitResult.LargeTickHit, 12)] + [TestCase(ScoringMode.Classic, HitResult.LargeTickHit, HitResult.LargeTickHit, 9)] [TestCase(ScoringMode.Classic, HitResult.SmallBonus, HitResult.SmallBonus, 36)] [TestCase(ScoringMode.Classic, HitResult.LargeBonus, HitResult.LargeBonus, 36)] public void TestFourVariousResultsOneMiss(ScoringMode scoringMode, HitResult hitResult, HitResult maxResult, int expectedScore) @@ -113,59 +103,18 @@ namespace osu.Game.Tests.Rulesets.Scoring { 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()) + var judgementResult = new JudgementResult(fourObjectBeatmap.HitObjects[i], new TestJudgement(maxResult)) { Type = i == 2 ? minResult : hitResult }; scoreProcessor.ApplyResult(judgementResult); } - Assert.That(scoreProcessor.TotalScore.Value, Is.EqualTo(expectedScore).Within(0.5d)); - } - - /// - /// 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, 34)] - [TestCase(ScoringMode.Classic, HitResult.SmallTickMiss, 30)] - 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.That(scoreProcessor.TotalScore.Value, Is.EqualTo(expectedScore).Within(0.5d)); + Assert.That(scoreProcessor.GetDisplayScore(scoringMode), Is.EqualTo(expectedScore).Within(0.5d)); } [Test] @@ -173,10 +122,9 @@ namespace osu.Game.Tests.Rulesets.Scoring [Values(ScoringMode.Standardised, ScoringMode.Classic)] ScoringMode scoringMode) { - scoreProcessor.Mode.Value = scoringMode; scoreProcessor.ApplyBeatmap(new TestBeatmap(new RulesetInfo())); - Assert.That(scoreProcessor.TotalScore.Value, Is.Zero); + Assert.That(scoreProcessor.GetDisplayScore(scoringMode), Is.Zero); } [TestCase(HitResult.IgnoreHit, HitResult.IgnoreMiss)] @@ -294,28 +242,6 @@ namespace osu.Game.Tests.Rulesets.Scoring Assert.AreEqual(expectedReturnValue, hitResult.IsScorable()); } - [TestCase(HitResult.Perfect, 1_000_000)] - [TestCase(HitResult.SmallTickHit, 1_000_000)] - [TestCase(HitResult.LargeTickHit, 1_000_000)] - [TestCase(HitResult.SmallBonus, 1_000_000 + Judgement.SMALL_BONUS_SCORE)] - [TestCase(HitResult.LargeBonus, 1_000_000 + Judgement.LARGE_BONUS_SCORE)] - public void TestGetScoreWithExternalStatistics(HitResult result, int expectedScore) - { - var statistic = new Dictionary { { result, 1 } }; - - scoreProcessor.ApplyBeatmap(new Beatmap - { - HitObjects = { new TestHitObject(result) } - }); - - Assert.That(scoreProcessor.ComputeScore(ScoringMode.Standardised, new ScoreInfo - { - Ruleset = new TestRuleset().RulesetInfo, - MaxCombo = result.AffectsCombo() ? 1 : 0, - Statistics = statistic - }), Is.EqualTo(expectedScore).Within(0.5d)); - } - #pragma warning disable CS0618 [Test] public void TestLegacyComboIncrease() @@ -330,29 +256,6 @@ namespace osu.Game.Tests.Rulesets.Scoring Assert.That(HitResult.LegacyComboIncrease.IsHit(), Is.True); Assert.That(HitResult.LegacyComboIncrease.IsScorable(), Is.True); Assert.That(HitResultExtensions.ALL_TYPES, Does.Not.Contain(HitResult.LegacyComboIncrease)); - - // Cannot be used to apply results. - Assert.Throws(() => scoreProcessor.ApplyBeatmap(new Beatmap - { - HitObjects = { new TestHitObject(HitResult.LegacyComboIncrease) } - })); - - ScoreInfo testScore = new ScoreInfo - { - MaxCombo = 1, - Statistics = new Dictionary - { - { HitResult.Great, 1 } - }, - MaximumStatistics = new Dictionary - { - { HitResult.Great, 1 }, - { HitResult.LegacyComboIncrease, 1 } - } - }; - - double totalScore = new TestScoreProcessor().ComputeScore(ScoringMode.Standardised, testScore); - Assert.That(totalScore, Is.EqualTo(750_000)); // 500K from accuracy (100%), and 250K from combo (50%). } #pragma warning restore CS0618 @@ -362,36 +265,30 @@ namespace osu.Game.Tests.Rulesets.Scoring const int count_judgements = 1000; const int count_misses = 1; - double actual = new TestScoreProcessor().ComputeAccuracy(new ScoreInfo + beatmap = new TestBeatmap(new RulesetInfo()) { - Statistics = new Dictionary + HitObjects = new List(Enumerable.Repeat(new TestHitObject(HitResult.Great), count_judgements)) + }; + + scoreProcessor = new TestScoreProcessor(); + scoreProcessor.ApplyBeatmap(beatmap); + + for (int i = 0; i < beatmap.HitObjects.Count; i++) + { + scoreProcessor.ApplyResult(new JudgementResult(beatmap.HitObjects[i], new TestJudgement(HitResult.Great)) { - { HitResult.Great, count_judgements - count_misses }, - { HitResult.Miss, count_misses } - } - }); + Type = i == 0 ? HitResult.Miss : HitResult.Great + }); + } const double expected = (count_judgements - count_misses) / (double)count_judgements; + double actual = scoreProcessor.Accuracy.Value; Assert.That(actual, Is.Not.EqualTo(0.0)); Assert.That(actual, Is.Not.EqualTo(1.0)); Assert.That(actual, Is.EqualTo(expected).Within(Precision.FLOAT_EPSILON)); } - private class TestRuleset : Ruleset - { - public override IEnumerable GetModsFor(ModType type) => throw new NotImplementedException(); - - public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => throw new NotImplementedException(); - - public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => throw new NotImplementedException(); - - public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => throw new NotImplementedException(); - - public override string Description => string.Empty; - public override string ShortName => string.Empty; - } - private class TestJudgement : Judgement { public override HitResult MaxResult { get; } @@ -419,14 +316,18 @@ namespace osu.Game.Tests.Rulesets.Scoring private partial class TestScoreProcessor : ScoreProcessor { - protected override double DefaultAccuracyPortion => 0.5; - protected override double DefaultComboPortion => 0.5; - public TestScoreProcessor() : base(new TestRuleset()) { } + protected override double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion) + { + return 500000 * comboProgress + + 500000 * Accuracy.Value * accuracyProgress + + bonusPortion; + } + // ReSharper disable once MemberHidesStaticFromOuterClass private class TestRuleset : Ruleset { diff --git a/osu.Game.Tests/Rulesets/TestSceneBrokenRulesetHandling.cs b/osu.Game.Tests/Rulesets/TestSceneBrokenRulesetHandling.cs index c3a6b7c474..dac6beea65 100644 --- a/osu.Game.Tests/Rulesets/TestSceneBrokenRulesetHandling.cs +++ b/osu.Game.Tests/Rulesets/TestSceneBrokenRulesetHandling.cs @@ -73,7 +73,5 @@ namespace osu.Game.Tests.Rulesets public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => null; public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => null; } - -#nullable enable } } diff --git a/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs b/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs index 8f4250799e..1523ae7027 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs @@ -286,7 +286,7 @@ namespace osu.Game.Tests.Visual.Background this.renderer = renderer; } - protected override Texture GetBackground() => renderer.CreateTexture(1, 1); + public override Texture GetBackground() => renderer.CreateTexture(1, 1); } private partial class TestWorkingBeatmapWithStoryboard : TestWorkingBeatmap diff --git a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs index 1e9982f8d4..cfa45ec6ef 100644 --- a/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs +++ b/osu.Game.Tests/Visual/Collections/TestSceneManageCollectionsDialog.cs @@ -264,8 +264,9 @@ namespace osu.Game.Tests.Visual.Collections assertCollectionName(1, "First"); } - [Test] - public void TestCollectionRenamedOnTextChange() + [TestCase(false)] + [TestCase(true)] + public void TestCollectionRenamedOnTextChange(bool commitWithEnter) { BeatmapCollection first = null!; DrawableCollectionListItem firstItem = null!; @@ -293,9 +294,19 @@ namespace osu.Game.Tests.Visual.Collections AddStep("change first collection name", () => { firstItem.ChildrenOfType().First().Text = "First"; - InputManager.Key(Key.Enter); }); + if (commitWithEnter) + AddStep("commit via enter", () => InputManager.Key(Key.Enter)); + else + { + AddStep("commit via click away", () => + { + InputManager.MoveMouseTo(firstItem.ScreenSpaceDrawQuad.TopLeft - new Vector2(10)); + InputManager.Click(MouseButton.Left); + }); + } + AddUntilStep("collection has new name", () => first.Name == "First"); } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneBeatDivisorControl.cs b/osu.Game.Tests/Visual/Editing/TestSceneBeatDivisorControl.cs index 56b16301be..88b959a2a0 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneBeatDivisorControl.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneBeatDivisorControl.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Diagnostics; using System.Linq; @@ -23,8 +21,8 @@ namespace osu.Game.Tests.Visual.Editing { public partial class TestSceneBeatDivisorControl : OsuManualInputManagerTestScene { - private BeatDivisorControl beatDivisorControl; - private BindableBeatDivisor bindableBeatDivisor; + private BeatDivisorControl beatDivisorControl = null!; + private BindableBeatDivisor bindableBeatDivisor = null!; private SliderBar tickSliderBar => beatDivisorControl.ChildrenOfType>().Single(); private Triangle tickMarkerHead => tickSliderBar.ChildrenOfType().Single(); @@ -42,7 +40,8 @@ namespace osu.Game.Tests.Visual.Editing { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(90, 90) + Size = new Vector2(90, 90), + Scale = new Vector2(3), } }; }); @@ -50,9 +49,9 @@ namespace osu.Game.Tests.Visual.Editing [Test] public void TestBindableBeatDivisor() { - AddRepeatStep("move previous", () => bindableBeatDivisor.Previous(), 2); + AddRepeatStep("move previous", () => bindableBeatDivisor.SelectPrevious(), 2); AddAssert("divisor is 4", () => bindableBeatDivisor.Value == 4); - AddRepeatStep("move next", () => bindableBeatDivisor.Next(), 1); + AddRepeatStep("move next", () => bindableBeatDivisor.SelectNext(), 1); AddAssert("divisor is 12", () => bindableBeatDivisor.Value == 8); } @@ -64,17 +63,24 @@ namespace osu.Game.Tests.Visual.Editing InputManager.MoveMouseTo(tickMarkerHead.ScreenSpaceDrawQuad.Centre); InputManager.PressButton(MouseButton.Left); }); - AddStep("move to 8 and release", () => + AddStep("move to 1", () => InputManager.MoveMouseTo(getPositionForDivisor(1))); + AddStep("move to 16 and release", () => { - InputManager.MoveMouseTo(tickSliderBar.ScreenSpaceDrawQuad.Centre); + InputManager.MoveMouseTo(getPositionForDivisor(16)); InputManager.ReleaseButton(MouseButton.Left); }); - AddAssert("divisor is 8", () => bindableBeatDivisor.Value == 8); + AddAssert("divisor is 16", () => bindableBeatDivisor.Value == 16); AddStep("hold marker", () => InputManager.PressButton(MouseButton.Left)); - AddStep("move to 16", () => InputManager.MoveMouseTo(getPositionForDivisor(16))); - AddStep("move to ~10 and release", () => + AddStep("move to ~6 and release", () => + { + InputManager.MoveMouseTo(getPositionForDivisor(6)); + InputManager.ReleaseButton(MouseButton.Left); + }); + AddAssert("divisor clamped to 8", () => bindableBeatDivisor.Value == 8); + AddStep("move to ~10 and click", () => { InputManager.MoveMouseTo(getPositionForDivisor(10)); + InputManager.PressButton(MouseButton.Left); InputManager.ReleaseButton(MouseButton.Left); }); AddAssert("divisor clamped to 8", () => bindableBeatDivisor.Value == 8); @@ -82,28 +88,33 @@ namespace osu.Game.Tests.Visual.Editing private Vector2 getPositionForDivisor(int divisor) { - float relativePosition = (float)Math.Clamp(divisor, 0, 16) / 16; - var sliderDrawQuad = tickSliderBar.ScreenSpaceDrawQuad; - return new Vector2( - sliderDrawQuad.TopLeft.X + sliderDrawQuad.Width * relativePosition, - sliderDrawQuad.Centre.Y - ); + float localX = (1 - 1 / (float)divisor) * tickSliderBar.UsableWidth + tickSliderBar.RangePadding; + return tickSliderBar.ToScreenSpace(new Vector2( + localX, + tickSliderBar.DrawHeight / 2 + )); } [Test] public void TestBeatChevronNavigation() { switchBeatSnap(1); + assertBeatSnap(16); + + switchBeatSnap(-4); assertBeatSnap(1); switchBeatSnap(3); assertBeatSnap(8); - switchBeatSnap(-1); + switchBeatSnap(3); + assertBeatSnap(16); + + switchBeatSnap(-2); assertBeatSnap(4); switchBeatSnap(-3); - assertBeatSnap(16); + assertBeatSnap(1); } [Test] @@ -156,9 +167,11 @@ namespace osu.Game.Tests.Visual.Editing switchPresets(1); assertPreset(BeatDivisorType.Triplets); + assertBeatSnap(6); switchPresets(1); assertPreset(BeatDivisorType.Common); + assertBeatSnap(4); switchPresets(-1); assertPreset(BeatDivisorType.Triplets); @@ -174,6 +187,7 @@ namespace osu.Game.Tests.Visual.Editing setDivisorViaInput(15); assertPreset(BeatDivisorType.Custom, 15); + assertBeatSnap(15); switchBeatSnap(-1); assertBeatSnap(5); @@ -183,12 +197,14 @@ namespace osu.Game.Tests.Visual.Editing setDivisorViaInput(5); assertPreset(BeatDivisorType.Custom, 15); + assertBeatSnap(5); switchPresets(1); assertPreset(BeatDivisorType.Common); switchPresets(-1); - assertPreset(BeatDivisorType.Triplets); + assertPreset(BeatDivisorType.Custom, 15); + assertBeatSnap(15); } private void switchBeatSnap(int direction) => AddRepeatStep($"move snap {(direction > 0 ? "forward" : "backward")}", () => @@ -200,7 +216,7 @@ namespace osu.Game.Tests.Visual.Editing }, Math.Abs(direction)); private void assertBeatSnap(int expected) => AddAssert($"beat snap is {expected}", - () => bindableBeatDivisor.Value == expected); + () => bindableBeatDivisor.Value, () => Is.EqualTo(expected)); private void switchPresets(int direction) => AddRepeatStep($"move presets {(direction > 0 ? "forward" : "backward")}", () => { @@ -212,7 +228,7 @@ namespace osu.Game.Tests.Visual.Editing private void assertPreset(BeatDivisorType type, int? maxDivisor = null) { - AddAssert($"preset is {type}", () => bindableBeatDivisor.ValidDivisors.Value.Type == type); + AddAssert($"preset is {type}", () => bindableBeatDivisor.ValidDivisors.Value.Type, () => Is.EqualTo(type)); if (type == BeatDivisorType.Custom) { @@ -230,7 +246,7 @@ namespace osu.Game.Tests.Visual.Editing InputManager.Click(MouseButton.Left); }); - BeatDivisorControl.CustomDivisorPopover popover = null; + BeatDivisorControl.CustomDivisorPopover? popover = null; AddUntilStep("wait for popover", () => (popover = this.ChildrenOfType().SingleOrDefault()) != null && popover.IsLoaded); AddStep($"set divisor to {divisor}", () => { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs index 21b925a257..70e4420a45 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs @@ -187,7 +187,7 @@ namespace osu.Game.Tests.Visual.Editing private class SnapProvider : IDistanceSnapProvider { - public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.Grids) => new SnapResult(screenSpacePosition, 0); + public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.AllGrids) => new SnapResult(screenSpacePosition, 0); public Bindable DistanceSpacingMultiplier { get; } = new BindableDouble(1); diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorNavigation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorNavigation.cs deleted file mode 100644 index 5914290d40..0000000000 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorNavigation.cs +++ /dev/null @@ -1,57 +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 NUnit.Framework; -using osu.Framework.Extensions; -using osu.Framework.Extensions.IEnumerableExtensions; -using osu.Framework.Extensions.ObjectExtensions; -using osu.Game.Beatmaps; -using osu.Game.Database; -using osu.Game.Rulesets.Mania; -using osu.Game.Rulesets.Osu; -using osu.Game.Screens.Edit; -using osu.Game.Screens.Edit.GameplayTest; -using osu.Game.Screens.Select; -using osu.Game.Tests.Resources; - -namespace osu.Game.Tests.Visual.Editing -{ - public partial class TestSceneEditorNavigation : OsuGameTestScene - { - [Test] - public void TestEditorGameplayTestAlwaysUsesOriginalRuleset() - { - BeatmapSetInfo beatmapSet = null!; - - AddStep("import test beatmap", () => Game.BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).WaitSafely()); - AddStep("retrieve beatmap", () => beatmapSet = Game.BeatmapManager.QueryBeatmapSet(set => !set.Protected).AsNonNull().Value.Detach()); - - AddStep("present beatmap", () => Game.PresentBeatmap(beatmapSet)); - AddUntilStep("wait for song select", - () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet) - && Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect - && songSelect.IsLoaded); - AddStep("switch ruleset", () => Game.Ruleset.Value = new ManiaRuleset().RulesetInfo); - - AddStep("open editor", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).Edit(beatmapSet.Beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == 0))); - AddUntilStep("wait for editor open", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.ReadyForUse); - AddStep("test gameplay", () => ((Editor)Game.ScreenStack.CurrentScreen).TestGameplay()); - - AddUntilStep("wait for player", () => - { - // notifications may fire at almost any inopportune time and cause annoying test failures. - // relentlessly attempt to dismiss any and all interfering overlays, which includes notifications. - // this is theoretically not foolproof, but it's the best that can be done here. - Game.CloseAllOverlays(); - return Game.ScreenStack.CurrentScreen is EditorPlayer editorPlayer && editorPlayer.IsLoaded; - }); - - AddAssert("current ruleset is osu!", () => Game.Ruleset.Value.Equals(new OsuRuleset().RulesetInfo)); - - AddStep("exit to song select", () => Game.PerformFromScreen(_ => { }, typeof(PlaySongSelect).Yield())); - AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); - AddAssert("previous ruleset restored", () => Game.Ruleset.Value.Equals(new ManiaRuleset().RulesetInfo)); - } - } -} diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs index 2250868a39..007716bd6c 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs @@ -209,10 +209,14 @@ namespace osu.Game.Tests.Visual.Editing public override void TearDownSteps() { base.TearDownSteps(); - AddStep("delete imported", () => + AddStep("delete imported", () => Realm.Write(r => { - beatmaps.Delete(importedBeatmapSet); - }); + // delete from realm directly rather than via `BeatmapManager` to avoid cross-test pollution + // (`BeatmapManager.Delete()` uses soft deletion, which can lead to beatmap reuse between test cases). + r.RemoveAll(); + r.RemoveAll(); + r.RemoveAll(); + })); } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs index 3b998b4219..c874b39028 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectDifficultyPointAdjustments.cs @@ -92,6 +92,20 @@ namespace osu.Game.Tests.Visual.Editing hitObjectHasVelocity(1, 5); } + [Test] + public void TestUndo() + { + clickDifficultyPiece(1); + velocityPopoverHasSingleValue(2); + + setVelocityViaPopover(5); + hitObjectHasVelocity(1, 5); + dismissPopover(); + + AddStep("undo", () => Editor.Undo()); + hitObjectHasVelocity(1, 2); + } + [Test] public void TestMultipleSelectionWithSameSliderVelocity() { diff --git a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSamplePointAdjustments.cs b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs similarity index 59% rename from osu.Game.Tests/Visual/Editing/TestSceneHitObjectSamplePointAdjustments.cs rename to osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs index 7a0418cfec..b0b51a5dbd 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSamplePointAdjustments.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneHitObjectSampleAdjustments.cs @@ -13,6 +13,7 @@ using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osu.Game.Rulesets; +using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.UI; @@ -24,7 +25,7 @@ using osuTK.Input; namespace osu.Game.Tests.Visual.Editing { - public partial class TestSceneHitObjectSamplePointAdjustments : EditorTestScene + public partial class TestSceneHitObjectSampleAdjustments : EditorTestScene { protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); @@ -42,7 +43,7 @@ namespace osu.Game.Tests.Visual.Editing Position = (OsuPlayfield.BASE_SIZE - new Vector2(100, 0)) / 2, Samples = new List { - new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "normal", volume: 80) + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, volume: 80) } }); @@ -52,12 +53,32 @@ namespace osu.Game.Tests.Visual.Editing Position = (OsuPlayfield.BASE_SIZE + new Vector2(100, 0)) / 2, Samples = new List { - new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "soft", volume: 60) + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT, volume: 60) } }); }); } + [Test] + public void TestAddSampleAddition() + { + AddStep("select both objects", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); + + AddStep("add clap addition", () => InputManager.Key(Key.R)); + + hitObjectHasSampleBank(0, "normal"); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_CLAP); + hitObjectHasSampleBank(1, HitSampleInfo.BANK_SOFT); + hitObjectHasSamples(1, HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_CLAP); + + AddStep("remove clap addition", () => InputManager.Key(Key.R)); + + hitObjectHasSampleBank(0, "normal"); + hitObjectHasSamples(0, HitSampleInfo.HIT_NORMAL); + hitObjectHasSampleBank(1, HitSampleInfo.BANK_SOFT); + hitObjectHasSamples(1, HitSampleInfo.HIT_NORMAL); + } + [Test] public void TestPopoverHasFocus() { @@ -69,7 +90,7 @@ namespace osu.Game.Tests.Visual.Editing public void TestSingleSelection() { clickSamplePiece(0); - samplePopoverHasSingleBank("normal"); + samplePopoverHasSingleBank(HitSampleInfo.BANK_NORMAL); samplePopoverHasSingleVolume(80); dismissPopover(); @@ -79,14 +100,29 @@ namespace osu.Game.Tests.Visual.Editing AddStep("select first object", () => EditorBeatmap.SelectedHitObjects.Add(EditorBeatmap.HitObjects.First())); clickSamplePiece(1); - samplePopoverHasSingleBank("soft"); + samplePopoverHasSingleBank(HitSampleInfo.BANK_SOFT); samplePopoverHasSingleVolume(60); setVolumeViaPopover(90); hitObjectHasSampleVolume(1, 90); - setBankViaPopover("drum"); - hitObjectHasSampleBank(1, "drum"); + setBankViaPopover(HitSampleInfo.BANK_DRUM); + hitObjectHasSampleBank(1, HitSampleInfo.BANK_DRUM); + } + + [Test] + public void TestUndo() + { + clickSamplePiece(1); + samplePopoverHasSingleBank(HitSampleInfo.BANK_SOFT); + samplePopoverHasSingleVolume(60); + + setVolumeViaPopover(90); + hitObjectHasSampleVolume(1, 90); + dismissPopover(); + + AddStep("undo", () => Editor.Undo()); + hitObjectHasSampleVolume(1, 60); } [Test] @@ -135,7 +171,7 @@ namespace osu.Game.Tests.Visual.Editing } [Test] - public void TestMultipleSelectionWithSameSampleBank() + public void TestPopoverMultipleSelectionWithSameSampleBank() { AddStep("unify sample bank", () => { @@ -143,33 +179,33 @@ namespace osu.Game.Tests.Visual.Editing { for (int i = 0; i < h.Samples.Count; i++) { - h.Samples[i] = h.Samples[i].With(newBank: "soft"); + h.Samples[i] = h.Samples[i].With(newBank: HitSampleInfo.BANK_SOFT); } } }); AddStep("select both objects", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); clickSamplePiece(0); - samplePopoverHasSingleBank("soft"); + samplePopoverHasSingleBank(HitSampleInfo.BANK_SOFT); dismissPopover(); clickSamplePiece(1); - samplePopoverHasSingleBank("soft"); + samplePopoverHasSingleBank(HitSampleInfo.BANK_SOFT); setBankViaPopover(string.Empty); - hitObjectHasSampleBank(0, "soft"); - hitObjectHasSampleBank(1, "soft"); - samplePopoverHasSingleBank("soft"); + hitObjectHasSampleBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSampleBank(1, HitSampleInfo.BANK_SOFT); + samplePopoverHasSingleBank(HitSampleInfo.BANK_SOFT); - setBankViaPopover("drum"); - hitObjectHasSampleBank(0, "drum"); - hitObjectHasSampleBank(1, "drum"); - samplePopoverHasSingleBank("drum"); + setBankViaPopover(HitSampleInfo.BANK_DRUM); + hitObjectHasSampleBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleBank(1, HitSampleInfo.BANK_DRUM); + samplePopoverHasSingleBank(HitSampleInfo.BANK_DRUM); } [Test] - public void TestMultipleSelectionWithDifferentSampleBank() + public void TestPopoverMultipleSelectionWithDifferentSampleBank() { AddStep("select both objects", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); clickSamplePiece(0); @@ -181,14 +217,109 @@ namespace osu.Game.Tests.Visual.Editing samplePopoverHasIndeterminateBank(); setBankViaPopover(string.Empty); - hitObjectHasSampleBank(0, "normal"); - hitObjectHasSampleBank(1, "soft"); + hitObjectHasSampleBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleBank(1, HitSampleInfo.BANK_SOFT); samplePopoverHasIndeterminateBank(); - setBankViaPopover("normal"); - hitObjectHasSampleBank(0, "normal"); - hitObjectHasSampleBank(1, "normal"); - samplePopoverHasSingleBank("normal"); + setBankViaPopover(HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleBank(1, HitSampleInfo.BANK_NORMAL); + samplePopoverHasSingleBank(HitSampleInfo.BANK_NORMAL); + } + + [Test] + public void TestHotkeysMultipleSelectionWithSameSampleBank() + { + AddStep("unify sample bank", () => + { + foreach (var h in EditorBeatmap.HitObjects) + { + for (int i = 0; i < h.Samples.Count; i++) + { + h.Samples[i] = h.Samples[i].With(newBank: HitSampleInfo.BANK_SOFT); + } + } + }); + + AddStep("select both objects", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects)); + + hitObjectHasSampleBank(0, HitSampleInfo.BANK_SOFT); + hitObjectHasSampleBank(1, HitSampleInfo.BANK_SOFT); + + AddStep("Press normal bank shortcut", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.W); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + + hitObjectHasSampleBank(0, HitSampleInfo.BANK_NORMAL); + hitObjectHasSampleBank(1, HitSampleInfo.BANK_NORMAL); + + AddStep("Press drum bank shortcut", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + + hitObjectHasSampleBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleBank(1, HitSampleInfo.BANK_DRUM); + + AddStep("Press auto bank shortcut", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.Q); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + + // Should be a noop. + hitObjectHasSampleBank(0, HitSampleInfo.BANK_DRUM); + hitObjectHasSampleBank(1, HitSampleInfo.BANK_DRUM); + } + + [Test] + public void TestHotkeysDuringPlacement() + { + AddStep("Enter placement mode", () => InputManager.Key(Key.Number2)); + AddStep("Move mouse to centre", () => InputManager.MoveMouseTo(Editor.ChildrenOfType().First().ScreenSpaceDrawQuad.Centre)); + + AddStep("Move between two objects", () => EditorClock.Seek(250)); + + AddStep("Press normal bank shortcut", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.W); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + + checkPlacementSample(HitSampleInfo.BANK_NORMAL); + + AddStep("Press drum bank shortcut", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.R); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + + checkPlacementSample(HitSampleInfo.BANK_DRUM); + + AddStep("Press auto bank shortcut", () => + { + InputManager.PressKey(Key.ShiftLeft); + InputManager.Key(Key.Q); + InputManager.ReleaseKey(Key.ShiftLeft); + }); + + checkPlacementSample(HitSampleInfo.BANK_NORMAL); + + AddStep("Move after second object", () => EditorClock.Seek(750)); + checkPlacementSample(HitSampleInfo.BANK_SOFT); + + AddStep("Move to first object", () => EditorClock.Seek(0)); + checkPlacementSample(HitSampleInfo.BANK_NORMAL); + + void checkPlacementSample(string expected) => AddAssert($"Placement sample is {expected}", () => EditorBeatmap.PlacementObject.Value.Samples.First().Bank, () => Is.EqualTo(expected)); } private void clickSamplePiece(int objectIndex) => AddStep($"click {objectIndex.ToOrdinalWords()} sample piece", () => @@ -271,6 +402,12 @@ namespace osu.Game.Tests.Visual.Editing InputManager.Key(Key.Enter); }); + private void hitObjectHasSamples(int objectIndex, params string[] samples) => AddAssert($"{objectIndex.ToOrdinalWords()} has samples {string.Join(',', samples)}", () => + { + var h = EditorBeatmap.HitObjects.ElementAt(objectIndex); + return h.Samples.Select(s => s.Name).SequenceEqual(samples); + }); + private void hitObjectHasSampleBank(int objectIndex, string bank) => AddAssert($"{objectIndex.ToOrdinalWords()} has bank {bank}", () => { var h = EditorBeatmap.HitObjects.ElementAt(objectIndex); diff --git a/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs b/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs new file mode 100644 index 0000000000..a5681bea4a --- /dev/null +++ b/osu.Game.Tests/Visual/Editing/TestScenePlacementBlueprint.cs @@ -0,0 +1,106 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Screens; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Input.Bindings; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.UI; +using osu.Game.Screens.Edit.Compose.Components; +using osu.Game.Tests.Beatmaps; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Editing +{ + public partial class TestScenePlacementBlueprint : EditorTestScene + { + protected override Ruleset CreateEditorRuleset() => new OsuRuleset(); + + protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset, false); + + private GlobalActionContainer globalActionContainer => this.ChildrenOfType().Single(); + + [Test] + public void TestCommitPlacementViaGlobalAction() + { + Playfield playfield = null!; + + AddStep("select slider placement tool", () => InputManager.Key(Key.Number3)); + AddStep("move mouse to top left of playfield", () => + { + playfield = this.ChildrenOfType().Single(); + var location = (3 * playfield.ScreenSpaceDrawQuad.TopLeft + playfield.ScreenSpaceDrawQuad.BottomRight) / 4; + InputManager.MoveMouseTo(location); + }); + AddStep("begin placement", () => InputManager.Click(MouseButton.Left)); + AddStep("move mouse to bottom right of playfield", () => + { + var location = (playfield.ScreenSpaceDrawQuad.TopLeft + 3 * playfield.ScreenSpaceDrawQuad.BottomRight) / 4; + InputManager.MoveMouseTo(location); + }); + AddStep("confirm via global action", () => + { + globalActionContainer.TriggerPressed(GlobalAction.Select); + globalActionContainer.TriggerReleased(GlobalAction.Select); + }); + AddAssert("slider placed", () => EditorBeatmap.HitObjects.Count, () => Is.EqualTo(1)); + } + + [Test] + public void TestAbortPlacementViaGlobalAction() + { + Playfield playfield = null!; + + AddStep("select slider placement tool", () => InputManager.Key(Key.Number3)); + AddStep("move mouse to top left of playfield", () => + { + playfield = this.ChildrenOfType().Single(); + var location = (3 * playfield.ScreenSpaceDrawQuad.TopLeft + playfield.ScreenSpaceDrawQuad.BottomRight) / 4; + InputManager.MoveMouseTo(location); + }); + AddStep("begin placement", () => InputManager.Click(MouseButton.Left)); + AddStep("move mouse to bottom right of playfield", () => + { + var location = (playfield.ScreenSpaceDrawQuad.TopLeft + 3 * playfield.ScreenSpaceDrawQuad.BottomRight) / 4; + InputManager.MoveMouseTo(location); + }); + AddStep("abort via global action", () => + { + globalActionContainer.TriggerPressed(GlobalAction.Back); + globalActionContainer.TriggerReleased(GlobalAction.Back); + }); + AddAssert("editor is still current", () => Editor.IsCurrentScreen()); + AddAssert("slider not placed", () => EditorBeatmap.HitObjects.Count, () => Is.EqualTo(0)); + AddAssert("no active placement", () => this.ChildrenOfType().Single().CurrentPlacement.PlacementActive, + () => Is.EqualTo(PlacementBlueprint.PlacementState.Waiting)); + } + + [Test] + public void TestCommitPlacementViaToolChange() + { + Playfield playfield = null!; + + AddStep("select slider placement tool", () => InputManager.Key(Key.Number3)); + AddStep("move mouse to top left of playfield", () => + { + playfield = this.ChildrenOfType().Single(); + var location = (3 * playfield.ScreenSpaceDrawQuad.TopLeft + playfield.ScreenSpaceDrawQuad.BottomRight) / 4; + InputManager.MoveMouseTo(location); + }); + AddStep("begin placement", () => InputManager.Click(MouseButton.Left)); + AddStep("move mouse to bottom right of playfield", () => + { + var location = (playfield.ScreenSpaceDrawQuad.TopLeft + 3 * playfield.ScreenSpaceDrawQuad.BottomRight) / 4; + InputManager.MoveMouseTo(location); + }); + + AddStep("change tool to circle", () => InputManager.Key(Key.Number2)); + AddAssert("slider placed", () => EditorBeatmap.HitObjects.Count, () => Is.EqualTo(1)); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs index 6eae795630..f3701b664c 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs @@ -8,6 +8,8 @@ using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Scoring; using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Tests.Visual.Ranking; @@ -49,6 +51,21 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); } + [Test] + public void TestModRemovingTimedInputs() + { + AddStep("Set score with mod removing timed inputs", () => + { + offsetControl.ReferenceScore.Value = new ScoreInfo + { + HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(10), + Mods = new Mod[] { new OsuModRelax() } + }; + }); + + AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); + } + [Test] public void TestCalibrationFromZero() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs index 114c554d28..e52ec6f8cc 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneGameplaySampleTriggerSource.cs @@ -73,7 +73,7 @@ namespace osu.Game.Tests.Visual.Gameplay new HitCircle { StartTime = t += spacing, - Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "soft") }, + Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT) }, }, new HitCircle { @@ -83,7 +83,7 @@ namespace osu.Game.Tests.Visual.Gameplay { StartTime = t += spacing, Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, Vector2.UnitY * 200 }), - Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE, "soft") }, + Samples = new[] { new HitSampleInfo(HitSampleInfo.HIT_WHISTLE, HitSampleInfo.BANK_SOFT) }, }, }); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs index ae46dda750..f97019e466 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlay.cs @@ -31,8 +31,8 @@ namespace osu.Game.Tests.Visual.Gameplay private HUDOverlay hudOverlay = null!; - [Cached] - private ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); + [Cached(typeof(ScoreProcessor))] + private ScoreProcessor scoreProcessor => gameplayState.ScoreProcessor; [Cached(typeof(HealthProcessor))] private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneScoring.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneScoring.cs index 8fff07e6d8..2b378c8013 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneScoring.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneScoring.cs @@ -22,6 +22,7 @@ using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring.Legacy; using osuTK; using osuTK.Graphics; using osuTK.Input; @@ -124,8 +125,8 @@ namespace osu.Game.Tests.Visual.Gameplay graphs.Clear(); legend.Clear(); - runForProcessor("lazer-standardised", Color4.YellowGreen, new ScoreProcessor(new OsuRuleset()) { Mode = { Value = ScoringMode.Standardised } }); - runForProcessor("lazer-classic", Color4.MediumPurple, new ScoreProcessor(new OsuRuleset()) { Mode = { Value = ScoringMode.Classic } }); + runForProcessor("lazer-standardised", Color4.YellowGreen, new ScoreProcessor(new OsuRuleset()), ScoringMode.Standardised); + runForProcessor("lazer-classic", Color4.MediumPurple, new ScoreProcessor(new OsuRuleset()), ScoringMode.Classic); runScoreV1(); runScoreV2(); @@ -218,7 +219,7 @@ namespace osu.Game.Tests.Visual.Gameplay }); } - private void runForProcessor(string name, Color4 colour, ScoreProcessor processor) + private void runForProcessor(string name, Color4 colour, ScoreProcessor processor, ScoringMode mode) { int maxCombo = sliderMaxCombo.Current.Value; @@ -232,10 +233,10 @@ namespace osu.Game.Tests.Visual.Gameplay () => processor.ApplyResult(new OsuJudgementResult(new HitCircle(), new OsuJudgement()) { Type = HitResult.Great }), () => processor.ApplyResult(new OsuJudgementResult(new HitCircle(), new OsuJudgement()) { Type = HitResult.Ok }), () => processor.ApplyResult(new OsuJudgementResult(new HitCircle(), new OsuJudgement()) { Type = HitResult.Miss }), - () => (int)processor.TotalScore.Value); + () => processor.GetDisplayScore(mode)); } - private void runForAlgorithm(string name, Color4 colour, Action applyHit, Action applyNonPerfect, Action applyMiss, Func getTotalScore) + private void runForAlgorithm(string name, Color4 colour, Action applyHit, Action applyNonPerfect, Action applyMiss, Func getTotalScore) { int maxCombo = sliderMaxCombo.Current.Value; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs index 93fec60de4..4ae115a68d 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditorMultipleSkins.cs @@ -22,8 +22,8 @@ namespace osu.Game.Tests.Visual.Gameplay { public partial class TestSceneSkinEditorMultipleSkins : SkinnableTestScene { - [Cached] - private readonly ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); + [Cached(typeof(ScoreProcessor))] + private ScoreProcessor scoreProcessor => gameplayState.ScoreProcessor; [Cached(typeof(HealthProcessor))] private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs index 0439656aae..89432940ba 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs @@ -28,8 +28,8 @@ namespace osu.Game.Tests.Visual.Gameplay { private HUDOverlay hudOverlay; - [Cached] - private ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); + [Cached(typeof(ScoreProcessor))] + private ScoreProcessor scoreProcessor => gameplayState.ScoreProcessor; [Cached(typeof(HealthProcessor))] private HealthProcessor healthProcessor = new DrainingHealthProcessor(0); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs index c95e8ee5b2..2cb3303dd6 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableScoreCounter.cs @@ -10,13 +10,14 @@ using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Play.HUD; using osu.Game.Skinning; +using osu.Game.Tests.Gameplay; namespace osu.Game.Tests.Visual.Gameplay { public partial class TestSceneSkinnableScoreCounter : SkinnableHUDComponentTestScene { - [Cached] - private ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); + [Cached(typeof(ScoreProcessor))] + private ScoreProcessor scoreProcessor = TestGameplayState.Create(new OsuRuleset()).ScoreProcessor; protected override Drawable CreateDefaultImplementation() => new DefaultScoreCounter(); protected override Drawable CreateLegacyImplementation() => new LegacyScoreCounter(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs index 8ae6a2a5fc..dbd14db818 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSoloGameplayLeaderboard.cs @@ -16,13 +16,14 @@ using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Select; +using osu.Game.Tests.Gameplay; namespace osu.Game.Tests.Visual.Gameplay { public partial class TestSceneSoloGameplayLeaderboard : OsuTestScene { - [Cached] - private readonly ScoreProcessor scoreProcessor = new ScoreProcessor(new OsuRuleset()); + [Cached(typeof(ScoreProcessor))] + private readonly ScoreProcessor scoreProcessor = TestGameplayState.Create(new OsuRuleset()).ScoreProcessor; private readonly BindableList scores = new BindableList(); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSongProgress.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSongProgress.cs index 5855838d3c..e975a85401 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSongProgress.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSongProgress.cs @@ -7,12 +7,15 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; using osu.Framework.Testing; +using osu.Framework.Utils; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.UI; using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osu.Game.Skinning; +using osuTK.Graphics; namespace osu.Game.Tests.Visual.Gameplay { @@ -21,6 +24,8 @@ namespace osu.Game.Tests.Visual.Gameplay { private GameplayClockContainer gameplayClockContainer = null!; + private Box background = null!; + private const double skip_target_time = -2000; [BackgroundDependencyLoader] @@ -30,11 +35,20 @@ namespace osu.Game.Tests.Visual.Gameplay FrameStabilityContainer frameStabilityContainer; - Add(gameplayClockContainer = new MasterGameplayClockContainer(Beatmap.Value, skip_target_time) + AddRange(new Drawable[] { - Child = frameStabilityContainer = new FrameStabilityContainer + background = new Box { - MaxCatchUpFrames = 1 + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue + }, + gameplayClockContainer = new MasterGameplayClockContainer(Beatmap.Value, skip_target_time) + { + Child = frameStabilityContainer = new FrameStabilityContainer + { + MaxCatchUpFrames = 1 + } } }); @@ -71,9 +85,20 @@ namespace osu.Game.Tests.Visual.Gameplay applyToArgonProgress(s => s.ShowGraph.Value = b); }); + AddStep("set white background", () => background.FadeColour(Color4.White, 200, Easing.OutQuint)); + AddStep("randomise background colour", () => background.FadeColour(new Colour4(RNG.NextSingle(), RNG.NextSingle(), RNG.NextSingle(), 1), 200, Easing.OutQuint)); + AddStep("stop", gameplayClockContainer.Stop); } + [Test] + public void TestSeekToKnownTime() + { + AddStep("seek to known time", () => gameplayClockContainer.Seek(60000)); + AddWaitStep("wait some for seek", 15); + AddStep("stop", () => gameplayClockContainer.Stop()); + } + private void applyToArgonProgress(Action action) => this.ChildrenOfType().ForEach(action); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs index dbce62cbef..a6663f3086 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboard.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 NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -8,6 +9,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Timing; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; using osu.Game.IO; @@ -42,6 +44,18 @@ namespace osu.Game.Tests.Visual.Gameplay AddStep("Load storyboard with missing video", () => loadStoryboard("storyboard_no_video.osu")); } + [Test] + public void TestVideoSize() + { + AddStep("load storyboard with only video", () => + { + // LegacyStoryboardDecoder doesn't parse WidescreenStoryboard, so it is set manually + loadStoryboard("storyboard_only_video.osu", s => s.BeatmapInfo.WidescreenStoryboard = false); + }); + + AddAssert("storyboard is correct width", () => Precision.AlmostEquals(storyboard?.Width ?? 0f, 480 * 16 / 9f)); + } + [BackgroundDependencyLoader] private void load() { @@ -102,7 +116,7 @@ namespace osu.Game.Tests.Visual.Gameplay decoupledClock.ChangeSource(Beatmap.Value.Track); } - private void loadStoryboard(string filename) + private void loadStoryboard(string filename, Action? setUpStoryboard = null) { Storyboard loaded; @@ -113,6 +127,8 @@ namespace osu.Game.Tests.Visual.Gameplay loaded = decoder.Decode(bfr); } + setUpStoryboard?.Invoke(loaded); + loadStoryboard(loaded); } } diff --git a/osu.Game.Tests/Visual/Mods/TestSceneModAccuracyChallenge.cs b/osu.Game.Tests/Visual/Mods/TestSceneModAccuracyChallenge.cs new file mode 100644 index 0000000000..6bdb9132e1 --- /dev/null +++ b/osu.Game.Tests/Visual/Mods/TestSceneModAccuracyChallenge.cs @@ -0,0 +1,69 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; +using osuTK; + +namespace osu.Game.Tests.Visual.Mods +{ + public partial class TestSceneModAccuracyChallenge : ModTestScene + { + protected override Ruleset CreatePlayerRuleset() => new OsuRuleset(); + + protected override TestPlayer CreateModPlayer(Ruleset ruleset) + { + var player = base.CreateModPlayer(ruleset); + return player; + } + + protected override bool AllowFail => true; + + [Test] + public void TestMaximumAchievableAccuracy() => + CreateModTest(new ModTestData + { + Mod = new ModAccuracyChallenge + { + MinimumAccuracy = { Value = 0.6 } + }, + Autoplay = false, + Beatmap = new Beatmap + { + HitObjects = Enumerable.Range(0, 5).Select(i => new HitCircle + { + StartTime = i * 250, + Position = new Vector2(i * 50) + }).Cast().ToList() + }, + PassCondition = () => Player.GameplayState.HasFailed && Player.ScoreProcessor.JudgedHits >= 3 + }); + + [Test] + public void TestStandardAccuracy() => + CreateModTest(new ModTestData + { + Mod = new ModAccuracyChallenge + { + MinimumAccuracy = { Value = 0.6 }, + AccuracyJudgeMode = { Value = ModAccuracyChallenge.AccuracyMode.Standard } + }, + Autoplay = false, + Beatmap = new Beatmap + { + HitObjects = Enumerable.Range(0, 5).Select(i => new HitCircle + { + StartTime = i * 250, + Position = new Vector2(i * 50) + }).Cast().ToList() + }, + PassCondition = () => Player.GameplayState.HasFailed && Player.ScoreProcessor.JudgedHits >= 1 + }); + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs b/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs index 649c662e41..906eea9553 100644 --- a/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs +++ b/osu.Game.Tests/Visual/Multiplayer/MultiplayerGameplayLeaderboardTestScene.cs @@ -21,7 +21,6 @@ using osu.Game.Online.Multiplayer; using osu.Game.Online.Spectator; using osu.Game.Replays.Legacy; using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring; using osu.Game.Screens.Play.HUD; namespace osu.Game.Tests.Visual.Multiplayer @@ -188,15 +187,12 @@ namespace osu.Game.Tests.Visual.Multiplayer if (!lastHeaders.TryGetValue(userId, out var header)) { - lastHeaders[userId] = header = new FrameHeader(new ScoreInfo + lastHeaders[userId] = header = new FrameHeader(0, 0, 0, 0, new Dictionary { - Statistics = new Dictionary - { - [HitResult.Miss] = 0, - [HitResult.Meh] = 0, - [HitResult.Great] = 0 - } - }); + [HitResult.Miss] = 0, + [HitResult.Meh] = 0, + [HitResult.Great] = 0 + }, new ScoreProcessorStatistics(), DateTimeOffset.Now); } switch (RNG.Next(0, 3)) diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs index 45f671618e..66ba908879 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs @@ -8,6 +8,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input; using osu.Framework.Testing; @@ -57,11 +58,36 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("customisation area not expanded", () => this.ChildrenOfType().Single().Height == 0); } + [Test] + public void TestSelectAllButtonUpdatesStateWhenSearchTermChanged() + { + createFreeModSelect(); + + AddStep("apply search term", () => freeModSelectOverlay.SearchTerm = "ea"); + + AddAssert("select all button enabled", () => this.ChildrenOfType().Single().Enabled.Value); + + AddStep("click select all button", navigateAndClick); + AddAssert("select all button disabled", () => !this.ChildrenOfType().Single().Enabled.Value); + + AddStep("change search term", () => freeModSelectOverlay.SearchTerm = "e"); + + AddAssert("select all button enabled", () => this.ChildrenOfType().Single().Enabled.Value); + + void navigateAndClick() where T : Drawable + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + } + } + [Test] public void TestSelectDeselectAllViaKeyboard() { createFreeModSelect(); + AddStep("kill search bar focus", () => freeModSelectOverlay.SearchTextBox.KillFocus()); + AddStep("press ctrl+a", () => InputManager.Keys(PlatformAction.SelectAll)); AddUntilStep("all mods selected", assertAllAvailableModsSelected); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs index 3efc7fbd30..6d309078e6 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs @@ -129,7 +129,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { Playlist = { - new MultiplayerPlaylistItem(playlistItem), + TestMultiplayerClient.CreateMultiplayerPlaylistItem(playlistItem), }, Users = { localUser }, Host = localUser, diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index d747d23229..09624f63b7 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -906,7 +906,7 @@ namespace osu.Game.Tests.Visual.Multiplayer enterGameplay(); AddStep("join other user", () => multiplayerClient.AddUser(new APIUser { Id = 1234 })); - AddStep("add item as other user", () => multiplayerClient.AddUserPlaylistItem(1234, new MultiplayerPlaylistItem( + AddStep("add item as other user", () => multiplayerClient.AddUserPlaylistItem(1234, TestMultiplayerClient.CreateMultiplayerPlaylistItem( new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID, @@ -938,7 +938,7 @@ namespace osu.Game.Tests.Visual.Multiplayer enterGameplay(); AddStep("join other user", () => multiplayerClient.AddUser(new APIUser { Id = 1234 })); - AddStep("add item as other user", () => multiplayerClient.AddUserPlaylistItem(1234, new MultiplayerPlaylistItem( + AddStep("add item as other user", () => multiplayerClient.AddUserPlaylistItem(1234, TestMultiplayerClient.CreateMultiplayerPlaylistItem( new PlaylistItem(beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.Ruleset.OnlineID == 0)).BeatmapInfo) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID, diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs index 9b130071cc..947b7e5be6 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs @@ -94,6 +94,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [TestCase(typeof(OsuModHidden), typeof(OsuModTraceable))] // Incompatible. public void TestAllowedModDeselectedWhenRequired(Type allowedMod, Type requiredMod) { + AddStep("change ruleset", () => Ruleset.Value = new OsuRuleset().RulesetInfo); AddStep($"select {allowedMod.ReadableName()} as allowed", () => songSelect.FreeMods.Value = new[] { (Mod)Activator.CreateInstance(allowedMod) }); AddStep($"select {requiredMod.ReadableName()} as required", () => songSelect.Mods.Value = new[] { (Mod)Activator.CreateInstance(requiredMod) }); @@ -102,17 +103,17 @@ namespace osu.Game.Tests.Visual.Multiplayer // A previous test's mod overlay could still be fading out. AddUntilStep("wait for only one freemod overlay", () => this.ChildrenOfType().Count() == 1); - assertHasFreeModButton(allowedMod, false); - assertHasFreeModButton(requiredMod, false); + assertFreeModNotShown(allowedMod); + assertFreeModNotShown(requiredMod); } - private void assertHasFreeModButton(Type type, bool hasButton = true) + private void assertFreeModNotShown(Type type) { - AddAssert($"{type.ReadableName()} {(hasButton ? "displayed" : "not displayed")} in freemod overlay", + AddAssert($"{type.ReadableName()} not displayed in freemod overlay", () => this.ChildrenOfType() .Single() .ChildrenOfType() - .Where(panel => !panel.Filtered.Value) + .Where(panel => panel.Visible) .All(b => b.Mod.GetType() != type)); } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs index 8816787ceb..a41eff067b 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs @@ -203,7 +203,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("mod select contains only double time mod", () => this.ChildrenOfType().Single().UserModsSelectOverlay .ChildrenOfType() - .SingleOrDefault(panel => !panel.Filtered.Value)?.Mod is OsuModDoubleTime); + .SingleOrDefault(panel => panel.Visible)?.Mod is OsuModDoubleTime); } [Test] diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs index d7578b4114..2100f82886 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs @@ -215,7 +215,7 @@ namespace osu.Game.Tests.Visual.Multiplayer /// private void addItemStep(bool expired = false, int? userId = null) => AddStep("add item", () => { - MultiplayerClient.AddUserPlaylistItem(userId ?? API.LocalUser.Value.OnlineID, new MultiplayerPlaylistItem(new PlaylistItem(importedBeatmap) + MultiplayerClient.AddUserPlaylistItem(userId ?? API.LocalUser.Value.OnlineID, TestMultiplayerClient.CreateMultiplayerPlaylistItem(new PlaylistItem(importedBeatmap) { Expired = expired, PlayedAt = DateTimeOffset.Now diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs index bb37f1a5a7..47fb4e06ea 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs @@ -130,7 +130,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("add playlist item", () => { - MultiplayerPlaylistItem item = new MultiplayerPlaylistItem(new PlaylistItem(importedBeatmap)); + MultiplayerPlaylistItem item = TestMultiplayerClient.CreateMultiplayerPlaylistItem(new PlaylistItem(importedBeatmap)); MultiplayerClient.AddUserPlaylistItem(userId(), item).WaitSafely(); diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs index 603573058e..5483be5676 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneBeatmapEditorNavigation.cs @@ -3,15 +3,61 @@ using System.Linq; using NUnit.Framework; +using osu.Framework.Extensions; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Screens; +using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Osu; using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.GameplayTest; using osu.Game.Screens.Menu; +using osu.Game.Screens.Select; +using osu.Game.Tests.Resources; +using osuTK.Input; namespace osu.Game.Tests.Visual.Navigation { public partial class TestSceneBeatmapEditorNavigation : OsuGameTestScene { + [Test] + public void TestEditorGameplayTestAlwaysUsesOriginalRuleset() + { + BeatmapSetInfo beatmapSet = null!; + + AddStep("import test beatmap", () => Game.BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).WaitSafely()); + AddStep("retrieve beatmap", () => beatmapSet = Game.BeatmapManager.QueryBeatmapSet(set => !set.Protected).AsNonNull().Value.Detach()); + + AddStep("present beatmap", () => Game.PresentBeatmap(beatmapSet)); + AddUntilStep("wait for song select", + () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet) + && Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect + && songSelect.IsLoaded); + AddStep("switch ruleset", () => Game.Ruleset.Value = new ManiaRuleset().RulesetInfo); + + AddStep("open editor", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).Edit(beatmapSet.Beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == 0))); + AddUntilStep("wait for editor open", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.ReadyForUse); + AddStep("test gameplay", () => getEditor().TestGameplay()); + + AddUntilStep("wait for player", () => + { + // notifications may fire at almost any inopportune time and cause annoying test failures. + // relentlessly attempt to dismiss any and all interfering overlays, which includes notifications. + // this is theoretically not foolproof, but it's the best that can be done here. + Game.CloseAllOverlays(); + return Game.ScreenStack.CurrentScreen is EditorPlayer editorPlayer && editorPlayer.IsLoaded; + }); + + AddAssert("current ruleset is osu!", () => Game.Ruleset.Value.Equals(new OsuRuleset().RulesetInfo)); + + AddStep("exit to song select", () => Game.PerformFromScreen(_ => { }, typeof(PlaySongSelect).Yield())); + AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is PlaySongSelect); + AddAssert("previous ruleset restored", () => Game.Ruleset.Value.Equals(new ManiaRuleset().RulesetInfo)); + } + /// /// When entering the editor, a new beatmap is created as part of the asynchronous load process. /// This test ensures that in the case of an early exit from the editor (ie. while it's still loading) @@ -38,5 +84,94 @@ namespace osu.Game.Tests.Visual.Navigation BeatmapSetInfo[] allBeatmapSets() => Game.Realm.Run(realm => realm.All().Where(x => !x.DeletePending).ToArray()); } + + [Test] + public void TestExitEditorWithoutSelection() + { + BeatmapSetInfo beatmapSet = null!; + + AddStep("import test beatmap", () => Game.BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).WaitSafely()); + AddStep("retrieve beatmap", () => beatmapSet = Game.BeatmapManager.QueryBeatmapSet(set => !set.Protected).AsNonNull().Value.Detach()); + + AddStep("present beatmap", () => Game.PresentBeatmap(beatmapSet)); + AddUntilStep("wait for song select", + () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet) + && Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect + && songSelect.IsLoaded); + + AddStep("open editor", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).Edit(beatmapSet.Beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == 0))); + AddUntilStep("wait for editor open", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.ReadyForUse); + + AddStep("escape once", () => InputManager.Key(Key.Escape)); + + AddUntilStep("wait for editor exit", () => Game.ScreenStack.CurrentScreen is not Editor); + } + + [Test] + public void TestExitEditorWithSelection() + { + BeatmapSetInfo beatmapSet = null!; + + AddStep("import test beatmap", () => Game.BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).WaitSafely()); + AddStep("retrieve beatmap", () => beatmapSet = Game.BeatmapManager.QueryBeatmapSet(set => !set.Protected).AsNonNull().Value.Detach()); + + AddStep("present beatmap", () => Game.PresentBeatmap(beatmapSet)); + AddUntilStep("wait for song select", + () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet) + && Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect + && songSelect.IsLoaded); + + AddStep("open editor", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).Edit(beatmapSet.Beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == 0))); + AddUntilStep("wait for editor open", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.ReadyForUse); + + AddStep("make selection", () => + { + var beatmap = getEditorBeatmap(); + beatmap.SelectedHitObjects.AddRange(beatmap.HitObjects.Take(5)); + }); + + AddAssert("selection exists", () => getEditorBeatmap().SelectedHitObjects, () => Has.Count.GreaterThan(0)); + + AddStep("escape once", () => InputManager.Key(Key.Escape)); + + AddAssert("selection empty", () => getEditorBeatmap().SelectedHitObjects, () => Has.Count.Zero); + + AddStep("escape again", () => InputManager.Key(Key.Escape)); + + AddUntilStep("wait for editor exit", () => Game.ScreenStack.CurrentScreen is not Editor); + } + + [Test] + public void TestLastTimestampRememberedOnExit() + { + BeatmapSetInfo beatmapSet = null!; + + AddStep("import test beatmap", () => Game.BeatmapManager.Import(TestResources.GetTestBeatmapForImport()).WaitSafely()); + AddStep("retrieve beatmap", () => beatmapSet = Game.BeatmapManager.QueryBeatmapSet(set => !set.Protected).AsNonNull().Value.Detach()); + + AddStep("present beatmap", () => Game.PresentBeatmap(beatmapSet)); + AddUntilStep("wait for song select", + () => Game.Beatmap.Value.BeatmapSetInfo.Equals(beatmapSet) + && Game.ScreenStack.CurrentScreen is PlaySongSelect songSelect + && songSelect.IsLoaded); + + AddStep("open editor", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).Edit(beatmapSet.Beatmaps.First(beatmap => beatmap.Ruleset.OnlineID == 0))); + AddUntilStep("wait for editor open", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.ReadyForUse); + + AddStep("seek to arbitrary time", () => getEditor().ChildrenOfType().First().Seek(1234)); + AddUntilStep("time is correct", () => getEditor().ChildrenOfType().First().CurrentTime, () => Is.EqualTo(1234)); + + AddStep("exit editor", () => InputManager.Key(Key.Escape)); + AddUntilStep("wait for editor exit", () => Game.ScreenStack.CurrentScreen is not Editor); + + AddStep("open editor", () => ((PlaySongSelect)Game.ScreenStack.CurrentScreen).Edit()); + + AddUntilStep("wait for editor open", () => Game.ScreenStack.CurrentScreen is Editor editor && editor.ReadyForUse); + AddUntilStep("time is correct", () => getEditor().ChildrenOfType().First().CurrentTime, () => Is.EqualTo(1234)); + } + + private EditorBeatmap getEditorBeatmap() => getEditor().ChildrenOfType().Single(); + + private Editor getEditor() => (Editor)Game.ScreenStack.CurrentScreen; } } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 193cec8907..18aef99ccd 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -17,6 +17,7 @@ using osu.Game.Beatmaps; using osu.Game.Collections; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; using osu.Game.Online.Leaderboards; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapListing; @@ -539,6 +540,11 @@ namespace osu.Game.Tests.Visual.Navigation AddStep("open room", () => multiplayerComponents.ChildrenOfType().Single().Open()); AddStep("press back button", () => Game.ChildrenOfType().First().Action()); AddWaitStep("wait two frames", 2); + + AddStep("exit lounge", () => Game.ScreenStack.Exit()); + // `TestMultiplayerComponents` registers a request handler in its BDL, but never unregisters it. + // to prevent the handler living for longer than it should be, clean up manually. + AddStep("clean up multiplayer request handler", () => ((DummyAPIAccess)API).HandleRequest = null); } [Test] diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs index a27c4ddad2..d9763ef6c8 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs @@ -86,6 +86,7 @@ namespace osu.Game.Tests.Visual.Online StarRating = 9.99, DifficultyName = @"TEST", Length = 456000, + HitLength = 400000, RulesetID = 3, CircleSize = 1, DrainRate = 2.3f, diff --git a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs index 4f825e1191..885c00be80 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneCurrentlyPlayingDisplay.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Linq; using System.Threading; @@ -24,8 +22,8 @@ namespace osu.Game.Tests.Visual.Online { private readonly APIUser streamingUser = new APIUser { Id = 2, Username = "Test user" }; - private TestSpectatorClient spectatorClient; - private CurrentlyPlayingDisplay currentlyPlaying; + private TestSpectatorClient spectatorClient = null!; + private CurrentlyPlayingDisplay currentlyPlaying = null!; [SetUpSteps] public void SetUpSteps() @@ -88,13 +86,13 @@ namespace osu.Game.Tests.Visual.Online "pishifat" }; - protected override Task ComputeValueAsync(int lookup, CancellationToken token = default) + protected override Task ComputeValueAsync(int lookup, CancellationToken token = default) { // tests against failed lookups if (lookup == 13) - return Task.FromResult(null); + return Task.FromResult(null); - return Task.FromResult(new APIUser + return Task.FromResult(new APIUser { Id = lookup, Username = usernames[lookup % usernames.Length], diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs b/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs index fcd5f97fcc..67211a3b72 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs @@ -174,78 +174,33 @@ namespace osu.Game.Tests.Visual.Ranking private class TestRulesetAllStatsRequireHitEvents : TestRuleset { - public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) + public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => new[] { - return new[] - { - new StatisticRow - { - Columns = new[] - { - new StatisticItem("Statistic Requiring Hit Events 1", - () => CreatePlaceholderStatistic("Placeholder statistic. Requires hit events"), true) - } - }, - new StatisticRow - { - Columns = new[] - { - new StatisticItem("Statistic Requiring Hit Events 2", - () => CreatePlaceholderStatistic("Placeholder statistic. Requires hit events"), true) - } - } - }; - } + new StatisticItem("Statistic Requiring Hit Events 1", () => CreatePlaceholderStatistic("Placeholder statistic. Requires hit events"), true), + new StatisticItem("Statistic Requiring Hit Events 2", () => CreatePlaceholderStatistic("Placeholder statistic. Requires hit events"), true) + }; } private class TestRulesetNoStatsRequireHitEvents : TestRuleset { - public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) + public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) { return new[] { - new StatisticRow - { - Columns = new[] - { - new StatisticItem("Statistic Not Requiring Hit Events 1", - () => CreatePlaceholderStatistic("Placeholder statistic. Does not require hit events")) - } - }, - new StatisticRow - { - Columns = new[] - { - new StatisticItem("Statistic Not Requiring Hit Events 2", - () => CreatePlaceholderStatistic("Placeholder statistic. Does not require hit events")) - } - } + new StatisticItem("Statistic Not Requiring Hit Events 1", () => CreatePlaceholderStatistic("Placeholder statistic. Does not require hit events")), + new StatisticItem("Statistic Not Requiring Hit Events 2", () => CreatePlaceholderStatistic("Placeholder statistic. Does not require hit events")) }; } } private class TestRulesetMixed : TestRuleset { - public override StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) + public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) { return new[] { - new StatisticRow - { - Columns = new[] - { - new StatisticItem("Statistic Requiring Hit Events", - () => CreatePlaceholderStatistic("Placeholder statistic. Requires hit events"), true) - } - }, - new StatisticRow - { - Columns = new[] - { - new StatisticItem("Statistic Not Requiring Hit Events", - () => CreatePlaceholderStatistic("Placeholder statistic. Does not require hit events")) - } - } + new StatisticItem("Statistic Requiring Hit Events", () => CreatePlaceholderStatistic("Placeholder statistic. Requires hit events"), true), + new StatisticItem("Statistic Not Requiring Hit Events", () => CreatePlaceholderStatistic("Placeholder statistic. Does not require hit events")) }; } } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs index 61a8322ee3..61f95dc628 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs @@ -453,6 +453,25 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("Un-filter", () => carousel.Filter(new FilterCriteria(), false)); } + [Test] + public void TestRewindToDeletedBeatmap() + { + loadBeatmaps(); + + var firstAdded = TestResources.CreateTestBeatmapSetInfo(); + + AddStep("add new set", () => carousel.UpdateBeatmapSet(firstAdded)); + AddStep("select set", () => carousel.SelectBeatmap(firstAdded.Beatmaps.First())); + + nextRandom(); + + AddStep("delete set", () => carousel.RemoveBeatmapSet(firstAdded)); + + prevRandom(); + + AddAssert("deleted set not selected", () => carousel.SelectedBeatmapSet?.Equals(firstAdded) == false); + } + /// /// Test adding and removing beatmap sets /// diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs index c2537cff79..379bd838cd 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapMetadataDisplay.cs @@ -1,10 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -24,10 +23,10 @@ namespace osu.Game.Tests.Visual.SongSelect { public partial class TestSceneBeatmapMetadataDisplay : OsuTestScene { - private BeatmapMetadataDisplay display; + private BeatmapMetadataDisplay display = null!; [Resolved] - private BeatmapManager manager { get; set; } + private BeatmapManager manager { get; set; } = null!; [Cached(typeof(BeatmapDifficultyCache))] private readonly TestBeatmapDifficultyCache testDifficultyCache = new TestBeatmapDifficultyCache(); @@ -121,7 +120,7 @@ namespace osu.Game.Tests.Visual.SongSelect private partial class TestBeatmapDifficultyCache : BeatmapDifficultyCache { - private TaskCompletionSource calculationBlocker; + private TaskCompletionSource? calculationBlocker; private bool blockCalculation; @@ -142,10 +141,13 @@ namespace osu.Game.Tests.Visual.SongSelect } } - public override async Task GetDifficultyAsync(IBeatmapInfo beatmapInfo, IRulesetInfo rulesetInfo = null, IEnumerable mods = null, CancellationToken cancellationToken = default) + public override async Task GetDifficultyAsync(IBeatmapInfo beatmapInfo, IRulesetInfo? rulesetInfo = null, IEnumerable? mods = null, CancellationToken cancellationToken = default) { if (blockCalculation) + { + Debug.Assert(calculationBlocker != null); await calculationBlocker.Task.ConfigureAwait(false); + } return await base.GetDifficultyAsync(beatmapInfo, rulesetInfo, mods, cancellationToken).ConfigureAwait(false); } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneTopLocalRank.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneTopLocalRank.cs index cf0de14541..79baae53e8 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneTopLocalRank.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneTopLocalRank.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Diagnostics; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -12,7 +11,6 @@ using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Rulesets; -using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Select.Carousel; using osu.Game.Tests.Resources; @@ -143,25 +141,20 @@ namespace osu.Game.Tests.Visual.SongSelect testScoreInfo.User = API.LocalUser.Value; testScoreInfo.Rank = ScoreRank.B; - testScoreInfo.TotalScore = scoreManager.GetTotalScore(testScoreInfo, ScoringMode.Classic); scoreManager.Import(testScoreInfo); }); AddUntilStep("B rank displayed", () => topLocalRank.DisplayedRank == ScoreRank.B); - AddStep("Add higher score for current user", () => + AddStep("Add higher-graded score for current user", () => { var testScoreInfo2 = TestResources.CreateTestScoreInfo(importedBeatmap); testScoreInfo2.User = API.LocalUser.Value; testScoreInfo2.Rank = ScoreRank.X; testScoreInfo2.Statistics = testScoreInfo2.MaximumStatistics; - testScoreInfo2.TotalScore = scoreManager.GetTotalScore(testScoreInfo2); - - // ensure second score has a total score (standardised) less than first one (classic) - // despite having better statistics, otherwise this test is pointless. - Debug.Assert(testScoreInfo2.TotalScore < testScoreInfo.TotalScore); + testScoreInfo2.TotalScore = testScoreInfo.TotalScore + 1; scoreManager.Import(testScoreInfo2); }); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModColumn.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModColumn.cs index a11000214c..18739c0275 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModColumn.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModColumn.cs @@ -106,26 +106,26 @@ namespace osu.Game.Tests.Visual.UserInterface }); AddStep("set filter", () => setFilter(mod => mod.Name.Contains("Wind", StringComparison.CurrentCultureIgnoreCase))); - AddUntilStep("two panels visible", () => column.ChildrenOfType().Count(panel => !panel.Filtered.Value) == 2); + AddUntilStep("two panels visible", () => column.ChildrenOfType().Count(panel => panel.Visible) == 2); clickToggle(); AddUntilStep("wait for animation", () => !column.SelectionAnimationRunning); - AddAssert("only visible items selected", () => column.ChildrenOfType().Where(panel => panel.Active.Value).All(panel => !panel.Filtered.Value)); + AddAssert("only visible items selected", () => column.ChildrenOfType().Where(panel => panel.Active.Value).All(panel => panel.Visible)); AddStep("unset filter", () => setFilter(null)); - AddUntilStep("all panels visible", () => column.ChildrenOfType().All(panel => !panel.Filtered.Value)); + AddUntilStep("all panels visible", () => column.ChildrenOfType().All(panel => panel.Visible)); AddAssert("checkbox not selected", () => !column.ChildrenOfType().Single().Current.Value); AddStep("set filter", () => setFilter(mod => mod.Name.Contains("Wind", StringComparison.CurrentCultureIgnoreCase))); - AddUntilStep("two panels visible", () => column.ChildrenOfType().Count(panel => !panel.Filtered.Value) == 2); + AddUntilStep("two panels visible", () => column.ChildrenOfType().Count(panel => panel.Visible) == 2); AddAssert("checkbox selected", () => column.ChildrenOfType().Single().Current.Value); AddStep("filter out everything", () => setFilter(_ => false)); - AddUntilStep("no panels visible", () => column.ChildrenOfType().All(panel => panel.Filtered.Value)); + AddUntilStep("no panels visible", () => column.ChildrenOfType().All(panel => !panel.Visible)); AddUntilStep("checkbox hidden", () => !column.ChildrenOfType().Single().IsPresent); AddStep("inset filter", () => setFilter(null)); - AddUntilStep("all panels visible", () => column.ChildrenOfType().All(panel => !panel.Filtered.Value)); + AddUntilStep("all panels visible", () => column.ChildrenOfType().All(panel => panel.Visible)); AddUntilStep("checkbox visible", () => column.ChildrenOfType().Single().IsPresent); void clickToggle() => AddStep("click toggle", () => @@ -288,10 +288,53 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("no change", () => this.ChildrenOfType().Count(panel => panel.Active.Value) == 2); } + [Test] + public void TestApplySearchTerms() + { + Mod hidden = getExampleModsFor(ModType.DifficultyIncrease).Where(modState => modState.Mod is ModHidden).Select(modState => modState.Mod).Single(); + + ModColumn column = null!; + AddStep("create content", () => Child = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(30), + Child = column = new ModColumn(ModType.DifficultyIncrease, false) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + AvailableMods = getExampleModsFor(ModType.DifficultyIncrease) + } + }); + + applySearchAndAssert(hidden.Name); + + clearSearch(); + + applySearchAndAssert(hidden.Acronym); + + clearSearch(); + + applySearchAndAssert(hidden.Description.ToString()); + + void applySearchAndAssert(string searchTerm) + { + AddStep("search by mod name", () => column.SearchTerm = searchTerm); + + AddAssert("only hidden is visible", () => column.ChildrenOfType().Where(panel => panel.Visible).All(panel => panel.Mod is ModHidden)); + } + + void clearSearch() + { + AddStep("clear search", () => column.SearchTerm = string.Empty); + + AddAssert("all mods are visible", () => column.ChildrenOfType().All(panel => panel.Visible)); + } + } + private void setFilter(Func? filter) { foreach (var modState in this.ChildrenOfType().Single().AvailableMods) - modState.Filtered.Value = filter?.Invoke(modState.Mod) == false; + modState.ValidForSelection.Value = filter?.Invoke(modState.Mod) != false; } private partial class TestModColumn : ModColumn diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs index 3efdba8754..2d54a4e566 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs @@ -392,6 +392,28 @@ namespace osu.Game.Tests.Visual.UserInterface new HashSet(this.ChildrenOfType().First().Preset.Value.Mods).SetEquals(mods)); } + [Test] + public void TestTextFiltering() + { + ModPresetColumn modPresetColumn = null!; + + AddStep("clear mods", () => SelectedMods.Value = Array.Empty()); + AddStep("create content", () => Child = modPresetColumn = new ModPresetColumn + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + + AddUntilStep("items loaded", () => modPresetColumn.IsLoaded && modPresetColumn.ItemsLoaded); + + AddStep("set osu! ruleset", () => Ruleset.Value = rulesets.GetRuleset(0)); + AddStep("set text filter", () => modPresetColumn.SearchTerm = "First"); + AddUntilStep("one panel visible", () => modPresetColumn.ChildrenOfType().Count(panel => panel.IsPresent), () => Is.EqualTo(1)); + + AddStep("set mania ruleset", () => Ruleset.Value = rulesets.GetRuleset(3)); + AddUntilStep("no panels visible", () => modPresetColumn.ChildrenOfType().Count(panel => panel.IsPresent), () => Is.EqualTo(0)); + } + private ICollection createTestPresets() => new[] { new ModPreset diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index 5cf24c1960..d566a04261 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs @@ -490,15 +490,15 @@ namespace osu.Game.Tests.Visual.UserInterface createScreen(); changeRuleset(0); - AddAssert("double time visible", () => modSelectOverlay.ChildrenOfType().Where(panel => panel.Mod is OsuModDoubleTime).Any(panel => !panel.Filtered.Value)); + AddAssert("double time visible", () => modSelectOverlay.ChildrenOfType().Where(panel => panel.Mod is OsuModDoubleTime).Any(panel => panel.Visible)); AddStep("make double time invalid", () => modSelectOverlay.IsValidMod = m => !(m is OsuModDoubleTime)); - AddUntilStep("double time not visible", () => modSelectOverlay.ChildrenOfType().Where(panel => panel.Mod is OsuModDoubleTime).All(panel => panel.Filtered.Value)); - AddAssert("nightcore still visible", () => modSelectOverlay.ChildrenOfType().Where(panel => panel.Mod is OsuModNightcore).Any(panel => !panel.Filtered.Value)); + AddUntilStep("double time not visible", () => modSelectOverlay.ChildrenOfType().Where(panel => panel.Mod is OsuModDoubleTime).All(panel => !panel.Visible)); + AddAssert("nightcore still visible", () => modSelectOverlay.ChildrenOfType().Where(panel => panel.Mod is OsuModNightcore).Any(panel => panel.Visible)); AddStep("make double time valid again", () => modSelectOverlay.IsValidMod = _ => true); - AddUntilStep("double time visible", () => modSelectOverlay.ChildrenOfType().Where(panel => panel.Mod is OsuModDoubleTime).Any(panel => !panel.Filtered.Value)); - AddAssert("nightcore still visible", () => modSelectOverlay.ChildrenOfType().Where(b => b.Mod is OsuModNightcore).Any(panel => !panel.Filtered.Value)); + AddUntilStep("double time visible", () => modSelectOverlay.ChildrenOfType().Where(panel => panel.Mod is OsuModDoubleTime).Any(panel => panel.Visible)); + AddAssert("nightcore still visible", () => modSelectOverlay.ChildrenOfType().Where(b => b.Mod is OsuModNightcore).Any(panel => panel.Visible)); } [Test] @@ -524,7 +524,57 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("set ruleset", () => Ruleset.Value = testRuleset.RulesetInfo); waitForColumnLoad(); - AddAssert("unimplemented mod panel is filtered", () => getPanelForMod(typeof(TestUnimplementedMod)).Filtered.Value); + AddAssert("unimplemented mod panel is filtered", () => !getPanelForMod(typeof(TestUnimplementedMod)).Visible); + } + + [Test] + public void TestFirstModSelectDeselect() + { + createScreen(); + + AddStep("apply search", () => modSelectOverlay.SearchTerm = "HD"); + + AddStep("press enter", () => InputManager.Key(Key.Enter)); + AddAssert("hidden selected", () => getPanelForMod(typeof(OsuModHidden)).Active.Value); + + AddStep("press enter again", () => InputManager.Key(Key.Enter)); + AddAssert("hidden deselected", () => !getPanelForMod(typeof(OsuModHidden)).Active.Value); + + AddStep("clear search", () => modSelectOverlay.SearchTerm = string.Empty); + AddStep("press enter", () => InputManager.Key(Key.Enter)); + AddAssert("mod select hidden", () => modSelectOverlay.State.Value == Visibility.Hidden); + } + + [Test] + public void TestSearchFocusChangeViaClick() + { + createScreen(); + + AddStep("click on search", navigateAndClick); + AddAssert("focused", () => modSelectOverlay.SearchTextBox.HasFocus); + + AddStep("click on mod column", navigateAndClick); + AddAssert("lost focus", () => !modSelectOverlay.SearchTextBox.HasFocus); + + void navigateAndClick() where T : Drawable + { + InputManager.MoveMouseTo(modSelectOverlay.ChildrenOfType().FirstOrDefault()); + InputManager.Click(MouseButton.Left); + } + } + + [Test] + public void TestSearchFocusChangeViaKey() + { + createScreen(); + + const Key focus_switch_key = Key.Tab; + + AddStep("press tab", () => InputManager.Key(focus_switch_key)); + AddAssert("focused", () => modSelectOverlay.SearchTextBox.HasFocus); + + AddStep("press tab", () => InputManager.Key(focus_switch_key)); + AddAssert("lost focus", () => !modSelectOverlay.SearchTextBox.HasFocus); } [Test] @@ -533,6 +583,8 @@ namespace osu.Game.Tests.Visual.UserInterface createScreen(); changeRuleset(0); + AddStep("kill search bar focus", () => modSelectOverlay.SearchTextBox.KillFocus()); + AddStep("select DT + HD", () => SelectedMods.Value = new Mod[] { new OsuModDoubleTime(), new OsuModHidden() }); AddAssert("DT + HD selected", () => modSelectOverlay.ChildrenOfType().Count(panel => panel.Active.Value) == 2); @@ -540,6 +592,26 @@ namespace osu.Game.Tests.Visual.UserInterface AddUntilStep("all mods deselected", () => !SelectedMods.Value.Any()); } + [Test] + public void TestDeselectAllViaKey_WithSearchApplied() + { + createScreen(); + changeRuleset(0); + + AddStep("select DT + HD", () => SelectedMods.Value = new Mod[] { new OsuModDoubleTime(), new OsuModHidden() }); + AddStep("focus on search", () => modSelectOverlay.SearchTextBox.TakeFocus()); + AddStep("apply search", () => modSelectOverlay.SearchTerm = "Easy"); + AddAssert("DT + HD selected and hidden", () => modSelectOverlay.ChildrenOfType().Count(panel => !panel.Visible && panel.Active.Value) == 2); + + AddStep("press backspace", () => InputManager.Key(Key.BackSpace)); + AddAssert("DT + HD still selected", () => modSelectOverlay.ChildrenOfType().Count(panel => panel.Active.Value) == 2); + AddAssert("search term changed", () => modSelectOverlay.SearchTerm == "Eas"); + + AddStep("kill focus", () => modSelectOverlay.SearchTextBox.KillFocus()); + AddStep("press backspace", () => InputManager.Key(Key.BackSpace)); + AddUntilStep("all mods deselected", () => !SelectedMods.Value.Any()); + } + [Test] public void TestDeselectAllViaButton() { @@ -561,6 +633,31 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("deselect all button disabled", () => !this.ChildrenOfType().Single().Enabled.Value); } + [Test] + public void TestDeselectAllViaButton_WithSearchApplied() + { + createScreen(); + changeRuleset(0); + + AddAssert("deselect all button disabled", () => !this.ChildrenOfType().Single().Enabled.Value); + + AddStep("select DT + HD + RD", () => SelectedMods.Value = new Mod[] { new OsuModDoubleTime(), new OsuModHidden(), new OsuModRandom() }); + AddAssert("DT + HD + RD selected", () => modSelectOverlay.ChildrenOfType().Count(panel => panel.Active.Value) == 3); + AddAssert("deselect all button enabled", () => this.ChildrenOfType().Single().Enabled.Value); + + AddStep("apply search", () => modSelectOverlay.SearchTerm = "Easy"); + AddAssert("DT + HD + RD are hidden and selected", () => modSelectOverlay.ChildrenOfType().Count(panel => !panel.Visible && panel.Active.Value) == 3); + AddAssert("deselect all button enabled", () => this.ChildrenOfType().Single().Enabled.Value); + + AddStep("click deselect all button", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("all mods deselected", () => !SelectedMods.Value.Any()); + AddAssert("deselect all button disabled", () => !this.ChildrenOfType().Single().Enabled.Value); + } + [Test] public void TestCloseViaBackButton() { @@ -580,8 +677,11 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("mod select hidden", () => modSelectOverlay.State.Value == Visibility.Hidden); } + /// + /// Covers columns hiding/unhiding on changes of . + /// [Test] - public void TestColumnHiding() + public void TestColumnHidingOnIsValidChange() { AddStep("create screen", () => Child = modSelectOverlay = new TestModSelectOverlay { @@ -610,6 +710,56 @@ namespace osu.Game.Tests.Visual.UserInterface AddUntilStep("3 columns visible", () => this.ChildrenOfType().Count(col => col.IsPresent) == 3); } + /// + /// Covers columns hiding/unhiding on changes of . + /// + [Test] + public void TestColumnHidingOnTextFilterChange() + { + AddStep("create screen", () => Child = modSelectOverlay = new TestModSelectOverlay + { + RelativeSizeAxes = Axes.Both, + State = { Value = Visibility.Visible }, + SelectedMods = { BindTarget = SelectedMods } + }); + waitForColumnLoad(); + changeRuleset(0); + + AddAssert("all columns visible", () => this.ChildrenOfType().All(col => col.IsPresent)); + + AddStep("set search", () => modSelectOverlay.SearchTerm = "HD"); + AddAssert("one column visible", () => this.ChildrenOfType().Count(col => col.IsPresent) == 1); + + AddStep("filter out everything", () => modSelectOverlay.SearchTerm = "Some long search term with no matches"); + AddAssert("no columns visible", () => this.ChildrenOfType().All(col => !col.IsPresent)); + + AddStep("clear search bar", () => modSelectOverlay.SearchTerm = ""); + AddAssert("all columns visible", () => this.ChildrenOfType().All(col => col.IsPresent)); + } + + [Test] + public void TestHidingOverlayClearsTextSearch() + { + AddStep("create screen", () => Child = modSelectOverlay = new TestModSelectOverlay + { + RelativeSizeAxes = Axes.Both, + State = { Value = Visibility.Visible }, + SelectedMods = { BindTarget = SelectedMods } + }); + waitForColumnLoad(); + changeRuleset(0); + + AddAssert("all columns visible", () => this.ChildrenOfType().All(col => col.IsPresent)); + + AddStep("set search", () => modSelectOverlay.SearchTerm = "fail"); + AddAssert("one column visible", () => this.ChildrenOfType().Count(col => col.IsPresent) == 2); + + AddStep("hide", () => modSelectOverlay.Hide()); + AddStep("show", () => modSelectOverlay.Show()); + + AddAssert("all columns visible", () => this.ChildrenOfType().All(col => col.IsPresent)); + } + [Test] public void TestColumnHidingOnRulesetChange() { @@ -688,12 +838,10 @@ namespace osu.Game.Tests.Visual.UserInterface { public override string ShortName => "unimplemented"; - public override IEnumerable GetModsFor(ModType type) - { - if (type == ModType.Conversion) return base.GetModsFor(type).Concat(new[] { new TestUnimplementedMod() }); - - return base.GetModsFor(type); - } + public override IEnumerable GetModsFor(ModType type) => + type == ModType.Conversion + ? base.GetModsFor(type).Concat(new[] { new TestUnimplementedMod() }) + : base.GetModsFor(type); } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayContainer.cs index d9c2774611..bb94912c83 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOverlayContainer.cs @@ -101,6 +101,10 @@ namespace osu.Game.Tests.Visual.UserInterface }, }; } + + protected override void PopIn() + { + } } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs index 05fffc903d..3a1eb554ab 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSectionsContainer.cs @@ -80,6 +80,24 @@ namespace osu.Game.Tests.Visual.UserInterface }); } + [Test] + public void TestCorrectScrollToWhenContentLoads() + { + AddRepeatStep("add many sections", () => append(1f), 3); + + AddStep("add section with delayed load content", () => + { + container.Add(new TestDelayedLoadSection("delayed")); + }); + + AddStep("add final section", () => append(0.5f)); + + AddStep("scroll to final section", () => container.ScrollTo(container.Children.Last())); + + AddUntilStep("correct section selected", () => container.SelectedSection.Value == container.Children.Last()); + AddUntilStep("wait for scroll to section", () => container.ScreenSpaceDrawQuad.AABBFloat.Contains(container.Children.Last().ScreenSpaceDrawQuad.AABBFloat)); + } + [Test] public void TestSelection() { @@ -196,6 +214,33 @@ namespace osu.Game.Tests.Visual.UserInterface InputManager.ScrollVerticalBy(direction); } + private partial class TestDelayedLoadSection : TestSection + { + public TestDelayedLoadSection(string label) + : base(label) + { + BackgroundColour = default_colour; + Width = 300; + AutoSizeAxes = Axes.Y; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Box box; + + Add(box = new Box + { + Alpha = 0.01f, + RelativeSizeAxes = Axes.X, + }); + + // Emulate an operation that will be inhibited by IsMaskedAway. + box.ResizeHeightTo(2000, 50); + } + } + private partial class TestSection : TestBox { public bool Selected diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSegmentedGraph.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSegmentedGraph.cs index 1144b9053d..320ec48e07 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneSegmentedGraph.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSegmentedGraph.cs @@ -7,6 +7,7 @@ using System.Diagnostics; using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; +using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu; @@ -137,8 +138,7 @@ namespace osu.Game.Tests.Visual.UserInterface if (!objects.Any()) return; - double firstHit = objects.First().StartTime; - double lastHit = objects.Max(o => o.GetEndTime()); + (double firstHit, double lastHit) = BeatmapExtensions.CalculatePlayableBounds(objects); if (lastHit == 0) lastHit = objects.Last().StartTime; diff --git a/osu.Game.Tests/WaveformTestBeatmap.cs b/osu.Game.Tests/WaveformTestBeatmap.cs index 5e41392560..12660ed2e1 100644 --- a/osu.Game.Tests/WaveformTestBeatmap.cs +++ b/osu.Game.Tests/WaveformTestBeatmap.cs @@ -51,7 +51,7 @@ namespace osu.Game.Tests protected override IBeatmap GetBeatmap() => beatmap; - protected override Texture GetBackground() => null; + public override Texture GetBackground() => null; protected override Waveform GetWaveform() => new Waveform(trackStore.GetStream(firstAudioFile)); diff --git a/osu.Game.Tournament.Tests/Screens/TestSceneMapPoolScreen.cs b/osu.Game.Tournament.Tests/Screens/TestSceneMapPoolScreen.cs index 5695cb5574..94086f10f2 100644 --- a/osu.Game.Tournament.Tests/Screens/TestSceneMapPoolScreen.cs +++ b/osu.Game.Tournament.Tests/Screens/TestSceneMapPoolScreen.cs @@ -24,6 +24,9 @@ namespace osu.Game.Tournament.Tests.Screens Add(screen = new MapPoolScreen { Width = 0.7f }); } + [SetUp] + public void SetUp() => Schedule(() => Ladder.SplitMapPoolByMods.Value = true); + [Test] public void TestFewMaps() { @@ -92,7 +95,7 @@ namespace osu.Game.Tournament.Tests.Screens Ladder.CurrentMatch.Value.Round.Value.Beatmaps.Clear(); for (int i = 0; i < 11; i++) - addBeatmap(i > 4 ? $"M{i}" : "NM"); + addBeatmap(i > 4 ? Ruleset.Value.CreateInstance().AllMods.ElementAt(i).Acronym : "NM"); }); AddStep("reset match", () => @@ -118,7 +121,7 @@ namespace osu.Game.Tournament.Tests.Screens Ladder.CurrentMatch.Value.Round.Value.Beatmaps.Clear(); for (int i = 0; i < 12; i++) - addBeatmap(i > 4 ? $"M{i}" : "NM"); + addBeatmap(i > 4 ? Ruleset.Value.CreateInstance().AllMods.ElementAt(i).Acronym : "NM"); }); AddStep("reset match", () => @@ -130,7 +133,27 @@ namespace osu.Game.Tournament.Tests.Screens assertThreeWide(); } - private void addBeatmap(string mods = "nm") + [Test] + public void TestSplitMapPoolByMods() + { + AddStep("load many maps", () => + { + Ladder.CurrentMatch.Value.Round.Value.Beatmaps.Clear(); + + for (int i = 0; i < 12; i++) + addBeatmap(i > 4 ? Ruleset.Value.CreateInstance().AllMods.ElementAt(i).Acronym : "NM"); + }); + + AddStep("disable splitting map pool by mods", () => Ladder.SplitMapPoolByMods.Value = false); + + AddStep("reset match", () => + { + Ladder.CurrentMatch.Value = new TournamentMatch(); + Ladder.CurrentMatch.Value = Ladder.Matches.First(); + }); + } + + private void addBeatmap(string mods = "NM") { Ladder.CurrentMatch.Value.Round.Value.Beatmaps.Add(new RoundBeatmap { diff --git a/osu.Game.Tournament/IPC/TourneyState.cs b/osu.Game.Tournament/IPC/TourneyState.cs index 2c7253dc10..ef1c612a53 100644 --- a/osu.Game.Tournament/IPC/TourneyState.cs +++ b/osu.Game.Tournament/IPC/TourneyState.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Tournament.IPC { public enum TourneyState diff --git a/osu.Game.Tournament/Models/LadderInfo.cs b/osu.Game.Tournament/Models/LadderInfo.cs index 6b64a1156e..cb4e8bc16a 100644 --- a/osu.Game.Tournament/Models/LadderInfo.cs +++ b/osu.Game.Tournament/Models/LadderInfo.cs @@ -42,5 +42,7 @@ namespace osu.Game.Tournament.Models }; public Bindable AutoProgressScreens = new BindableBool(true); + + public Bindable SplitMapPoolByMods = new BindableBool(true); } } diff --git a/osu.Game.Tournament/Screens/Ladder/Components/ConditionalTournamentMatch.cs b/osu.Game.Tournament/Screens/Ladder/Components/ConditionalTournamentMatch.cs index 04155fcb89..16224a7fb4 100644 --- a/osu.Game.Tournament/Screens/Ladder/Components/ConditionalTournamentMatch.cs +++ b/osu.Game.Tournament/Screens/Ladder/Components/ConditionalTournamentMatch.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Game.Tournament.Models; namespace osu.Game.Tournament.Screens.Ladder.Components diff --git a/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs b/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs index f0e34d78c3..cb6c5902ec 100644 --- a/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs +++ b/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs @@ -24,7 +24,7 @@ namespace osu.Game.Tournament.Screens.MapPool { public partial class MapPoolScreen : TournamentMatchScreen { - private readonly FillFlowContainer> mapFlows; + private FillFlowContainer> mapFlows; [Resolved(canBeNull: true)] private TournamentSceneManager sceneManager { get; set; } @@ -32,12 +32,13 @@ namespace osu.Game.Tournament.Screens.MapPool private TeamColour pickColour; private ChoiceType pickType; - private readonly OsuButton buttonRedBan; - private readonly OsuButton buttonBlueBan; - private readonly OsuButton buttonRedPick; - private readonly OsuButton buttonBluePick; + private OsuButton buttonRedBan; + private OsuButton buttonBlueBan; + private OsuButton buttonRedPick; + private OsuButton buttonBluePick; - public MapPoolScreen() + [BackgroundDependencyLoader] + private void load(MatchIPCInfo ipc) { InternalChildren = new Drawable[] { @@ -98,15 +99,26 @@ namespace osu.Game.Tournament.Screens.MapPool Action = reset }, new ControlPanel.Spacer(), + new OsuCheckbox + { + LabelText = "Split display by mods", + Current = LadderInfo.SplitMapPoolByMods, + }, }, } }; + + ipc.Beatmap.BindValueChanged(beatmapChanged); } - [BackgroundDependencyLoader] - private void load(MatchIPCInfo ipc) + private Bindable splitMapPoolByMods; + + protected override void LoadComplete() { - ipc.Beatmap.BindValueChanged(beatmapChanged); + base.LoadComplete(); + + splitMapPoolByMods = LadderInfo.SplitMapPoolByMods.GetBoundCopy(); + splitMapPoolByMods.BindValueChanged(_ => updateDisplay()); } private void beatmapChanged(ValueChangedEvent beatmap) @@ -213,24 +225,27 @@ namespace osu.Game.Tournament.Screens.MapPool protected override void CurrentMatchChanged(ValueChangedEvent match) { base.CurrentMatchChanged(match); + updateDisplay(); + } + private void updateDisplay() + { mapFlows.Clear(); - if (match.NewValue == null) + if (CurrentMatch.Value == null) return; int totalRows = 0; - if (match.NewValue.Round.Value != null) + if (CurrentMatch.Value.Round.Value != null) { FillFlowContainer currentFlow = null; - string currentMod = null; - + string currentMods = null; int flowCount = 0; - foreach (var b in match.NewValue.Round.Value.Beatmaps) + foreach (var b in CurrentMatch.Value.Round.Value.Beatmaps) { - if (currentFlow == null || currentMod != b.Mods) + if (currentFlow == null || (LadderInfo.SplitMapPoolByMods.Value && currentMods != b.Mods)) { mapFlows.Add(currentFlow = new FillFlowContainer { @@ -240,7 +255,7 @@ namespace osu.Game.Tournament.Screens.MapPool AutoSizeAxes = Axes.Y }); - currentMod = b.Mods; + currentMods = b.Mods; totalRows++; flowCount = 0; diff --git a/osu.Game/Audio/HitSampleInfo.cs b/osu.Game/Audio/HitSampleInfo.cs index efa5562cb8..9573a9a4aa 100644 --- a/osu.Game/Audio/HitSampleInfo.cs +++ b/osu.Game/Audio/HitSampleInfo.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using Newtonsoft.Json; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Utils; namespace osu.Game.Audio @@ -19,11 +20,20 @@ namespace osu.Game.Audio public const string HIT_FINISH = @"hitfinish"; public const string HIT_CLAP = @"hitclap"; + public const string BANK_NORMAL = @"normal"; + public const string BANK_SOFT = @"soft"; + public const string BANK_DRUM = @"drum"; + /// /// All valid sample addition constants. /// public static IEnumerable AllAdditions => new[] { HIT_WHISTLE, HIT_FINISH, HIT_CLAP }; + /// + /// All valid bank constants. + /// + public static IEnumerable AllBanks => new[] { BANK_NORMAL, BANK_SOFT, BANK_DRUM }; + /// /// The name of the sample to load. /// @@ -32,7 +42,7 @@ namespace osu.Game.Audio /// /// The bank to load the sample from. /// - public readonly string? Bank; + public readonly string Bank; /// /// An optional suffix to provide priority lookup. Falls back to non-suffixed . @@ -44,7 +54,7 @@ namespace osu.Game.Audio /// public int Volume { get; } - public HitSampleInfo(string name, string? bank = null, string? suffix = null, int volume = 0) + public HitSampleInfo(string name, string bank = SampleControlPoint.DEFAULT_BANK, string? suffix = null, int volume = 100) { Name = name; Bank = bank; @@ -75,7 +85,7 @@ namespace osu.Game.Audio /// An optional new lookup suffix. /// An optional new volume. /// The new . - public virtual HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default) + public virtual HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default) => new HitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newSuffix.GetOr(Suffix), newVolume.GetOr(Volume)); public bool Equals(HitSampleInfo? other) diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index 393feff087..5019d64276 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -171,6 +171,11 @@ namespace osu.Game.Beatmaps public double TimelineZoom { get; set; } = 1.0; + /// + /// The time in milliseconds when last exiting the editor with this beatmap loaded. + /// + public double? EditorTimestamp { get; set; } + [Ignored] public CountdownType Countdown { get; set; } = CountdownType.Normal; diff --git a/osu.Game/Beatmaps/BeatmapPanelBackgroundTextureLoaderStore.cs b/osu.Game/Beatmaps/BeatmapPanelBackgroundTextureLoaderStore.cs new file mode 100644 index 0000000000..acd60b664d --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapPanelBackgroundTextureLoaderStore.cs @@ -0,0 +1,95 @@ +// 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.IO; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Processing; + +namespace osu.Game.Beatmaps +{ + // Implementation of this class is based off of `MaxDimensionLimitedTextureLoaderStore`. + // If issues are found it's worth checking to make sure similar issues exist there. + public class BeatmapPanelBackgroundTextureLoaderStore : IResourceStore + { + // The aspect ratio of SetPanelBackground at its maximum size (very tall window). + private const float minimum_display_ratio = 512 / 80f; + + private readonly IResourceStore? textureStore; + + public BeatmapPanelBackgroundTextureLoaderStore(IResourceStore? textureStore) + { + this.textureStore = textureStore; + } + + public void Dispose() + { + textureStore?.Dispose(); + } + + public TextureUpload Get(string name) + { + var textureUpload = textureStore?.Get(name); + + // NRT not enabled on framework side classes (IResourceStore / TextureLoaderStore), welp. + if (textureUpload == null) + return null!; + + return limitTextureUploadSize(textureUpload); + } + + public async Task GetAsync(string name, CancellationToken cancellationToken = new CancellationToken()) + { + // NRT not enabled on framework side classes (IResourceStore / TextureLoaderStore), welp. + if (textureStore == null) + return null!; + + var textureUpload = await textureStore.GetAsync(name, cancellationToken).ConfigureAwait(false); + + if (textureUpload == null) + return null!; + + return await Task.Run(() => limitTextureUploadSize(textureUpload), cancellationToken).ConfigureAwait(false); + } + + private TextureUpload limitTextureUploadSize(TextureUpload textureUpload) + { + var image = Image.LoadPixelData(textureUpload.Data.ToArray(), textureUpload.Width, textureUpload.Height); + + // The original texture upload will no longer be returned or used. + textureUpload.Dispose(); + + Size size = image.Size(); + + // Assume that panel backgrounds are always displayed using `FillMode.Fill`. + // Also assume that all backgrounds are wider than they are tall, so the + // fill is always going to be based on width. + // + // We need to include enough height to make this work for all ratio panels are displayed at. + int usableHeight = (int)Math.Ceiling(size.Width * 1 / minimum_display_ratio); + + usableHeight = Math.Min(size.Height, usableHeight); + + // Crop the centre region of the background for now. + Rectangle cropRectangle = new Rectangle( + 0, + (size.Height - usableHeight) / 2, + size.Width, + usableHeight + ); + + image.Mutate(i => i.Crop(cropRectangle)); + + return new TextureUpload(image); + } + + public Stream? GetStream(string name) => textureStore?.GetStream(name); + + public IEnumerable GetAvailableResources() => textureStore?.GetAvailableResources() ?? Array.Empty(); + } +} diff --git a/osu.Game/Beatmaps/BeatmapProcessor.cs b/osu.Game/Beatmaps/BeatmapProcessor.cs index 8f3d0b7445..fb5313469f 100644 --- a/osu.Game/Beatmaps/BeatmapProcessor.cs +++ b/osu.Game/Beatmaps/BeatmapProcessor.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Linq; using osu.Game.Rulesets.Objects.Types; @@ -22,34 +20,17 @@ namespace osu.Game.Beatmaps public virtual void PreProcess() { - IHasComboInformation lastObj = null; - - bool isFirst = true; + IHasComboInformation? lastObj = null; foreach (var obj in Beatmap.HitObjects.OfType()) { - if (isFirst) + if (lastObj == null) { - obj.NewCombo = true; - // first hitobject should always be marked as a new combo for sanity. - isFirst = false; - } - - obj.ComboIndex = lastObj?.ComboIndex ?? 0; - obj.ComboIndexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0; - obj.IndexInCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0; - - if (obj.NewCombo) - { - obj.IndexInCurrentCombo = 0; - obj.ComboIndex++; - obj.ComboIndexWithOffsets += obj.ComboOffset + 1; - - if (lastObj != null) - lastObj.LastInCombo = true; + obj.NewCombo = true; } + obj.UpdateComboInformation(lastObj); lastObj = obj; } } diff --git a/osu.Game/Beatmaps/BeatmapUpdater.cs b/osu.Game/Beatmaps/BeatmapUpdater.cs index af9f32f834..046adb8327 100644 --- a/osu.Game/Beatmaps/BeatmapUpdater.cs +++ b/osu.Game/Beatmaps/BeatmapUpdater.cs @@ -3,7 +3,6 @@ using System; using System.Diagnostics; -using System.Linq; using System.Threading.Tasks; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Logging; @@ -11,7 +10,6 @@ using osu.Framework.Platform; using osu.Framework.Threading; using osu.Game.Database; using osu.Game.Online.API; -using osu.Game.Rulesets.Objects; namespace osu.Game.Beatmaps { @@ -74,7 +72,7 @@ namespace osu.Game.Beatmaps var calculator = ruleset.CreateDifficultyCalculator(working); beatmap.StarRating = calculator.Calculate().StarRating; - beatmap.Length = calculateLength(working.Beatmap); + beatmap.Length = working.Beatmap.CalculatePlayableLength(); beatmap.BPM = 60000 / working.Beatmap.GetMostCommonBeatLength(); } @@ -82,20 +80,6 @@ namespace osu.Game.Beatmaps workingBeatmapCache.Invalidate(beatmapSet); }); - private double calculateLength(IBeatmap b) - { - if (!b.HitObjects.Any()) - return 0; - - var lastObject = b.HitObjects.Last(); - - //TODO: this isn't always correct (consider mania where a non-last object may last for longer than the last in the list). - double endTime = lastObject.GetEndTime(); - double startTime = b.HitObjects.First().StartTime; - - return endTime - startTime; - } - #region Implementation of IDisposable public void Dispose() diff --git a/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs index 6cd4d74a31..ae4bdafd6f 100644 --- a/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs @@ -14,7 +14,7 @@ namespace osu.Game.Beatmaps.ControlPoints /// public class SampleControlPoint : ControlPoint, IEquatable { - public const string DEFAULT_BANK = "normal"; + public const string DEFAULT_BANK = HitSampleInfo.BANK_NORMAL; public static readonly SampleControlPoint DEFAULT = new SampleControlPoint { @@ -69,7 +69,7 @@ namespace osu.Game.Beatmaps.ControlPoints /// The . This will not be modified. /// The modified . This does not share a reference with . public virtual HitSampleInfo ApplyTo(HitSampleInfo hitSampleInfo) - => hitSampleInfo.With(newBank: hitSampleInfo.Bank ?? SampleBank, newVolume: hitSampleInfo.Volume > 0 ? hitSampleInfo.Volume : SampleVolume); + => hitSampleInfo.With(newBank: hitSampleInfo.Bank, newVolume: hitSampleInfo.Volume > 0 ? hitSampleInfo.Volume : SampleVolume); public override bool IsRedundant(ControlPoint? existing) => existing is SampleControlPoint existingSample diff --git a/osu.Game/Beatmaps/DifficultyRating.cs b/osu.Game/Beatmaps/DifficultyRating.cs index 478c0e36df..f0ee0ad705 100644 --- a/osu.Game/Beatmaps/DifficultyRating.cs +++ b/osu.Game/Beatmaps/DifficultyRating.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Beatmaps { public enum DifficultyRating diff --git a/osu.Game/Beatmaps/Drawables/BeatmapBackgroundSprite.cs b/osu.Game/Beatmaps/Drawables/BeatmapBackgroundSprite.cs index 767504fcb1..5b9cf6846c 100644 --- a/osu.Game/Beatmaps/Drawables/BeatmapBackgroundSprite.cs +++ b/osu.Game/Beatmaps/Drawables/BeatmapBackgroundSprite.cs @@ -23,8 +23,9 @@ namespace osu.Game.Beatmaps.Drawables [BackgroundDependencyLoader] private void load() { - if (working.Background != null) - Texture = working.Background; + var background = working.GetBackground(); + if (background != null) + Texture = background; } } } diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs index 5c6f0c4ee1..175c15ea7b 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtra.cs @@ -106,12 +106,11 @@ namespace osu.Game.Beatmaps.Drawables.Cards { new Drawable[] { - new OsuSpriteText + new TruncatingSpriteText { Text = new RomanisableString(BeatmapSet.TitleUnicode, BeatmapSet.Title), Font = OsuFont.Default.With(size: 22.5f, weight: FontWeight.SemiBold), RelativeSizeAxes = Axes.X, - Truncate = true }, titleBadgeArea = new FillFlowContainer { @@ -140,21 +139,19 @@ namespace osu.Game.Beatmaps.Drawables.Cards { new[] { - new OsuSpriteText + new TruncatingSpriteText { Text = createArtistText(), Font = OsuFont.Default.With(size: 17.5f, weight: FontWeight.SemiBold), RelativeSizeAxes = Axes.X, - Truncate = true }, Empty() }, } }, - new OsuSpriteText + new TruncatingSpriteText { RelativeSizeAxes = Axes.X, - Truncate = true, Text = BeatmapSet.Source, Shadow = false, Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold), diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs index 720d892495..18e1584a98 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardNormal.cs @@ -107,12 +107,11 @@ namespace osu.Game.Beatmaps.Drawables.Cards { new Drawable[] { - new OsuSpriteText + new TruncatingSpriteText { Text = new RomanisableString(BeatmapSet.TitleUnicode, BeatmapSet.Title), Font = OsuFont.Default.With(size: 22.5f, weight: FontWeight.SemiBold), RelativeSizeAxes = Axes.X, - Truncate = true }, titleBadgeArea = new FillFlowContainer { @@ -141,12 +140,11 @@ namespace osu.Game.Beatmaps.Drawables.Cards { new[] { - new OsuSpriteText + new TruncatingSpriteText { Text = createArtistText(), Font = OsuFont.Default.With(size: 17.5f, weight: FontWeight.SemiBold), RelativeSizeAxes = Axes.X, - Truncate = true }, Empty() }, diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardSize.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardSize.cs index 1f6538a890..098265506d 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardSize.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardSize.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Beatmaps.Drawables.Cards { /// diff --git a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs index 0b390a2ab5..8089d789c1 100644 --- a/osu.Game/Beatmaps/DummyWorkingBeatmap.cs +++ b/osu.Game/Beatmaps/DummyWorkingBeatmap.cs @@ -52,7 +52,7 @@ namespace osu.Game.Beatmaps protected override IBeatmap GetBeatmap() => new Beatmap(); - protected override Texture GetBackground() => textures?.Get(@"Backgrounds/bg4"); + public override Texture GetBackground() => textures?.Get(@"Backgrounds/bg4"); protected override Track GetBeatmapTrack() => GetVirtualTrack(); diff --git a/osu.Game/Beatmaps/FlatFileWorkingBeatmap.cs b/osu.Game/Beatmaps/FlatFileWorkingBeatmap.cs index 02fcde5257..0b53278ab3 100644 --- a/osu.Game/Beatmaps/FlatFileWorkingBeatmap.cs +++ b/osu.Game/Beatmaps/FlatFileWorkingBeatmap.cs @@ -43,7 +43,7 @@ namespace osu.Game.Beatmaps } protected override IBeatmap GetBeatmap() => beatmap; - protected override Texture GetBackground() => throw new NotImplementedException(); + public override Texture GetBackground() => throw new NotImplementedException(); protected override Track GetBeatmapTrack() => throw new NotImplementedException(); protected internal override ISkin GetSkin() => throw new NotImplementedException(); public override Stream GetStream(string storagePath) => throw new NotImplementedException(); diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs index 49594ca969..65a01befb4 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs @@ -12,6 +12,7 @@ using System.Linq; using osu.Framework.Extensions; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Logging; +using osu.Game.Audio; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Legacy; using osu.Game.Beatmaps.Timing; @@ -480,7 +481,7 @@ namespace osu.Game.Beatmaps.Formats string stringSampleSet = sampleSet.ToString().ToLowerInvariant(); if (stringSampleSet == @"none") - stringSampleSet = @"normal"; + stringSampleSet = HitSampleInfo.BANK_NORMAL; if (timingChange) { diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs index 7fbcca9adb..a5fc815a5e 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs @@ -589,13 +589,13 @@ namespace osu.Game.Beatmaps.Formats { switch (sampleBank?.ToLowerInvariant()) { - case "normal": + case HitSampleInfo.BANK_NORMAL: return LegacySampleBank.Normal; - case "soft": + case HitSampleInfo.BANK_SOFT: return LegacySampleBank.Soft; - case "drum": + case HitSampleInfo.BANK_DRUM: return LegacySampleBank.Drum; default: diff --git a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs index 704756e3dd..23440b8a1d 100644 --- a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs @@ -226,12 +226,16 @@ namespace osu.Game.Beatmaps.Formats public override HitSampleInfo ApplyTo(HitSampleInfo hitSampleInfo) { - var baseInfo = base.ApplyTo(hitSampleInfo); + if (hitSampleInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacy) + { + return legacy.With( + newCustomSampleBank: legacy.CustomSampleBank > 0 ? legacy.CustomSampleBank : CustomSampleBank, + newVolume: hitSampleInfo.Volume > 0 ? hitSampleInfo.Volume : SampleVolume, + newBank: legacy.BankSpecified ? legacy.Bank : SampleBank + ); + } - if (baseInfo is ConvertHitObjectParser.LegacyHitSampleInfo legacy && legacy.CustomSampleBank == 0) - return legacy.With(newCustomSampleBank: CustomSampleBank); - - return baseInfo; + return base.ApplyTo(hitSampleInfo); } public override bool IsRedundant(ControlPoint? existing) diff --git a/osu.Game/Beatmaps/IBeatmap.cs b/osu.Game/Beatmaps/IBeatmap.cs index f6771f7adf..9dc3084cb5 100644 --- a/osu.Game/Beatmaps/IBeatmap.cs +++ b/osu.Game/Beatmaps/IBeatmap.cs @@ -104,6 +104,19 @@ namespace osu.Game.Beatmaps } } + /// + /// Find the total milliseconds between the first and last hittable objects. + /// + /// + /// This is cached to , so using that is preferable when available. + /// + public static double CalculatePlayableLength(this IBeatmap beatmap) => CalculatePlayableLength(beatmap.HitObjects); + + /// + /// Find the timestamps in milliseconds of the start and end of the playable region. + /// + public static (double start, double end) CalculatePlayableBounds(this IBeatmap beatmap) => CalculatePlayableBounds(beatmap.HitObjects); + /// /// Find the absolute end time of the latest in a beatmap. Will throw if beatmap contains no objects. /// @@ -114,5 +127,36 @@ namespace osu.Game.Beatmaps /// It's not super efficient so calls should be kept to a minimum. /// public static double GetLastObjectTime(this IBeatmap beatmap) => beatmap.HitObjects.Max(h => h.GetEndTime()); + + #region Helper methods + + /// + /// Find the total milliseconds between the first and last hittable objects. + /// + /// + /// This is cached to , so using that is preferable when available. + /// + public static double CalculatePlayableLength(IEnumerable objects) + { + (double start, double end) = CalculatePlayableBounds(objects); + + return end - start; + } + + /// + /// Find the timestamps in milliseconds of the start and end of the playable region. + /// + public static (double start, double end) CalculatePlayableBounds(IEnumerable objects) + { + if (!objects.Any()) + return (0, 0); + + double lastObjectTime = objects.Max(o => o.GetEndTime()); + double firstObjectTime = objects.First().StartTime; + + return (firstObjectTime, lastObjectTime); + } + + #endregion } } diff --git a/osu.Game/Beatmaps/IBeatmapInfo.cs b/osu.Game/Beatmaps/IBeatmapInfo.cs index 4f2c08f63d..b8c69cc525 100644 --- a/osu.Game/Beatmaps/IBeatmapInfo.cs +++ b/osu.Game/Beatmaps/IBeatmapInfo.cs @@ -33,7 +33,7 @@ namespace osu.Game.Beatmaps IBeatmapSetInfo? BeatmapSet { get; } /// - /// The playable length in milliseconds of this beatmap. + /// The total length in milliseconds of this beatmap. /// double Length { get; } diff --git a/osu.Game/Beatmaps/IBeatmapOnlineInfo.cs b/osu.Game/Beatmaps/IBeatmapOnlineInfo.cs index e1634e7d24..707a0696ba 100644 --- a/osu.Game/Beatmaps/IBeatmapOnlineInfo.cs +++ b/osu.Game/Beatmaps/IBeatmapOnlineInfo.cs @@ -59,5 +59,10 @@ namespace osu.Game.Beatmaps int PassCount { get; } APIFailTimes? FailTimes { get; } + + /// + /// The playable length in milliseconds of this beatmap. + /// + double HitLength { get; } } } diff --git a/osu.Game/Beatmaps/IBeatmapResourceProvider.cs b/osu.Game/Beatmaps/IBeatmapResourceProvider.cs index 22ff7ce8c8..9e79e03785 100644 --- a/osu.Game/Beatmaps/IBeatmapResourceProvider.cs +++ b/osu.Game/Beatmaps/IBeatmapResourceProvider.cs @@ -9,13 +9,18 @@ using osu.Game.IO; namespace osu.Game.Beatmaps { - public interface IBeatmapResourceProvider : IStorageResourceProvider + internal interface IBeatmapResourceProvider : IStorageResourceProvider { /// /// Retrieve a global large texture store, used for loading beatmap backgrounds. /// TextureStore LargeTextureStore { get; } + /// + /// Retrieve a global large texture store, used specifically for retrieving cropped beatmap panel backgrounds. + /// + TextureStore BeatmapPanelTextureStore { get; } + /// /// Access a global track store for retrieving beatmap tracks from. /// diff --git a/osu.Game/Beatmaps/IWorkingBeatmap.cs b/osu.Game/Beatmaps/IWorkingBeatmap.cs index 0f0e72b0ac..bdfa6bdf6d 100644 --- a/osu.Game/Beatmaps/IWorkingBeatmap.cs +++ b/osu.Game/Beatmaps/IWorkingBeatmap.cs @@ -32,12 +32,12 @@ namespace osu.Game.Beatmaps /// /// Whether the Beatmap has finished loading. /// - public bool BeatmapLoaded { get; } + bool BeatmapLoaded { get; } /// /// Whether the Track has finished loading. /// - public bool TrackLoaded { get; } + bool TrackLoaded { get; } /// /// Retrieves the which this represents. @@ -47,7 +47,12 @@ namespace osu.Game.Beatmaps /// /// Retrieves the background for this . /// - Texture Background { get; } + Texture GetBackground(); + + /// + /// Retrieves a cropped background for this used for display on panels. + /// + Texture GetPanelBackground(); /// /// Retrieves the for the of this . @@ -124,12 +129,12 @@ namespace osu.Game.Beatmaps /// /// Beings loading the contents of this asynchronously. /// - public void BeginAsyncLoad(); + void BeginAsyncLoad(); /// /// Cancels the asynchronous loading of the contents of this . /// - public void CancelAsyncLoad(); + void CancelAsyncLoad(); /// /// Reads the correct track restart point from beatmap metadata and sets looping to enabled. diff --git a/osu.Game/Beatmaps/Legacy/LegacyOrigins.cs b/osu.Game/Beatmaps/Legacy/LegacyOrigins.cs index 62b0edc384..31f67d6dfd 100644 --- a/osu.Game/Beatmaps/Legacy/LegacyOrigins.cs +++ b/osu.Game/Beatmaps/Legacy/LegacyOrigins.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Beatmaps.Legacy { internal enum LegacyOrigins diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index 59a71fd80c..25159996f3 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -34,8 +34,6 @@ namespace osu.Game.Beatmaps public Storyboard Storyboard => storyboard.Value; - public Texture Background => GetBackground(); // Texture uses ref counting, so we want to return a new instance every usage. - public ISkin Skin => skin.Value; private AudioManager audioManager { get; } @@ -67,7 +65,8 @@ namespace osu.Game.Beatmaps protected virtual Storyboard GetStoryboard() => new Storyboard { BeatmapInfo = BeatmapInfo }; protected abstract IBeatmap GetBeatmap(); - protected abstract Texture GetBackground(); + public abstract Texture GetBackground(); + public virtual Texture GetPanelBackground() => GetBackground(); protected abstract Track GetBeatmapTrack(); /// diff --git a/osu.Game/Beatmaps/WorkingBeatmapCache.cs b/osu.Game/Beatmaps/WorkingBeatmapCache.cs index ef843909d8..78eed626f2 100644 --- a/osu.Game/Beatmaps/WorkingBeatmapCache.cs +++ b/osu.Game/Beatmaps/WorkingBeatmapCache.cs @@ -42,6 +42,7 @@ namespace osu.Game.Beatmaps private readonly AudioManager audioManager; private readonly IResourceStore resources; private readonly LargeTextureStore largeTextureStore; + private readonly LargeTextureStore beatmapPanelTextureStore; private readonly ITrackStore trackStore; private readonly IResourceStore files; @@ -58,6 +59,7 @@ namespace osu.Game.Beatmaps this.host = host; this.files = files; largeTextureStore = new LargeTextureStore(host?.Renderer ?? new DummyRenderer(), host?.CreateTextureLoaderStore(files)); + beatmapPanelTextureStore = new LargeTextureStore(host?.Renderer ?? new DummyRenderer(), new BeatmapPanelBackgroundTextureLoaderStore(host?.CreateTextureLoaderStore(files))); this.trackStore = trackStore; } @@ -110,6 +112,7 @@ namespace osu.Game.Beatmaps #region IResourceStorageProvider TextureStore IBeatmapResourceProvider.LargeTextureStore => largeTextureStore; + TextureStore IBeatmapResourceProvider.BeatmapPanelTextureStore => beatmapPanelTextureStore; ITrackStore IBeatmapResourceProvider.Tracks => trackStore; IRenderer IStorageResourceProvider.Renderer => host?.Renderer ?? new DummyRenderer(); AudioManager IStorageResourceProvider.AudioManager => audioManager; @@ -160,7 +163,11 @@ namespace osu.Game.Beatmaps } } - protected override Texture GetBackground() + public override Texture GetPanelBackground() => getBackgroundFromStore(resources.BeatmapPanelTextureStore); + + public override Texture GetBackground() => getBackgroundFromStore(resources.LargeTextureStore); + + private Texture getBackgroundFromStore(TextureStore store) { if (string.IsNullOrEmpty(Metadata?.BackgroundFile)) return null; @@ -168,7 +175,7 @@ namespace osu.Game.Beatmaps try { string fileStorePath = BeatmapSetInfo.GetPathForFile(Metadata.BackgroundFile); - var texture = resources.LargeTextureStore.Get(fileStorePath); + var texture = store.Get(fileStorePath); if (texture == null) { @@ -257,7 +264,7 @@ namespace osu.Game.Beatmaps if (beatmapFileStream == null) { Logger.Log($"Beatmap failed to load (file {BeatmapInfo.Path} not found on disk at expected location {fileStorePath})", level: LogLevel.Error); - return null; + return new Storyboard(); } using (var reader = new LineBufferedReader(beatmapFileStream)) diff --git a/osu.Game/Collections/DrawableCollectionListItem.cs b/osu.Game/Collections/DrawableCollectionListItem.cs index 23156b1ad5..0ab0ff520d 100644 --- a/osu.Game/Collections/DrawableCollectionListItem.cs +++ b/osu.Game/Collections/DrawableCollectionListItem.cs @@ -86,6 +86,7 @@ namespace osu.Game.Collections RelativeSizeAxes = Axes.Both, Size = Vector2.One, CornerRadius = item_height / 2, + CommitOnFocusLost = true, PlaceholderText = collection.IsManaged ? string.Empty : "Create a new collection" }, } diff --git a/osu.Game/Collections/ManageCollectionsDialog.cs b/osu.Game/Collections/ManageCollectionsDialog.cs index 36142cf26f..31016b807b 100644 --- a/osu.Game/Collections/ManageCollectionsDialog.cs +++ b/osu.Game/Collections/ManageCollectionsDialog.cs @@ -114,8 +114,6 @@ namespace osu.Game.Collections protected override void PopIn() { - base.PopIn(); - lowPassFilter.CutoffTo(300, 100, Easing.OutCubic); this.FadeIn(enter_duration, Easing.OutQuint); this.ScaleTo(0.9f).Then().ScaleTo(1f, enter_duration, Easing.OutQuint); diff --git a/osu.Game/Configuration/IntroSequence.cs b/osu.Game/Configuration/IntroSequence.cs index 8327ea2f57..5672c44bbe 100644 --- a/osu.Game/Configuration/IntroSequence.cs +++ b/osu.Game/Configuration/IntroSequence.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Configuration { public enum IntroSequence diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 365ad37f4c..193068193a 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -178,6 +178,7 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.EditorWaveformOpacity, 0.25f, 0f, 1f, 0.25f); SetDefault(OsuSetting.EditorShowHitMarkers, true); SetDefault(OsuSetting.EditorAutoSeekOnPlacement, true); + SetDefault(OsuSetting.EditorLimitedDistanceSnap, false); SetDefault(OsuSetting.LastProcessedMetadataId, -1); @@ -383,5 +384,6 @@ namespace osu.Game.Configuration SafeAreaConsiderations, ComboColourNormalisationAmount, ProfileCoverExpanded, + EditorLimitedDistanceSnap } } diff --git a/osu.Game/Configuration/ReleaseStream.cs b/osu.Game/Configuration/ReleaseStream.cs index 9cdd91bfd0..ed0bee1dd8 100644 --- a/osu.Game/Configuration/ReleaseStream.cs +++ b/osu.Game/Configuration/ReleaseStream.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Configuration { public enum ReleaseStream diff --git a/osu.Game/Configuration/SessionStatics.cs b/osu.Game/Configuration/SessionStatics.cs index 276563e163..5e2f0c2128 100644 --- a/osu.Game/Configuration/SessionStatics.cs +++ b/osu.Game/Configuration/SessionStatics.cs @@ -6,6 +6,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; +using osu.Game.Overlays.Mods; namespace osu.Game.Configuration { @@ -21,6 +22,7 @@ namespace osu.Game.Configuration SetDefault(Static.LowBatteryNotificationShownOnce, false); SetDefault(Static.FeaturedArtistDisclaimerShownOnce, false); SetDefault(Static.LastHoverSoundPlaybackTime, (double?)null); + SetDefault(Static.LastModSelectPanelSamplePlaybackTime, (double?)null); SetDefault(Static.SeasonalBackgrounds, null); } @@ -56,5 +58,11 @@ namespace osu.Game.Configuration /// Used to debounce hover sounds game-wide to avoid volume saturation, especially in scrolling views with many UI controls like . /// LastHoverSoundPlaybackTime, + + /// + /// The last playback time in milliseconds of an on/off sample (from ). + /// Used to debounce on/off sounds game-wide to avoid volume saturation, especially in activating mod presets with many mods. + /// + LastModSelectPanelSamplePlaybackTime } } diff --git a/osu.Game/Configuration/ToolbarClockDisplayMode.cs b/osu.Game/Configuration/ToolbarClockDisplayMode.cs index 682e221ef8..2f42f7a9b5 100644 --- a/osu.Game/Configuration/ToolbarClockDisplayMode.cs +++ b/osu.Game/Configuration/ToolbarClockDisplayMode.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Configuration { public enum ToolbarClockDisplayMode diff --git a/osu.Game/Database/BeatmapLookupCache.cs b/osu.Game/Database/BeatmapLookupCache.cs index d9bf0138dc..973c25ec4f 100644 --- a/osu.Game/Database/BeatmapLookupCache.cs +++ b/osu.Game/Database/BeatmapLookupCache.cs @@ -1,13 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; -using JetBrains.Annotations; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; @@ -21,8 +18,7 @@ namespace osu.Game.Database /// The beatmap to lookup. /// An optional cancellation token. /// The populated beatmap, or null if the beatmap does not exist or the request could not be satisfied. - [ItemCanBeNull] - public Task GetBeatmapAsync(int beatmapId, CancellationToken token = default) => LookupAsync(beatmapId, token); + public Task GetBeatmapAsync(int beatmapId, CancellationToken token = default) => LookupAsync(beatmapId, token); /// /// Perform an API lookup on the specified beatmaps, populating a model. @@ -30,10 +26,10 @@ namespace osu.Game.Database /// The beatmaps to lookup. /// An optional cancellation token. /// The populated beatmaps. May include null results for failed retrievals. - public Task GetBeatmapsAsync(int[] beatmapIds, CancellationToken token = default) => LookupAsync(beatmapIds, token); + public Task GetBeatmapsAsync(int[] beatmapIds, CancellationToken token = default) => LookupAsync(beatmapIds, token); protected override GetBeatmapsRequest CreateRequest(IEnumerable ids) => new GetBeatmapsRequest(ids.ToArray()); - protected override IEnumerable RetrieveResults(GetBeatmapsRequest request) => request.Response?.Beatmaps; + protected override IEnumerable? RetrieveResults(GetBeatmapsRequest request) => request.Response?.Beatmaps; } } diff --git a/osu.Game/Database/ISoftDelete.cs b/osu.Game/Database/ISoftDelete.cs index b07c8db2de..afa42c2002 100644 --- a/osu.Game/Database/ISoftDelete.cs +++ b/osu.Game/Database/ISoftDelete.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Database { /// diff --git a/osu.Game/Database/MemoryCachingComponent.cs b/osu.Game/Database/MemoryCachingComponent.cs index 5d1a381f09..e98475efae 100644 --- a/osu.Game/Database/MemoryCachingComponent.cs +++ b/osu.Game/Database/MemoryCachingComponent.cs @@ -1,13 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using System.Threading; using System.Threading.Tasks; -using JetBrains.Annotations; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; using osu.Framework.Statistics; @@ -19,8 +17,9 @@ namespace osu.Game.Database /// Currently not persisted between game sessions. /// public abstract partial class MemoryCachingComponent : Component + where TLookup : notnull { - private readonly ConcurrentDictionary cache = new ConcurrentDictionary(); + private readonly ConcurrentDictionary cache = new ConcurrentDictionary(); private readonly GlobalStatistic statistics; @@ -37,12 +36,12 @@ namespace osu.Game.Database /// /// The lookup to retrieve. /// An optional to cancel the operation. - protected async Task GetAsync([NotNull] TLookup lookup, CancellationToken token = default) + protected async Task GetAsync(TLookup lookup, CancellationToken token = default) { - if (CheckExists(lookup, out TValue performance)) + if (CheckExists(lookup, out TValue? existing)) { statistics.Value.HitCount++; - return performance; + return existing; } var computed = await ComputeValueAsync(lookup, token).ConfigureAwait(false); @@ -73,7 +72,7 @@ namespace osu.Game.Database statistics.Value.Usage = cache.Count; } - protected bool CheckExists([NotNull] TLookup lookup, out TValue value) => + protected bool CheckExists(TLookup lookup, [MaybeNullWhen(false)] out TValue value) => cache.TryGetValue(lookup, out value); /// @@ -82,7 +81,7 @@ namespace osu.Game.Database /// The lookup to retrieve. /// An optional to cancel the operation. /// The computed value. - protected abstract Task ComputeValueAsync(TLookup lookup, CancellationToken token = default); + protected abstract Task ComputeValueAsync(TLookup lookup, CancellationToken token = default); private class MemoryCachingStatistics { diff --git a/osu.Game/Database/OnlineLookupCache.cs b/osu.Game/Database/OnlineLookupCache.cs index d9b37e2f29..3b54804fec 100644 --- a/osu.Game/Database/OnlineLookupCache.cs +++ b/osu.Game/Database/OnlineLookupCache.cs @@ -1,14 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Game.Online.API; @@ -21,7 +18,7 @@ namespace osu.Game.Database where TRequest : APIRequest { [Resolved] - private IAPIProvider api { get; set; } + private IAPIProvider api { get; set; } = null!; /// /// Creates an to retrieve the values for a given collection of s. @@ -32,8 +29,7 @@ namespace osu.Game.Database /// /// Retrieves a list of s from a successful created by . /// - [CanBeNull] - protected abstract IEnumerable RetrieveResults(TRequest request); + protected abstract IEnumerable? RetrieveResults(TRequest request); /// /// Perform a lookup using the specified , populating a . @@ -41,8 +37,7 @@ namespace osu.Game.Database /// The ID to lookup. /// An optional cancellation token. /// The populated , or null if the value does not exist or the request could not be satisfied. - [ItemCanBeNull] - protected Task LookupAsync(TLookup id, CancellationToken token = default) => GetAsync(id, token); + protected Task LookupAsync(TLookup id, CancellationToken token = default) => GetAsync(id, token); /// /// Perform an API lookup on the specified , populating a . @@ -50,9 +45,9 @@ namespace osu.Game.Database /// The IDs to lookup. /// An optional cancellation token. /// The populated values. May include null results for failed retrievals. - protected Task LookupAsync(TLookup[] ids, CancellationToken token = default) + protected Task LookupAsync(TLookup[] ids, CancellationToken token = default) { - var lookupTasks = new List>(); + var lookupTasks = new List>(); foreach (var id in ids) { @@ -69,18 +64,18 @@ namespace osu.Game.Database } // cannot be sealed due to test usages (see TestUserLookupCache). - protected override async Task ComputeValueAsync(TLookup lookup, CancellationToken token = default) + protected override async Task ComputeValueAsync(TLookup lookup, CancellationToken token = default) => await queryValue(lookup).ConfigureAwait(false); - private readonly Queue<(TLookup id, TaskCompletionSource)> pendingTasks = new Queue<(TLookup, TaskCompletionSource)>(); - private Task pendingRequestTask; + private readonly Queue<(TLookup id, TaskCompletionSource)> pendingTasks = new Queue<(TLookup, TaskCompletionSource)>(); + private Task? pendingRequestTask; private readonly object taskAssignmentLock = new object(); - private Task queryValue(TLookup id) + private Task queryValue(TLookup id) { lock (taskAssignmentLock) { - var tcs = new TaskCompletionSource(); + var tcs = new TaskCompletionSource(); // Add to the queue. pendingTasks.Enqueue((id, tcs)); @@ -96,14 +91,14 @@ namespace osu.Game.Database private async Task performLookup() { // contains at most 50 unique IDs from tasks, which is used to perform the lookup. - var nextTaskBatch = new Dictionary>>(); + var nextTaskBatch = new Dictionary>>(); // Grab at most 50 unique IDs from the queue. lock (taskAssignmentLock) { while (pendingTasks.Count > 0 && nextTaskBatch.Count < 50) { - (TLookup id, TaskCompletionSource task) next = pendingTasks.Dequeue(); + (TLookup id, TaskCompletionSource task) next = pendingTasks.Dequeue(); // Perform a secondary check for existence, in case the value was queried in a previous batch. if (CheckExists(next.id, out var existing)) @@ -113,7 +108,7 @@ namespace osu.Game.Database if (nextTaskBatch.TryGetValue(next.id, out var tasks)) tasks.Add(next.task); else - nextTaskBatch[next.id] = new List> { next.task }; + nextTaskBatch[next.id] = new List> { next.task }; } } } diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index 831e328439..da4caa42ba 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -22,12 +22,15 @@ using osu.Framework.Statistics; using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Extensions; using osu.Game.Input.Bindings; +using osu.Game.IO.Legacy; using osu.Game.Models; using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; +using osu.Game.Scoring.Legacy; using osu.Game.Skinning; using Realms; using Realms.Exceptions; @@ -71,8 +74,12 @@ namespace osu.Game.Database /// 24 2022-08-22 Added MaximumStatistics to ScoreInfo. /// 25 2022-09-18 Remove skins to add with new naming. /// 26 2023-02-05 Added BeatmapHash to ScoreInfo. + /// 27 2023-06-06 Added EditorTimestamp to BeatmapInfo. + /// 28 2023-06-08 Added IsLegacyScore to ScoreInfo, parsed from replay files. + /// 29 2023-06-12 Run migration of old lazer scores to be best-effort in the new scoring number space. No actual realm changes. + /// 30 2023-06-16 Run migration of old lazer scores again. This time with more correct rounding considerations. /// - private const int schema_version = 26; + private const int schema_version = 30; /// /// Lock object which is held during sections, blocking realm retrieval during blocking periods. @@ -179,43 +186,9 @@ namespace osu.Game.Database applyFilenameSchemaSuffix(ref Filename); #endif - string newerVersionFilename = $"{Filename.Replace(realm_extension, string.Empty)}_newer_version{realm_extension}"; - - // Attempt to recover a newer database version if available. - if (storage.Exists(newerVersionFilename)) - { - Logger.Log(@"A newer realm database has been found, attempting recovery...", LoggingTarget.Database); - attemptRecoverFromFile(newerVersionFilename); - } - - try - { - // This method triggers the first `getRealmInstance` call, which will implicitly run realm migrations and bring the schema up-to-date. - cleanupPendingDeletions(); - } - catch (Exception e) - { - // See https://github.com/realm/realm-core/blob/master/src%2Frealm%2Fobject-store%2Fobject_store.cpp#L1016-L1022 - // This is the best way we can detect a schema version downgrade. - if (e.Message.StartsWith(@"Provided schema version", StringComparison.Ordinal)) - { - Logger.Error(e, "Your local database is too new to work with this version of osu!. Please close osu! and install the latest release to recover your data."); - - // If a newer version database already exists, don't backup again. We can presume that the first backup is the one we care about. - if (!storage.Exists(newerVersionFilename)) - createBackup(newerVersionFilename); - - storage.Delete(Filename); - } - else - { - Logger.Error(e, "Realm startup failed with unrecoverable error; starting with a fresh database. A backup of your database has been made."); - createBackup($"{Filename.Replace(realm_extension, string.Empty)}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}_corrupt{realm_extension}"); - storage.Delete(Filename); - } - - cleanupPendingDeletions(); - } + // `prepareFirstRealmAccess()` triggers the first `getRealmInstance` call, which will implicitly run realm migrations and bring the schema up-to-date. + using (var realm = prepareFirstRealmAccess()) + cleanupPendingDeletions(realm); } /// @@ -312,49 +285,93 @@ namespace osu.Game.Database Logger.Log(@"Recovery complete!", LoggingTarget.Database); } - private void cleanupPendingDeletions() + private Realm prepareFirstRealmAccess() { - using (var realm = getRealmInstance()) - using (var transaction = realm.BeginWrite()) + string newerVersionFilename = $"{Filename.Replace(realm_extension, string.Empty)}_newer_version{realm_extension}"; + + // Attempt to recover a newer database version if available. + if (storage.Exists(newerVersionFilename)) { - var pendingDeleteScores = realm.All().Where(s => s.DeletePending); - - foreach (var score in pendingDeleteScores) - realm.Remove(score); - - var pendingDeleteSets = realm.All().Where(s => s.DeletePending); - - foreach (var beatmapSet in pendingDeleteSets) - { - foreach (var beatmap in beatmapSet.Beatmaps) - { - // Cascade delete related scores, else they will have a null beatmap against the model's spec. - foreach (var score in beatmap.Scores) - realm.Remove(score); - - realm.Remove(beatmap.Metadata); - realm.Remove(beatmap); - } - - realm.Remove(beatmapSet); - } - - var pendingDeleteSkins = realm.All().Where(s => s.DeletePending); - - foreach (var s in pendingDeleteSkins) - realm.Remove(s); - - var pendingDeletePresets = realm.All().Where(s => s.DeletePending); - - foreach (var s in pendingDeletePresets) - realm.Remove(s); - - transaction.Commit(); + Logger.Log(@"A newer realm database has been found, attempting recovery...", LoggingTarget.Database); + attemptRecoverFromFile(newerVersionFilename); } - // clean up files after dropping any pending deletions. - // in the future we may want to only do this when the game is idle, rather than on every startup. - new RealmFileStore(this, storage).Cleanup(); + try + { + return getRealmInstance(); + } + catch (Exception e) + { + // See https://github.com/realm/realm-core/blob/master/src%2Frealm%2Fobject-store%2Fobject_store.cpp#L1016-L1022 + // This is the best way we can detect a schema version downgrade. + if (e.Message.StartsWith(@"Provided schema version", StringComparison.Ordinal)) + { + Logger.Error(e, "Your local database is too new to work with this version of osu!. Please close osu! and install the latest release to recover your data."); + + // If a newer version database already exists, don't backup again. We can presume that the first backup is the one we care about. + if (!storage.Exists(newerVersionFilename)) + createBackup(newerVersionFilename); + } + else + { + Logger.Error(e, "Realm startup failed with unrecoverable error; starting with a fresh database. A backup of your database has been made."); + createBackup($"{Filename.Replace(realm_extension, string.Empty)}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}_corrupt{realm_extension}"); + } + + storage.Delete(Filename); + return getRealmInstance(); + } + } + + private void cleanupPendingDeletions(Realm realm) + { + try + { + using (var transaction = realm.BeginWrite()) + { + var pendingDeleteScores = realm.All().Where(s => s.DeletePending); + + foreach (var score in pendingDeleteScores) + realm.Remove(score); + + var pendingDeleteSets = realm.All().Where(s => s.DeletePending); + + foreach (var beatmapSet in pendingDeleteSets) + { + foreach (var beatmap in beatmapSet.Beatmaps) + { + // Cascade delete related scores, else they will have a null beatmap against the model's spec. + foreach (var score in beatmap.Scores) + realm.Remove(score); + + realm.Remove(beatmap.Metadata); + realm.Remove(beatmap); + } + + realm.Remove(beatmapSet); + } + + var pendingDeleteSkins = realm.All().Where(s => s.DeletePending); + + foreach (var s in pendingDeleteSkins) + realm.Remove(s); + + var pendingDeletePresets = realm.All().Where(s => s.DeletePending); + + foreach (var s in pendingDeletePresets) + realm.Remove(s); + + transaction.Commit(); + } + + // clean up files after dropping any pending deletions. + // in the future we may want to only do this when the game is idle, rather than on every startup. + new RealmFileStore(this, storage).Cleanup(); + } + catch (Exception e) + { + Logger.Error(e, "Failed to clean up unused files. This is not critical but please report if it happens regularly."); + } } /// @@ -709,6 +726,11 @@ namespace osu.Game.Database private void applyMigrationsForVersion(Migration migration, ulong targetVersion) { + Logger.Log($"Running realm migration to version {targetVersion}..."); + Stopwatch stopwatch = new Stopwatch(); + + stopwatch.Start(); + switch (targetVersion) { case 7: @@ -869,6 +891,7 @@ namespace osu.Game.Database break; case 26: + { // Add ScoreInfo.BeatmapHash property to ensure scores correspond to the correct version of beatmap. var scores = migration.NewRealm.All(); @@ -876,7 +899,76 @@ namespace osu.Game.Database score.BeatmapHash = score.BeatmapInfo.Hash; break; + } + + case 28: + { + var files = new RealmFileStore(this, storage); + var scores = migration.NewRealm.All(); + + foreach (var score in scores) + { + string? replayFilename = score.Files.FirstOrDefault(f => f.Filename.EndsWith(@".osr", StringComparison.InvariantCultureIgnoreCase))?.File.GetStoragePath(); + if (replayFilename == null) + continue; + + try + { + using (var stream = files.Store.GetStream(replayFilename)) + { + if (stream == null) + continue; + + // Trimmed down logic from LegacyScoreDecoder to extract the version from replays. + using (SerializationReader sr = new SerializationReader(stream)) + { + sr.ReadByte(); // Ruleset. + int version = sr.ReadInt32(); + if (version < LegacyScoreEncoder.FIRST_LAZER_VERSION) + score.IsLegacyScore = true; + } + } + } + catch (Exception e) + { + Logger.Error(e, $"Failed to read replay {replayFilename} during score migration", LoggingTarget.Database); + } + } + + break; + } + + case 29: + case 30: + { + var scores = migration.NewRealm + .All() + .Where(s => !s.IsLegacyScore); + + foreach (var score in scores) + { + try + { + if (StandardisedScoreMigrationTools.ShouldMigrateToNewStandardised(score)) + { + try + { + long calculatedNew = StandardisedScoreMigrationTools.GetNewStandardised(score); + score.TotalScore = calculatedNew; + } + catch + { + } + } + } + catch { } + } + + break; + } } + + Logger.Log($"Migration completed in {stopwatch.ElapsedMilliseconds}ms"); } private string? getRulesetShortNameFromLegacyID(long rulesetId) @@ -909,7 +1001,7 @@ namespace osu.Game.Database int attempts = 10; - while (attempts-- > 0) + while (true) { try { @@ -927,6 +1019,9 @@ namespace osu.Game.Database } catch (IOException) { + if (attempts-- <= 0) + throw; + // file may be locked during use. Thread.Sleep(500); } diff --git a/osu.Game/Database/StandardisedScoreMigrationTools.cs b/osu.Game/Database/StandardisedScoreMigrationTools.cs new file mode 100644 index 0000000000..582a656efa --- /dev/null +++ b/osu.Game/Database/StandardisedScoreMigrationTools.cs @@ -0,0 +1,210 @@ +// 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; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; + +namespace osu.Game.Database +{ + public static class StandardisedScoreMigrationTools + { + public static bool ShouldMigrateToNewStandardised(ScoreInfo score) + { + if (score.IsLegacyScore) + return false; + + // Recalculate the old-style standardised score to see if this was an old lazer score. + bool oldScoreMatchesExpectations = GetOldStandardised(score) == score.TotalScore; + // Some older scores don't have correct statistics populated, so let's give them benefit of doubt. + bool scoreIsVeryOld = score.Date < new DateTime(2023, 1, 1, 0, 0, 0); + + return oldScoreMatchesExpectations || scoreIsVeryOld; + } + + public static long GetNewStandardised(ScoreInfo score) + { + int maxJudgementIndex = 0; + + // Avoid retrieving from realm inside loops. + int maxCombo = score.MaxCombo; + + var ruleset = score.Ruleset.CreateInstance(); + var processor = ruleset.CreateScoreProcessor(); + + processor.TrackHitEvents = false; + + var beatmap = new Beatmap(); + + HitResult maxRulesetJudgement = ruleset.GetHitResults().First().result; + + // This is a list of all results, ordered from best to worst. + // We are constructing a "best possible" score from the statistics provided because it's the best we can do. + List sortedHits = score.Statistics + .Where(kvp => kvp.Key.AffectsCombo()) + .OrderByDescending(kvp => Judgement.ToNumericResult(kvp.Key)) + .SelectMany(kvp => Enumerable.Repeat(kvp.Key, kvp.Value)) + .ToList(); + + // Attempt to use maximum statistics from the database. + var maximumJudgements = score.MaximumStatistics + .Where(kvp => kvp.Key.AffectsCombo()) + .OrderByDescending(kvp => Judgement.ToNumericResult(kvp.Key)) + .SelectMany(kvp => Enumerable.Repeat(new FakeJudgement(kvp.Key), kvp.Value)) + .ToList(); + + // Some older scores may not have maximum statistics populated correctly. + // In this case we need to fill them with best-known-defaults. + if (maximumJudgements.Count != sortedHits.Count) + { + maximumJudgements = sortedHits + .Select(r => new FakeJudgement(getMaxJudgementFor(r, maxRulesetJudgement))) + .ToList(); + } + + // This is required to get the correct maximum combo portion. + foreach (var judgement in maximumJudgements) + beatmap.HitObjects.Add(new FakeHit(judgement)); + processor.ApplyBeatmap(beatmap); + processor.Mods.Value = score.Mods; + + // Insert all misses into a queue. + // These will be nibbled at whenever we need to reset the combo. + Queue misses = new Queue(score.Statistics + .Where(kvp => kvp.Key == HitResult.Miss || kvp.Key == HitResult.LargeTickMiss) + .SelectMany(kvp => Enumerable.Repeat(kvp.Key, kvp.Value))); + + foreach (var result in sortedHits) + { + // For the main part of this loop, ignore all misses, as they will be inserted from the queue. + if (result == HitResult.Miss || result == HitResult.LargeTickMiss) + continue; + + // Reset combo if required. + if (processor.Combo.Value == maxCombo) + insertMiss(); + + processor.ApplyResult(new JudgementResult(null!, maximumJudgements[maxJudgementIndex++]) + { + Type = result + }); + } + + // Ensure we haven't forgotten any misses. + while (misses.Count > 0) + insertMiss(); + + var bonusHits = score.Statistics + .Where(kvp => kvp.Key.IsBonus()) + .SelectMany(kvp => Enumerable.Repeat(kvp.Key, kvp.Value)); + + foreach (var result in bonusHits) + processor.ApplyResult(new JudgementResult(null!, new FakeJudgement(result)) { Type = result }); + + // Not true for all scores for whatever reason. Oh well. + // Debug.Assert(processor.HighestCombo.Value == score.MaxCombo); + + return processor.TotalScore.Value; + + void insertMiss() + { + if (misses.Count > 0) + { + processor.ApplyResult(new JudgementResult(null!, maximumJudgements[maxJudgementIndex++]) + { + Type = misses.Dequeue(), + }); + } + else + { + // We ran out of misses. But we can't let max combo increase beyond the known value, + // so let's forge a miss. + processor.ApplyResult(new JudgementResult(null!, new FakeJudgement(getMaxJudgementFor(HitResult.Miss, maxRulesetJudgement))) + { + Type = HitResult.Miss, + }); + } + } + } + + private static HitResult getMaxJudgementFor(HitResult hitResult, HitResult max) + { + switch (hitResult) + { + case HitResult.Miss: + case HitResult.Meh: + case HitResult.Ok: + case HitResult.Good: + case HitResult.Great: + case HitResult.Perfect: + return max; + + case HitResult.SmallTickMiss: + case HitResult.SmallTickHit: + return HitResult.SmallTickHit; + + case HitResult.LargeTickMiss: + case HitResult.LargeTickHit: + return HitResult.LargeTickHit; + } + + return HitResult.IgnoreHit; + } + + public static long GetOldStandardised(ScoreInfo score) + { + double accuracyScore = + (double)score.Statistics.Where(kvp => kvp.Key.AffectsAccuracy()).Sum(kvp => Judgement.ToNumericResult(kvp.Key) * kvp.Value) + / score.MaximumStatistics.Where(kvp => kvp.Key.AffectsAccuracy()).Sum(kvp => Judgement.ToNumericResult(kvp.Key) * kvp.Value); + double comboScore = (double)score.MaxCombo / score.MaximumStatistics.Where(kvp => kvp.Key.AffectsCombo()).Sum(kvp => kvp.Value); + double bonusScore = score.Statistics.Where(kvp => kvp.Key.IsBonus()).Sum(kvp => Judgement.ToNumericResult(kvp.Key) * kvp.Value); + + double accuracyPortion = 0.3; + + switch (score.RulesetID) + { + case 1: + accuracyPortion = 0.75; + break; + + case 3: + accuracyPortion = 0.99; + break; + } + + double modMultiplier = 1; + + foreach (var mod in score.Mods) + modMultiplier *= mod.ScoreMultiplier; + + return (long)Math.Round((1000000 * (accuracyPortion * accuracyScore + (1 - accuracyPortion) * comboScore) + bonusScore) * modMultiplier); + } + + private class FakeHit : HitObject + { + private readonly Judgement judgement; + + public override Judgement CreateJudgement() => judgement; + + public FakeHit(Judgement judgement) + { + this.judgement = judgement; + } + } + + private class FakeJudgement : Judgement + { + public override HitResult MaxResult { get; } + + public FakeJudgement(HitResult maxResult) + { + MaxResult = maxResult; + } + } + } +} diff --git a/osu.Game/Database/UserLookupCache.cs b/osu.Game/Database/UserLookupCache.cs index b1609fbf7b..e581d5ce82 100644 --- a/osu.Game/Database/UserLookupCache.cs +++ b/osu.Game/Database/UserLookupCache.cs @@ -1,13 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; -using JetBrains.Annotations; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; @@ -21,8 +18,7 @@ namespace osu.Game.Database /// The user to lookup. /// An optional cancellation token. /// The populated user, or null if the user does not exist or the request could not be satisfied. - [ItemCanBeNull] - public Task GetUserAsync(int userId, CancellationToken token = default) => LookupAsync(userId, token); + public Task GetUserAsync(int userId, CancellationToken token = default) => LookupAsync(userId, token); /// /// Perform an API lookup on the specified users, populating a model. @@ -30,10 +26,10 @@ namespace osu.Game.Database /// The users to lookup. /// An optional cancellation token. /// The populated users. May include null results for failed retrievals. - public Task GetUsersAsync(int[] userIds, CancellationToken token = default) => LookupAsync(userIds, token); + public Task GetUsersAsync(int[] userIds, CancellationToken token = default) => LookupAsync(userIds, token); protected override GetUsersRequest CreateRequest(IEnumerable ids) => new GetUsersRequest(ids.ToArray()); - protected override IEnumerable RetrieveResults(GetUsersRequest request) => request.Response?.Users; + protected override IEnumerable? RetrieveResults(GetUsersRequest request) => request.Response?.Users; } } diff --git a/osu.Game/Extensions/DrawableExtensions.cs b/osu.Game/Extensions/DrawableExtensions.cs index 915a2292a2..6553ad3886 100644 --- a/osu.Game/Extensions/DrawableExtensions.cs +++ b/osu.Game/Extensions/DrawableExtensions.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Graphics; +using osu.Framework.Platform; using osuTK; namespace osu.Game.Extensions @@ -43,5 +44,20 @@ namespace osu.Game.Extensions /// The delta vector in Parent's coordinates. public static Vector2 ScreenSpaceDeltaToParentSpace(this Drawable drawable, Vector2 delta) => drawable.Parent.ToLocalSpace(drawable.Parent.ToScreenSpace(Vector2.Zero) + delta); + + /// + /// Some elements don't handle rewind correctly and fixing them is non-trivial. + /// In the future we need a better solution to this, but as a temporary work-around, give these components the game-wide + /// clock so they don't need to worry about rewind. + /// + /// This only works if input handling components handle OnPressed/OnReleased which results in a correct state while rewinding. + /// + /// This is kinda dodgy (and will cause weirdness when pausing gameplay) but is better than completely broken rewind. + /// + public static void ApplyGameWideClock(this Drawable drawable, GameHost host) + { + drawable.Clock = host.UpdateThread.Clock; + drawable.ProcessCustomClock = false; + } } } diff --git a/osu.Game/Extensions/LanguageExtensions.cs b/osu.Game/Extensions/LanguageExtensions.cs index 04231c384c..44932cf3c8 100644 --- a/osu.Game/Extensions/LanguageExtensions.cs +++ b/osu.Game/Extensions/LanguageExtensions.cs @@ -21,7 +21,12 @@ namespace osu.Game.Extensions /// This is required as enum member names are not allowed to contain hyphens. /// public static string ToCultureCode(this Language language) - => language.ToString().Replace("_", "-"); + { + if (language == Language.zh_hant) + return @"zh-tw"; + + return language.ToString().Replace("_", "-"); + } /// /// Attempts to parse the supplied to a value. @@ -30,7 +35,15 @@ namespace osu.Game.Extensions /// The parsed . Valid only if the return value of the method is . /// Whether the parsing succeeded. public static bool TryParseCultureCode(string cultureCode, out Language language) - => Enum.TryParse(cultureCode.Replace("-", "_"), out language); + { + if (cultureCode == @"zh-tw") + { + language = Language.zh_hant; + return true; + } + + return Enum.TryParse(cultureCode.Replace("-", "_"), out language); + } /// /// Parses the that is specified in , diff --git a/osu.Game/Graphics/Backgrounds/BeatmapBackground.cs b/osu.Game/Graphics/Backgrounds/BeatmapBackground.cs index b79eb4927f..3ace67f410 100644 --- a/osu.Game/Graphics/Backgrounds/BeatmapBackground.cs +++ b/osu.Game/Graphics/Backgrounds/BeatmapBackground.cs @@ -24,7 +24,7 @@ namespace osu.Game.Graphics.Backgrounds [BackgroundDependencyLoader] private void load(LargeTextureStore textures) { - Sprite.Texture = Beatmap?.Background ?? textures.Get(fallbackTextureName); + Sprite.Texture = Beatmap?.GetBackground() ?? textures.Get(fallbackTextureName); } public override bool Equals(Background other) diff --git a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs index 07b5b53e0e..f92cfc2306 100644 --- a/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs +++ b/osu.Game/Graphics/Containers/OsuFocusedOverlayContainer.cs @@ -152,7 +152,6 @@ namespace osu.Game.Graphics.Containers protected override void PopOut() { - base.PopOut(); previewTrackManager.StopAnyPlaying(this); } diff --git a/osu.Game/Graphics/Containers/SectionsContainer.cs b/osu.Game/Graphics/Containers/SectionsContainer.cs index 8dd6eac7bb..27ff6b851d 100644 --- a/osu.Game/Graphics/Containers/SectionsContainer.cs +++ b/osu.Game/Graphics/Containers/SectionsContainer.cs @@ -1,17 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Diagnostics; using System.Linq; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Layout; +using osu.Framework.Logging; +using osu.Framework.Threading; using osu.Framework.Utils; namespace osu.Game.Graphics.Containers @@ -23,11 +22,35 @@ namespace osu.Game.Graphics.Containers public partial class SectionsContainer : Container where T : Drawable { - public Bindable SelectedSection { get; } = new Bindable(); + public Bindable SelectedSection { get; } = new Bindable(); - private T lastClickedSection; + private T? lastClickedSection; - public Drawable ExpandableHeader + protected override Container Content => scrollContentContainer; + + private readonly UserTrackingScrollContainer scrollContainer; + private readonly Container headerBackgroundContainer; + private readonly MarginPadding originalSectionsMargin; + + private Drawable? fixedHeader; + + private Drawable? footer; + private Drawable? headerBackground; + + private FlowContainer scrollContentContainer = null!; + + private float? headerHeight, footerHeight; + + private float? lastKnownScroll; + + /// + /// The percentage of the container to consider the centre-point for deciding the active section (and scrolling to a requested section). + /// + private const float scroll_y_centre = 0.1f; + + private Drawable? expandableHeader; + + public Drawable? ExpandableHeader { get => expandableHeader; set @@ -42,11 +65,12 @@ namespace osu.Game.Graphics.Containers if (value == null) return; AddInternal(expandableHeader); + lastKnownScroll = null; } } - public Drawable FixedHeader + public Drawable? FixedHeader { get => fixedHeader; set @@ -63,7 +87,7 @@ namespace osu.Game.Graphics.Containers } } - public Drawable Footer + public Drawable? Footer { get => footer; set @@ -75,16 +99,17 @@ namespace osu.Game.Graphics.Containers footer = value; - if (value == null) return; + if (footer == null) return; footer.Anchor |= Anchor.y2; footer.Origin |= Anchor.y2; + scrollContainer.Add(footer); lastKnownScroll = null; } } - public Drawable HeaderBackground + public Drawable? HeaderBackground { get => headerBackground; set @@ -102,23 +127,6 @@ namespace osu.Game.Graphics.Containers } } - protected override Container Content => scrollContentContainer; - - private readonly UserTrackingScrollContainer scrollContainer; - private readonly Container headerBackgroundContainer; - private readonly MarginPadding originalSectionsMargin; - private Drawable expandableHeader, fixedHeader, footer, headerBackground; - private FlowContainer scrollContentContainer; - - private float? headerHeight, footerHeight; - - private float? lastKnownScroll; - - /// - /// The percentage of the container to consider the centre-point for deciding the active section (and scrolling to a requested section). - /// - private const float scroll_y_centre = 0.1f; - public SectionsContainer() { AddRangeInternal(new Drawable[] @@ -150,31 +158,63 @@ namespace osu.Game.Graphics.Containers footerHeight = null; } + private ScheduledDelegate? scrollToTargetDelegate; + public void ScrollTo(Drawable target) { + Logger.Log($"Scrolling to {target}.."); + lastKnownScroll = null; - // implementation similar to ScrollIntoView but a bit more nuanced. - float top = scrollContainer.GetChildPosInContent(target); + float scrollTarget = getScrollTargetForDrawable(target); - float bottomScrollExtent = scrollContainer.ScrollableExtent; - float scrollTarget = top - scrollContainer.DisplayableContent * scroll_y_centre; - - if (scrollTarget > bottomScrollExtent) + if (scrollTarget > scrollContainer.ScrollableExtent) scrollContainer.ScrollToEnd(); else scrollContainer.ScrollTo(scrollTarget); if (target is T section) lastClickedSection = section; + + // Content may load in as a scroll occurs, changing the scroll target we need to aim for. + // This scheduled operation ensures that we keep trying until actually arriving at the target. + scrollToTargetDelegate?.Cancel(); + scrollToTargetDelegate = Scheduler.AddDelayed(() => + { + if (scrollContainer.UserScrolling) + { + Logger.Log("Scroll operation interrupted by user scroll"); + scrollToTargetDelegate?.Cancel(); + scrollToTargetDelegate = null; + return; + } + + if (Precision.AlmostEquals(scrollContainer.Current, scrollTarget, 1)) + { + Logger.Log($"Finished scrolling to {target}!"); + scrollToTargetDelegate?.Cancel(); + scrollToTargetDelegate = null; + return; + } + + if (!Precision.AlmostEquals(getScrollTargetForDrawable(target), scrollTarget, 1)) + { + Logger.Log($"Reattempting scroll to {target} due to change in position"); + ScrollTo(target); + } + }, 50, true); + } + + private float getScrollTargetForDrawable(Drawable target) + { + // implementation similar to ScrollIntoView but a bit more nuanced. + return scrollContainer.GetChildPosInContent(target) - scrollContainer.DisplayableContent * scroll_y_centre; } public void ScrollToTop() => scrollContainer.ScrollTo(0); - [NotNull] protected virtual UserTrackingScrollContainer CreateScrollContainer() => new UserTrackingScrollContainer(); - [NotNull] protected virtual FlowContainer CreateScrollContentContainer() => new FillFlowContainer { diff --git a/osu.Game/Graphics/Containers/WaveContainer.cs b/osu.Game/Graphics/Containers/WaveContainer.cs index 952ef3f182..05a666721a 100644 --- a/osu.Game/Graphics/Containers/WaveContainer.cs +++ b/osu.Game/Graphics/Containers/WaveContainer.cs @@ -17,6 +17,7 @@ namespace osu.Game.Graphics.Containers { public const float APPEAR_DURATION = 800; public const float DISAPPEAR_DURATION = 500; + public const float SHADOW_OPACITY = 0.2f; private const Easing easing_show = Easing.OutSine; private const Easing easing_hide = Easing.InSine; diff --git a/osu.Game/Graphics/ScreenshotManager.cs b/osu.Game/Graphics/ScreenshotManager.cs index d799e82bc9..82f89d6889 100644 --- a/osu.Game/Graphics/ScreenshotManager.cs +++ b/osu.Game/Graphics/ScreenshotManager.cs @@ -19,6 +19,7 @@ using osu.Framework.Platform; using osu.Framework.Threading; using osu.Game.Configuration; using osu.Game.Input.Bindings; +using osu.Game.Online.Multiplayer; using osu.Game.Overlays; using osu.Game.Overlays.Notifications; using SixLabors.ImageSharp; @@ -69,7 +70,7 @@ namespace osu.Game.Graphics { case GlobalAction.TakeScreenshot: shutter.Play(); - TakeScreenshotAsync(); + TakeScreenshotAsync().FireAndForget(); return true; } @@ -86,70 +87,75 @@ namespace osu.Game.Graphics { Interlocked.Increment(ref screenShotTasks); - if (!captureMenuCursor.Value) + try { - cursorVisibility.Value = false; - - // We need to wait for at most 3 draw nodes to be drawn, following which we can be assured at least one DrawNode has been generated/drawn with the set value - const int frames_to_wait = 3; - - int framesWaited = 0; - - using (var framesWaitedEvent = new ManualResetEventSlim(false)) + if (!captureMenuCursor.Value) { - ScheduledDelegate waitDelegate = host.DrawThread.Scheduler.AddDelayed(() => + cursorVisibility.Value = false; + + // We need to wait for at most 3 draw nodes to be drawn, following which we can be assured at least one DrawNode has been generated/drawn with the set value + const int frames_to_wait = 3; + + int framesWaited = 0; + + using (var framesWaitedEvent = new ManualResetEventSlim(false)) { - if (framesWaited++ >= frames_to_wait) - // ReSharper disable once AccessToDisposedClosure - framesWaitedEvent.Set(); - }, 10, true); + ScheduledDelegate waitDelegate = host.DrawThread.Scheduler.AddDelayed(() => + { + if (framesWaited++ >= frames_to_wait) + // ReSharper disable once AccessToDisposedClosure + framesWaitedEvent.Set(); + }, 10, true); - if (!framesWaitedEvent.Wait(1000)) - throw new TimeoutException("Screenshot data did not arrive in a timely fashion"); + if (!framesWaitedEvent.Wait(1000)) + throw new TimeoutException("Screenshot data did not arrive in a timely fashion"); - waitDelegate.Cancel(); + waitDelegate.Cancel(); + } + } + + using (var image = await host.TakeScreenshotAsync().ConfigureAwait(false)) + { + host.GetClipboard()?.SetImage(image); + + (string filename, var stream) = getWritableStream(); + + if (filename == null) return; + + using (stream) + { + switch (screenshotFormat.Value) + { + case ScreenshotFormat.Png: + await image.SaveAsPngAsync(stream).ConfigureAwait(false); + break; + + case ScreenshotFormat.Jpg: + const int jpeg_quality = 92; + + await image.SaveAsJpegAsync(stream, new JpegEncoder { Quality = jpeg_quality }).ConfigureAwait(false); + break; + + default: + throw new InvalidOperationException($"Unknown enum member {nameof(ScreenshotFormat)} {screenshotFormat.Value}."); + } + } + + notificationOverlay.Post(new SimpleNotification + { + Text = $"Screenshot {filename} saved!", + Activated = () => + { + storage.PresentFileExternally(filename); + return true; + } + }); } } - - using (var image = await host.TakeScreenshotAsync().ConfigureAwait(false)) + finally { - if (Interlocked.Decrement(ref screenShotTasks) == 0 && cursorVisibility.Value == false) + if (Interlocked.Decrement(ref screenShotTasks) == 0) cursorVisibility.Value = true; - - host.GetClipboard()?.SetImage(image); - - (string filename, var stream) = getWritableStream(); - - if (filename == null) return; - - using (stream) - { - switch (screenshotFormat.Value) - { - case ScreenshotFormat.Png: - await image.SaveAsPngAsync(stream).ConfigureAwait(false); - break; - - case ScreenshotFormat.Jpg: - const int jpeg_quality = 92; - - await image.SaveAsJpegAsync(stream, new JpegEncoder { Quality = jpeg_quality }).ConfigureAwait(false); - break; - - default: - throw new InvalidOperationException($"Unknown enum member {nameof(ScreenshotFormat)} {screenshotFormat.Value}."); - } - } - - notificationOverlay.Post(new SimpleNotification - { - Text = $"Screenshot {filename} saved!", - Activated = () => - { - storage.PresentFileExternally(filename); - return true; - } - }); } }); diff --git a/osu.Game/Graphics/Sprites/OsuSpriteText.cs b/osu.Game/Graphics/Sprites/OsuSpriteText.cs index e149e0abfb..afbec0eab4 100644 --- a/osu.Game/Graphics/Sprites/OsuSpriteText.cs +++ b/osu.Game/Graphics/Sprites/OsuSpriteText.cs @@ -3,12 +3,19 @@ #nullable disable +using System; using osu.Framework.Graphics.Sprites; namespace osu.Game.Graphics.Sprites { public partial class OsuSpriteText : SpriteText { + [Obsolete("Use TruncatingSpriteText instead.")] + public new bool Truncate + { + set => throw new InvalidOperationException($"Use {nameof(TruncatingSpriteText)} instead."); + } + public OsuSpriteText() { Shadow = true; diff --git a/osu.Game/Graphics/Sprites/TruncatingSpriteText.cs b/osu.Game/Graphics/Sprites/TruncatingSpriteText.cs new file mode 100644 index 0000000000..46abdbf09e --- /dev/null +++ b/osu.Game/Graphics/Sprites/TruncatingSpriteText.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.Graphics.Cursor; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; + +namespace osu.Game.Graphics.Sprites +{ + /// + /// A derived version of which automatically shows non-truncated text in tooltip when required. + /// + public sealed partial class TruncatingSpriteText : OsuSpriteText, IHasTooltip + { + /// + /// Whether a tooltip should be shown with non-truncated text on hover. + /// + public bool ShowTooltip { get; init; } = true; + + public LocalisableString TooltipText => Text; + + public override bool HandlePositionalInput => IsTruncated && ShowTooltip; + + public TruncatingSpriteText() + { + ((SpriteText)this).Truncate = true; + } + } +} diff --git a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs index ad02e3b2ab..eb046932e6 100644 --- a/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs +++ b/osu.Game/Graphics/UserInterface/DrawableOsuMenuItem.cs @@ -77,10 +77,10 @@ namespace osu.Game.Graphics.UserInterface private void updateState() { - hoverClickSounds.Enabled.Value = !Item.Action.Disabled; - Alpha = Item.Action.Disabled ? 0.2f : 1; + hoverClickSounds.Enabled.Value = IsActionable; + Alpha = IsActionable ? 1 : 0.2f; - if (IsHovered && !Item.Action.Disabled) + if (IsHovered && IsActionable) { text.BoldText.FadeIn(transition_length, Easing.OutQuint); text.NormalText.FadeOut(transition_length, Easing.OutQuint); diff --git a/osu.Game/Graphics/UserInterface/FPSCounter.cs b/osu.Game/Graphics/UserInterface/FPSCounter.cs index 9dbeba6449..c1ef573848 100644 --- a/osu.Game/Graphics/UserInterface/FPSCounter.cs +++ b/osu.Game/Graphics/UserInterface/FPSCounter.cs @@ -167,9 +167,12 @@ namespace osu.Game.Graphics.UserInterface { base.Update(); + double elapsedDrawFrameTime = drawClock.ElapsedFrameTime; + double elapsedUpdateFrameTime = updateClock.ElapsedFrameTime; + // If the game goes into a suspended state (ie. debugger attached or backgrounded on a mobile device) // we want to ignore really long periods of no processing. - if (updateClock.ElapsedFrameTime > 10000) + if (elapsedUpdateFrameTime > 10000) return; mainContent.Width = Math.Max(mainContent.Width, counters.DrawWidth); @@ -178,17 +181,17 @@ namespace osu.Game.Graphics.UserInterface // frame limiter (we want to show the FPS as it's changing, even if it isn't an outlier). bool aimRatesChanged = updateAimFPS(); - bool hasUpdateSpike = displayedFrameTime < spike_time_ms && updateClock.ElapsedFrameTime > spike_time_ms; + bool hasUpdateSpike = displayedFrameTime < spike_time_ms && elapsedUpdateFrameTime > spike_time_ms; // use elapsed frame time rather then FramesPerSecond to better catch stutter frames. - bool hasDrawSpike = displayedFpsCount > (1000 / spike_time_ms) && drawClock.ElapsedFrameTime > spike_time_ms; + bool hasDrawSpike = displayedFpsCount > (1000 / spike_time_ms) && elapsedDrawFrameTime > spike_time_ms; const float damp_time = 100; - displayedFrameTime = Interpolation.DampContinuously(displayedFrameTime, updateClock.ElapsedFrameTime, hasUpdateSpike ? 0 : damp_time, updateClock.ElapsedFrameTime); + displayedFrameTime = Interpolation.DampContinuously(displayedFrameTime, elapsedUpdateFrameTime, hasUpdateSpike ? 0 : damp_time, elapsedUpdateFrameTime); if (hasDrawSpike) // show spike time using raw elapsed value, to account for `FramesPerSecond` being so averaged spike frames don't show. - displayedFpsCount = 1000 / drawClock.ElapsedFrameTime; + displayedFpsCount = 1000 / elapsedDrawFrameTime; else displayedFpsCount = Interpolation.DampContinuously(displayedFpsCount, drawClock.FramesPerSecond, damp_time, Time.Elapsed); diff --git a/osu.Game/Graphics/UserInterface/MenuItemType.cs b/osu.Game/Graphics/UserInterface/MenuItemType.cs index 1eb45d6b1c..0269f2cb57 100644 --- a/osu.Game/Graphics/UserInterface/MenuItemType.cs +++ b/osu.Game/Graphics/UserInterface/MenuItemType.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Graphics.UserInterface { public enum MenuItemType diff --git a/osu.Game/Graphics/UserInterface/OsuDropdown.cs b/osu.Game/Graphics/UserInterface/OsuDropdown.cs index 3230bb0569..b530172f3e 100644 --- a/osu.Game/Graphics/UserInterface/OsuDropdown.cs +++ b/osu.Game/Graphics/UserInterface/OsuDropdown.cs @@ -335,12 +335,11 @@ namespace osu.Game.Graphics.UserInterface { new Drawable[] { - Text = new OsuSpriteText + Text = new TruncatingSpriteText { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, RelativeSizeAxes = Axes.X, - Truncate = true, }, Icon = new SpriteIcon { diff --git a/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs b/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs index 63c98d7838..9de9eceb07 100644 --- a/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuPasswordTextBox.cs @@ -16,6 +16,7 @@ using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Framework.Platform; +using osu.Game.Localisation; namespace osu.Game.Graphics.UserInterface { @@ -112,7 +113,7 @@ namespace osu.Game.Graphics.UserInterface private partial class CapsWarning : SpriteIcon, IHasTooltip { - public LocalisableString TooltipText => "caps lock is active"; + public LocalisableString TooltipText => CommonStrings.CapsLockIsActive; public CapsWarning() { diff --git a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs index 0e26029ffa..e5f5f97eb7 100644 --- a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs @@ -89,14 +89,16 @@ namespace osu.Game.Graphics.UserInterface double floatValue = value.ToDouble(NumberFormatInfo.InvariantInfo); - if (DisplayAsPercentage) - return floatValue.ToString("0%"); - decimal decimalPrecision = normalise(CurrentNumber.Precision.ToDecimal(NumberFormatInfo.InvariantInfo), max_decimal_digits); // Find the number of significant digits (we could have less than 5 after normalize()) int significantDigits = FormatUtils.FindPrecision(decimalPrecision); + if (DisplayAsPercentage) + { + return floatValue.ToString($@"P{Math.Max(0, significantDigits - 2)}"); + } + string negativeSign = Math.Round(floatValue, significantDigits) < 0 ? "-" : string.Empty; return $"{negativeSign}{Math.Abs(floatValue).ToString($"N{significantDigits}")}"; diff --git a/osu.Game/Graphics/UserInterface/SelectionState.cs b/osu.Game/Graphics/UserInterface/SelectionState.cs index edabf0547b..c85b2ad3ab 100644 --- a/osu.Game/Graphics/UserInterface/SelectionState.cs +++ b/osu.Game/Graphics/UserInterface/SelectionState.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Graphics.UserInterface { public enum SelectionState diff --git a/osu.Game/Graphics/UserInterface/ShearedSearchTextBox.cs b/osu.Game/Graphics/UserInterface/ShearedSearchTextBox.cs index 7bd083f9d5..fb0a66cb8d 100644 --- a/osu.Game/Graphics/UserInterface/ShearedSearchTextBox.cs +++ b/osu.Game/Graphics/UserInterface/ShearedSearchTextBox.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; using osu.Game.Overlays; using osu.Game.Overlays.Mods; @@ -37,6 +38,14 @@ namespace osu.Game.Graphics.UserInterface set => textBox.HoldFocus = value; } + public LocalisableString PlaceholderText + { + get => textBox.PlaceholderText; + set => textBox.PlaceholderText = value; + } + + public new bool HasFocus => textBox.HasFocus; + public void TakeFocus() => textBox.TakeFocus(); public void KillFocus() => textBox.KillFocus(); diff --git a/osu.Game/Graphics/UserInterface/TernaryState.cs b/osu.Game/Graphics/UserInterface/TernaryState.cs index effbe624c3..d4de28044f 100644 --- a/osu.Game/Graphics/UserInterface/TernaryState.cs +++ b/osu.Game/Graphics/UserInterface/TernaryState.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Graphics.UserInterface { /// diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs index 9b7087ce6d..16bad5785f 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs @@ -152,7 +152,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 [BackgroundDependencyLoader(true)] private void load(OverlayColourProvider? colourProvider, OsuColour osuColour) { - background.Colour = colourProvider?.Background5 ?? Color4Extensions.FromHex(@"1c2125"); + background.Colour = colourProvider?.Background4 ?? Color4Extensions.FromHex(@"1c2125"); descriptionText.Colour = osuColour.Yellow; } diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index d580eea248..fdd96d3890 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -101,6 +101,10 @@ namespace osu.Game.Input.Bindings new KeyBinding(new[] { InputKey.Control, InputKey.J }, GlobalAction.EditorFlipVertically), new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.MouseWheelDown }, GlobalAction.EditorDecreaseDistanceSpacing), new KeyBinding(new[] { InputKey.Control, InputKey.Alt, InputKey.MouseWheelUp }, GlobalAction.EditorIncreaseDistanceSpacing), + // Framework automatically converts wheel up/down to left/right when shift is held. + // See https://github.com/ppy/osu-framework/blob/master/osu.Framework/Input/StateChanges/MouseScrollRelativeInput.cs#L37-L38. + new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.MouseWheelRight }, GlobalAction.EditorCyclePreviousBeatSnapDivisor), + new KeyBinding(new[] { InputKey.Control, InputKey.Shift, InputKey.MouseWheelLeft }, GlobalAction.EditorCycleNextBeatSnapDivisor), }; public IEnumerable InGameKeyBindings => new[] @@ -355,6 +359,12 @@ namespace osu.Game.Input.Bindings ToggleProfile, [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorCloneSelection))] - EditorCloneSelection + EditorCloneSelection, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorCyclePreviousBeatSnapDivisor))] + EditorCyclePreviousBeatSnapDivisor, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.EditorCycleNextBeatSnapDivisor))] + EditorCycleNextBeatSnapDivisor, } } diff --git a/osu.Game/Input/OsuUserInputManager.cs b/osu.Game/Input/OsuUserInputManager.cs index ab43497156..c205636ab9 100644 --- a/osu.Game/Input/OsuUserInputManager.cs +++ b/osu.Game/Input/OsuUserInputManager.cs @@ -3,6 +3,7 @@ #nullable disable +using osu.Framework.Bindables; using osu.Framework.Input; using osuTK.Input; @@ -10,6 +11,10 @@ namespace osu.Game.Input { public partial class OsuUserInputManager : UserInputManager { + protected override bool AllowRightClickFromLongTouch => !LocalUserPlaying.Value; + + public readonly BindableBool LocalUserPlaying = new BindableBool(); + internal OsuUserInputManager() { } diff --git a/osu.Game/Localisation/AccountCreationStrings.cs b/osu.Game/Localisation/AccountCreationStrings.cs new file mode 100644 index 0000000000..2183df9b52 --- /dev/null +++ b/osu.Game/Localisation/AccountCreationStrings.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 osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class AccountCreationStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.AccountCreation"; + + /// + /// "New player registration" + /// + public static LocalisableString NewPlayerRegistration => new TranslatableString(getKey(@"new_player_registration"), @"New player registration"); + + /// + /// "Let's get you started" + /// + public static LocalisableString LetsGetYouStarted => new TranslatableString(getKey(@"lets_get_you_started"), @"Let's get you started"); + + /// + /// "Let's create an account!" + /// + public static LocalisableString LetsCreateAnAccount => new TranslatableString(getKey(@"lets_create_an_account"), @"Let's create an account!"); + + /// + /// "Help, I can't access my account!" + /// + public static LocalisableString MultiAccountWarningHelp => new TranslatableString(getKey(@"multi_account_warning_help"), @"Help, I can't access my account!"); + + /// + /// "I understand. This account isn't for me." + /// + public static LocalisableString MultiAccountWarningAccept => new TranslatableString(getKey(@"multi_account_warning_accept"), @"I understand. This account isn't for me."); + + /// + /// "This will be your public presence. No profanity, no impersonation. Avoid exposing your own personal details, too!" + /// + public static LocalisableString UsernameDescription => new TranslatableString(getKey(@"username_description"), @"This will be your public presence. No profanity, no impersonation. Avoid exposing your own personal details, too!"); + + /// + /// "Will be used for notifications, account verification and in the case you forget your password. No spam, ever." + /// + public static LocalisableString EmailDescription1 => new TranslatableString(getKey(@"email_description_1"), @"Will be used for notifications, account verification and in the case you forget your password. No spam, ever."); + + /// + /// " Make sure to get it right!" + /// + public static LocalisableString EmailDescription2 => new TranslatableString(getKey(@"email_description_2"), @" Make sure to get it right!"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/CommonStrings.cs b/osu.Game/Localisation/CommonStrings.cs index fed7b6cab7..c9223db246 100644 --- a/osu.Game/Localisation/CommonStrings.cs +++ b/osu.Game/Localisation/CommonStrings.cs @@ -154,6 +154,11 @@ namespace osu.Game.Localisation /// public static LocalisableString Exit => new TranslatableString(getKey(@"exit"), @"Exit"); + /// + /// "Caps lock is active" + /// + public static LocalisableString CapsLockIsActive => new TranslatableString(getKey(@"caps_lock_is_active"), @"Caps lock is active"); + /// /// "Revert to default" /// diff --git a/osu.Game/Localisation/EditorStrings.cs b/osu.Game/Localisation/EditorStrings.cs index 20258b9c35..077bd92d4f 100644 --- a/osu.Game/Localisation/EditorStrings.cs +++ b/osu.Game/Localisation/EditorStrings.cs @@ -109,6 +109,11 @@ namespace osu.Game.Localisation /// public static LocalisableString RotationSnapped(float newRotation) => new TranslatableString(getKey(@"rotation_snapped"), @"{0:0}° (snapped)", newRotation); + /// + /// "Limit distance snap placement to current time" + /// + public static LocalisableString LimitedDistanceSnap => new TranslatableString(getKey(@"limited_distance_snap_grid"), @"Limit distance snap placement to current time"); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/GameplayMenuOverlayStrings.cs b/osu.Game/Localisation/GameplayMenuOverlayStrings.cs new file mode 100644 index 0000000000..f1a65ab430 --- /dev/null +++ b/osu.Game/Localisation/GameplayMenuOverlayStrings.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class GameplayMenuOverlayStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.GameplayMenuOverlay"; + + /// + /// "Continue" + /// + public static LocalisableString Continue => new TranslatableString(getKey(@"continue"), @"Continue"); + + /// + /// "Retry" + /// + public static LocalisableString Retry => new TranslatableString(getKey(@"retry"), @"Retry"); + + /// + /// "Quit" + /// + public static LocalisableString Quit => new TranslatableString(getKey(@"quit"), @"Quit"); + + /// + /// "failed" + /// + public static LocalisableString FailedHeader => new TranslatableString(getKey(@"failed_header"), @"failed"); + + /// + /// "paused" + /// + public static LocalisableString PausedHeader => new TranslatableString(getKey(@"paused_header"), @"paused"); + + /// + /// "Retry count: " + /// + public static LocalisableString RetryCount => new TranslatableString(getKey(@"retry_count"), @"Retry count: "); + + /// + /// "Song progress: " + /// + public static LocalisableString SongProgress => new TranslatableString(getKey(@"song_progress"), @"Song progress: "); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} \ No newline at end of file diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs index 303dbb6f46..aa608a603b 100644 --- a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs +++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs @@ -279,6 +279,16 @@ namespace osu.Game.Localisation /// public static LocalisableString EditorDecreaseDistanceSpacing => new TranslatableString(getKey(@"editor_decrease_distance_spacing"), @"Decrease distance spacing"); + /// + /// "Cycle previous beat snap divisor" + /// + public static LocalisableString EditorCyclePreviousBeatSnapDivisor => new TranslatableString(getKey(@"editor_cycle_previous_beat_snap_divisor"), @"Cycle previous beat snap divisor"); + + /// + /// "Cycle next beat snap divisor" + /// + public static LocalisableString EditorCycleNextBeatSnapDivisor => new TranslatableString(getKey(@"editor_cycle_next_snap_divisor"), @"Cycle next beat snap divisor"); + /// /// "Toggle skin editor" /// diff --git a/osu.Game/Localisation/Language.cs b/osu.Game/Localisation/Language.cs index 6a4e5110e6..711e95486f 100644 --- a/osu.Game/Localisation/Language.cs +++ b/osu.Game/Localisation/Language.cs @@ -22,6 +22,9 @@ namespace osu.Game.Localisation [Description(@"Български")] bg, + [Description(@"Català")] + ca, + [Description(@"Česky")] cs, @@ -37,12 +40,27 @@ namespace osu.Game.Localisation [Description(@"español")] es, + // TODO: Requires Arabic glyphs to be added to resources (and possibly also RTL support). + // [Description(@"فارسی")] + // fa_ir, + [Description(@"Suomi")] fi, + // TODO: Doesn't work as appropriate satellite assemblies aren't copied from resources (see: https://github.com/ppy/osu/discussions/18851#discussioncomment-3042170) + // [Description(@"Filipino")] + // fil, + [Description(@"français")] fr, + // TODO: Requires Hebrew glyphs to be added to resources (and possibly also RTL support). + // [Description(@"עברית")] + // he, + + [Description(@"Hrvatski")] + hr_hr, + [Description(@"Magyar")] hu, @@ -58,6 +76,15 @@ namespace osu.Game.Localisation [Description(@"한국어")] ko, + [Description(@"Lietuvių")] + lt, + + [Description(@"Latviešu")] + lv_lv, + + [Description(@"Melayu")] + ms_my, + [Description(@"Nederlands")] nl, @@ -79,12 +106,28 @@ namespace osu.Game.Localisation [Description(@"Русский")] ru, + // TODO: Requires Sinhala glyphs to be added to resources. + // Additionally, no translations available yet. + // [Description(@"සිංහල")] + // si_lk, + [Description(@"Slovenčina")] sk, + [Description(@"Slovenščina")] + sl, + + [Description(@"Српски")] + sr, + [Description(@"Svenska")] sv, + // Tajik has no associated localisations yet, and is not supported on Windows versions <10. + // TODO: update language mapping in osu-resources to redirect tg-TJ to tg-Cyrl-TJ (which is supported on earlier Windows versions) + // [Description(@"Тоҷикӣ")] + // tg_tj, + [Description(@"ไทย")] th, diff --git a/osu.Game/Localisation/LoginPanelStrings.cs b/osu.Game/Localisation/LoginPanelStrings.cs new file mode 100644 index 0000000000..19b0ca3b52 --- /dev/null +++ b/osu.Game/Localisation/LoginPanelStrings.cs @@ -0,0 +1,49 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class LoginPanelStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.LoginPanel"; + + /// + /// "Do not disturb" + /// + public static LocalisableString DoNotDisturb => new TranslatableString(getKey(@"do_not_disturb"), @"Do not disturb"); + + /// + /// "Appear offline" + /// + public static LocalisableString AppearOffline => new TranslatableString(getKey(@"appear_offline"), @"Appear offline"); + + /// + /// "Signed in" + /// + public static LocalisableString SignedIn => new TranslatableString(getKey(@"signed_in"), @"Signed in"); + + /// + /// "Account" + /// + public static LocalisableString Account => new TranslatableString(getKey(@"account"), @"Account"); + + /// + /// "Remember username" + /// + public static LocalisableString RememberUsername => new TranslatableString(getKey(@"remember_username"), @"Remember username"); + + /// + /// "Stay signed in" + /// + public static LocalisableString StaySignedIn => new TranslatableString(getKey(@"stay_signed_in"), @"Stay signed in"); + + /// + /// "Register" + /// + public static LocalisableString Register => new TranslatableString(getKey(@"register"), @"Register"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/ModSelectOverlayStrings.cs b/osu.Game/Localisation/ModSelectOverlayStrings.cs index f11c52ee20..05dcf138d7 100644 --- a/osu.Game/Localisation/ModSelectOverlayStrings.cs +++ b/osu.Game/Localisation/ModSelectOverlayStrings.cs @@ -1,4 +1,4 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using osu.Framework.Localisation; @@ -39,6 +39,11 @@ namespace osu.Game.Localisation /// public static LocalisableString UseCurrentMods => new TranslatableString(getKey(@"use_current_mods"), @"Use current mods"); + /// + /// "tab to search..." + /// + public static LocalisableString TabToSearch => new TranslatableString(getKey(@"tab_to_search"), @"tab to search..."); + private static string getKey(string key) => $@"{prefix}:{key}"; } -} +} \ No newline at end of file diff --git a/osu.Game/Localisation/RulesetSettingsStrings.cs b/osu.Game/Localisation/RulesetSettingsStrings.cs index 52e6a5eaac..3fa7656cbb 100644 --- a/osu.Game/Localisation/RulesetSettingsStrings.cs +++ b/osu.Game/Localisation/RulesetSettingsStrings.cs @@ -82,7 +82,7 @@ namespace osu.Game.Localisation /// /// "{0}ms (speed {1})" /// - public static LocalisableString ScrollSpeedTooltip(double arg0, int arg1) => new TranslatableString(getKey(@"ruleset"), @"{0}ms (speed {1})", arg0, arg1); + public static LocalisableString ScrollSpeedTooltip(int scrollTime, int scrollSpeed) => new TranslatableString(getKey(@"ruleset"), @"{0}ms (speed {1})", scrollTime, scrollSpeed); private static string getKey(string key) => $@"{prefix}:{key}"; } diff --git a/osu.Game/Online/API/APIAccess.cs b/osu.Game/Online/API/APIAccess.cs index 94bb77d6ec..4f586c8fff 100644 --- a/osu.Game/Online/API/APIAccess.cs +++ b/osu.Game/Online/API/APIAccess.cs @@ -18,6 +18,7 @@ using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Game.Configuration; +using osu.Game.Localisation; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Notifications; @@ -28,6 +29,7 @@ namespace osu.Game.Online.API { public partial class APIAccess : Component, IAPIProvider { + private readonly OsuGameBase game; private readonly OsuConfigManager config; private readonly string versionHash; @@ -52,6 +54,8 @@ namespace osu.Game.Online.API public IBindableList Friends => friends; public IBindable Activity => activity; + public Language Language => game.CurrentLanguage.Value; + private Bindable localUser { get; } = new Bindable(createGuestUser()); private BindableList friends { get; } = new BindableList(); @@ -64,8 +68,9 @@ namespace osu.Game.Online.API private readonly Logger log; - public APIAccess(OsuConfigManager config, EndpointConfiguration endpointConfiguration, string versionHash) + public APIAccess(OsuGameBase game, OsuConfigManager config, EndpointConfiguration endpointConfiguration, string versionHash) { + this.game = game; this.config = config; this.versionHash = versionHash; diff --git a/osu.Game/Online/API/APIRequest.cs b/osu.Game/Online/API/APIRequest.cs index dc6a3fe3d5..cd6e8df754 100644 --- a/osu.Game/Online/API/APIRequest.cs +++ b/osu.Game/Online/API/APIRequest.cs @@ -9,6 +9,7 @@ using JetBrains.Annotations; using Newtonsoft.Json; using osu.Framework.IO.Network; using osu.Framework.Logging; +using osu.Game.Extensions; using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Online.API @@ -116,10 +117,11 @@ namespace osu.Game.Online.API WebRequest.Failed += Fail; WebRequest.AllowRetryOnTimeout = false; - WebRequest.AddHeader("x-api-version", API.APIVersion.ToString(CultureInfo.InvariantCulture)); + WebRequest.AddHeader(@"Accept-Language", API.Language.ToCultureCode()); + WebRequest.AddHeader(@"x-api-version", API.APIVersion.ToString(CultureInfo.InvariantCulture)); if (!string.IsNullOrEmpty(API.AccessToken)) - WebRequest.AddHeader("Authorization", $"Bearer {API.AccessToken}"); + WebRequest.AddHeader(@"Authorization", $@"Bearer {API.AccessToken}"); if (isFailing) return; diff --git a/osu.Game/Online/API/APIRequestCompletionState.cs b/osu.Game/Online/API/APIRequestCompletionState.cs index 52eb669a7d..84c9974dd8 100644 --- a/osu.Game/Online/API/APIRequestCompletionState.cs +++ b/osu.Game/Online/API/APIRequestCompletionState.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Online.API { public enum APIRequestCompletionState diff --git a/osu.Game/Online/API/DummyAPIAccess.cs b/osu.Game/Online/API/DummyAPIAccess.cs index abe2755654..16afef8e30 100644 --- a/osu.Game/Online/API/DummyAPIAccess.cs +++ b/osu.Game/Online/API/DummyAPIAccess.cs @@ -8,6 +8,7 @@ using System.Threading; using System.Threading.Tasks; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Game.Localisation; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Notifications; using osu.Game.Tests; @@ -29,6 +30,8 @@ namespace osu.Game.Online.API public Bindable Activity { get; } = new Bindable(); + public Language Language => Language.en; + public string AccessToken => "token"; public bool IsLoggedIn => State.Value == APIState.Online; diff --git a/osu.Game/Online/API/IAPIProvider.cs b/osu.Game/Online/API/IAPIProvider.cs index 6054effaa1..a1d7006c8c 100644 --- a/osu.Game/Online/API/IAPIProvider.cs +++ b/osu.Game/Online/API/IAPIProvider.cs @@ -4,6 +4,7 @@ using System; using System.Threading.Tasks; using osu.Framework.Bindables; +using osu.Game.Localisation; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Notifications; using osu.Game.Users; @@ -27,6 +28,11 @@ namespace osu.Game.Online.API /// IBindable Activity { get; } + /// + /// The language supplied by this provider to API requests. + /// + Language Language { get; } + /// /// Retrieve the OAuth access token. /// diff --git a/osu.Game/Online/API/Requests/GetUsersRequest.cs b/osu.Game/Online/API/Requests/GetUsersRequest.cs index b57bb215aa..6f7e9c07d2 100644 --- a/osu.Game/Online/API/Requests/GetUsersRequest.cs +++ b/osu.Game/Online/API/Requests/GetUsersRequest.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; namespace osu.Game.Online.API.Requests diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs index 7d6740ee46..902b651be9 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs @@ -63,6 +63,16 @@ namespace osu.Game.Online.API.Requests.Responses set => Length = TimeSpan.FromSeconds(value).TotalMilliseconds; } + [JsonIgnore] + public double HitLength { get; set; } + + [JsonProperty(@"hit_length")] + private double hitLengthInSeconds + { + get => TimeSpan.FromMilliseconds(HitLength).TotalSeconds; + set => HitLength = TimeSpan.FromSeconds(value).TotalMilliseconds; + } + [JsonProperty(@"convert")] public bool Convert { get; set; } diff --git a/osu.Game/Online/Chat/ChannelType.cs b/osu.Game/Online/Chat/ChannelType.cs index a864e20830..bd628e90c4 100644 --- a/osu.Game/Online/Chat/ChannelType.cs +++ b/osu.Game/Online/Chat/ChannelType.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Online.Chat { public enum ChannelType diff --git a/osu.Game/Online/Chat/MessageNotifier.cs b/osu.Game/Online/Chat/MessageNotifier.cs index 9b2ad666b2..52bdd36169 100644 --- a/osu.Game/Online/Chat/MessageNotifier.cs +++ b/osu.Game/Online/Chat/MessageNotifier.cs @@ -12,6 +12,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; +using osu.Framework.Platform; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Online.API; @@ -35,6 +36,9 @@ namespace osu.Game.Online.Chat [Resolved] private ChannelManager channelManager { get; set; } + [Resolved] + private GameHost host { get; set; } + private Bindable notifyOnUsername; private Bindable notifyOnPrivateMessage; @@ -89,8 +93,8 @@ namespace osu.Game.Online.Chat if (channel == null) return; - // Only send notifications, if ChatOverlay and the target channel aren't visible. - if (chatOverlay.IsPresent && channelManager.CurrentChannel.Value == channel) + // Only send notifications if ChatOverlay or the target channel aren't visible, or if the window is unfocused + if (chatOverlay.IsPresent && channelManager.CurrentChannel.Value == channel && host.IsActive.Value) return; foreach (var message in messages.OrderByDescending(m => m.Id)) @@ -99,6 +103,7 @@ namespace osu.Game.Online.Chat if (message.Id <= channel.LastReadId) return; + // ignore notifications triggered by local user's own chat messages if (message.Sender.Id == localUser.Value.Id) continue; diff --git a/osu.Game/Online/Leaderboards/LeaderboardState.cs b/osu.Game/Online/Leaderboards/LeaderboardState.cs index abc0ef4f19..6b07500a98 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardState.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardState.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Online.Leaderboards { public enum LeaderboardState diff --git a/osu.Game/Online/Multiplayer/ForceGameplayStartCountdown.cs b/osu.Game/Online/Multiplayer/ForceGameplayStartCountdown.cs index bbfc5a02c6..c497601e37 100644 --- a/osu.Game/Online/Multiplayer/ForceGameplayStartCountdown.cs +++ b/osu.Game/Online/Multiplayer/ForceGameplayStartCountdown.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using MessagePack; namespace osu.Game.Online.Multiplayer diff --git a/osu.Game/Online/Multiplayer/IMultiplayerServer.cs b/osu.Game/Online/Multiplayer/IMultiplayerServer.cs index cc7a474ce7..d3a070af6d 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerServer.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerServer.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Online.Multiplayer { /// diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 2be7327234..5716b7ad3b 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -783,7 +783,7 @@ namespace osu.Game.Online.Multiplayer RoomUpdated?.Invoke(); } - private PlaylistItem createPlaylistItem(MultiplayerPlaylistItem item) => new PlaylistItem(new APIBeatmap { OnlineID = item.BeatmapID }) + private PlaylistItem createPlaylistItem(MultiplayerPlaylistItem item) => new PlaylistItem(new APIBeatmap { OnlineID = item.BeatmapID, StarRating = item.StarRating }) { ID = item.ID, OwnerID = item.OwnerID, diff --git a/osu.Game/Online/Multiplayer/MultiplayerUserState.cs b/osu.Game/Online/Multiplayer/MultiplayerUserState.cs index 0f7dc6b8cd..d1369a7970 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerUserState.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerUserState.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Online.Multiplayer { public enum MultiplayerUserState diff --git a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs index c45f703b05..8be703e620 100644 --- a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs +++ b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs @@ -53,22 +53,12 @@ namespace osu.Game.Online.Rooms [Key(9)] public DateTimeOffset? PlayedAt { get; set; } + [Key(10)] + public double StarRating { get; set; } + + [SerializationConstructor] public MultiplayerPlaylistItem() { } - - public MultiplayerPlaylistItem(PlaylistItem item) - { - ID = item.ID; - OwnerID = item.OwnerID; - BeatmapID = item.Beatmap.OnlineID; - BeatmapChecksum = item.Beatmap.MD5Hash; - RulesetID = item.RulesetID; - RequiredMods = item.RequiredMods.ToArray(); - AllowedMods = item.AllowedMods.ToArray(); - Expired = item.Expired; - PlaylistOrder = item.PlaylistOrder ?? 0; - PlayedAt = item.PlayedAt; - } } } diff --git a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs index 1d496cc636..cce633d46a 100644 --- a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs @@ -67,7 +67,7 @@ namespace osu.Game.Online.Rooms { var beatmap = task.GetResultSafely(); - if (SelectedItem.Value?.Beatmap.OnlineID == beatmap.OnlineID) + if (beatmap != null && SelectedItem.Value?.Beatmap.OnlineID == beatmap.OnlineID) { selectedBeatmap = beatmap; beginTracking(); diff --git a/osu.Game/Online/Rooms/PlaylistItem.cs b/osu.Game/Online/Rooms/PlaylistItem.cs index 2213311c67..a900d8f3d7 100644 --- a/osu.Game/Online/Rooms/PlaylistItem.cs +++ b/osu.Game/Online/Rooms/PlaylistItem.cs @@ -91,7 +91,7 @@ namespace osu.Game.Online.Rooms } public PlaylistItem(MultiplayerPlaylistItem item) - : this(new APIBeatmap { OnlineID = item.BeatmapID }) + : this(new APIBeatmap { OnlineID = item.BeatmapID, StarRating = item.StarRating }) { ID = item.ID; OwnerID = item.OwnerID; diff --git a/osu.Game/Online/Spectator/FrameDataBundle.cs b/osu.Game/Online/Spectator/FrameDataBundle.cs index 97ae468875..d58ddd5310 100644 --- a/osu.Game/Online/Spectator/FrameDataBundle.cs +++ b/osu.Game/Online/Spectator/FrameDataBundle.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using MessagePack; using Newtonsoft.Json; using osu.Game.Replays.Legacy; +using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; namespace osu.Game.Online.Spectator @@ -20,10 +21,10 @@ namespace osu.Game.Online.Spectator [Key(1)] public IList Frames { get; set; } - public FrameDataBundle(ScoreInfo score, IList frames) + public FrameDataBundle(ScoreInfo score, ScoreProcessor scoreProcessor, IList frames) { Frames = frames; - Header = new FrameHeader(score); + Header = new FrameHeader(score, scoreProcessor.GetScoreProcessorStatistics()); } [JsonConstructor] diff --git a/osu.Game/Online/Spectator/FrameHeader.cs b/osu.Game/Online/Spectator/FrameHeader.cs index b6dcd8aaa5..45f920e65b 100644 --- a/osu.Game/Online/Spectator/FrameHeader.cs +++ b/osu.Game/Online/Spectator/FrameHeader.cs @@ -15,57 +15,74 @@ namespace osu.Game.Online.Spectator public class FrameHeader { /// - /// The current accuracy of the score. + /// The total score. /// [Key(0)] + public long TotalScore { get; set; } + + /// + /// The current accuracy of the score. + /// + [Key(1)] public double Accuracy { get; set; } /// /// The current combo of the score. /// - [Key(1)] + [Key(2)] public int Combo { get; set; } /// /// The maximum combo achieved up to the current point in time. /// - [Key(2)] + [Key(3)] public int MaxCombo { get; set; } /// /// Cumulative hit statistics. /// - [Key(3)] + [Key(4)] public Dictionary Statistics { get; set; } + /// + /// Additional statistics that guides the score processor to calculate the correct score for this frame. + /// + [Key(5)] + public ScoreProcessorStatistics ScoreProcessorStatistics { get; set; } + /// /// The time at which this frame was received by the server. /// - [Key(4)] + [Key(6)] public DateTimeOffset ReceivedTime { get; set; } /// /// Construct header summary information from a point-in-time reference to a score which is actively being played. /// /// The score for reference. - public FrameHeader(ScoreInfo score) + /// The score processor statistics for the current point in time. + public FrameHeader(ScoreInfo score, ScoreProcessorStatistics statistics) { + TotalScore = score.TotalScore; + Accuracy = score.Accuracy; Combo = score.Combo; MaxCombo = score.MaxCombo; - Accuracy = score.Accuracy; - // copy for safety Statistics = new Dictionary(score.Statistics); + + ScoreProcessorStatistics = statistics; } [JsonConstructor] [SerializationConstructor] - public FrameHeader(double accuracy, int combo, int maxCombo, Dictionary statistics, DateTimeOffset receivedTime) + public FrameHeader(long totalScore, double accuracy, int combo, int maxCombo, Dictionary statistics, ScoreProcessorStatistics scoreProcessorStatistics, DateTimeOffset receivedTime) { + TotalScore = totalScore; + Accuracy = accuracy; Combo = combo; MaxCombo = maxCombo; - Accuracy = accuracy; Statistics = statistics; + ScoreProcessorStatistics = scoreProcessorStatistics; ReceivedTime = receivedTime; } } diff --git a/osu.Game/Online/Spectator/SpectatedUserState.cs b/osu.Game/Online/Spectator/SpectatedUserState.cs index edf0859a33..0f0a3068b8 100644 --- a/osu.Game/Online/Spectator/SpectatedUserState.cs +++ b/osu.Game/Online/Spectator/SpectatedUserState.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Online.Spectator { public enum SpectatedUserState diff --git a/osu.Game/Online/Spectator/SpectatorClient.cs b/osu.Game/Online/Spectator/SpectatorClient.cs index 55ec75f4ce..89da8b9d32 100644 --- a/osu.Game/Online/Spectator/SpectatorClient.cs +++ b/osu.Game/Online/Spectator/SpectatorClient.cs @@ -16,6 +16,7 @@ using osu.Game.Online.API; using osu.Game.Replays.Legacy; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays.Types; +using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play; @@ -82,6 +83,7 @@ namespace osu.Game.Online.Spectator private IBeatmap? currentBeatmap; private Score? currentScore; private long? currentScoreToken; + private ScoreProcessor? currentScoreProcessor; private readonly Queue pendingFrameBundles = new Queue(); @@ -192,6 +194,7 @@ namespace osu.Game.Online.Spectator currentBeatmap = state.Beatmap; currentScore = score; currentScoreToken = scoreToken; + currentScoreProcessor = state.ScoreProcessor; BeginPlayingInternal(currentScoreToken, currentState); }); @@ -302,9 +305,10 @@ namespace osu.Game.Online.Spectator return; Debug.Assert(currentScore != null); + Debug.Assert(currentScoreProcessor != null); var frames = pendingFrames.ToArray(); - var bundle = new FrameDataBundle(currentScore.ScoreInfo, frames); + var bundle = new FrameDataBundle(currentScore.ScoreInfo, currentScoreProcessor, frames); pendingFrames.Clear(); lastPurgeTime = Time.Current; diff --git a/osu.Game/Online/Spectator/SpectatorScoreProcessor.cs b/osu.Game/Online/Spectator/SpectatorScoreProcessor.cs index 1c505ea107..3242e21994 100644 --- a/osu.Game/Online/Spectator/SpectatorScoreProcessor.cs +++ b/osu.Game/Online/Spectator/SpectatorScoreProcessor.cs @@ -14,6 +14,7 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; +using osu.Game.Scoring.Legacy; namespace osu.Game.Online.Spectator { @@ -46,7 +47,9 @@ namespace osu.Game.Online.Spectator /// /// The applied s. /// - public IReadOnlyList Mods => scoreProcessor?.Mods.Value ?? Array.Empty(); + public IReadOnlyList Mods => scoreInfo?.Mods ?? Array.Empty(); + + public Func GetDisplayScore => mode => scoreInfo?.GetDisplayScore(mode) ?? 0; private IClock? referenceClock; @@ -70,7 +73,6 @@ namespace osu.Game.Online.Spectator private readonly int userId; private SpectatorState? spectatorState; - private ScoreProcessor? scoreProcessor; private ScoreInfo? scoreInfo; public SpectatorScoreProcessor(int userId) @@ -94,19 +96,15 @@ namespace osu.Game.Online.Spectator { if (!spectatorStates.TryGetValue(userId, out var userState) || userState.BeatmapID == null || userState.RulesetID == null) { - scoreProcessor?.RemoveAndDisposeImmediately(); - scoreProcessor = null; scoreInfo = null; spectatorState = null; replayFrames.Clear(); return; } - if (scoreProcessor != null) + if (scoreInfo != null) return; - Debug.Assert(scoreInfo == null); - RulesetInfo? rulesetInfo = rulesetStore.GetRuleset(userState.RulesetID.Value); if (rulesetInfo == null) return; @@ -114,9 +112,11 @@ namespace osu.Game.Online.Spectator Ruleset ruleset = rulesetInfo.CreateInstance(); spectatorState = userState; - scoreInfo = new ScoreInfo { Ruleset = rulesetInfo }; - scoreProcessor = ruleset.CreateScoreProcessor(); - scoreProcessor.Mods.Value = userState.Mods.Select(m => m.ToMod(ruleset)).ToArray(); + scoreInfo = new ScoreInfo + { + Ruleset = rulesetInfo, + Mods = userState.Mods.Select(m => m.ToMod(ruleset)).ToArray() + }; } private void onNewFrames(int incomingUserId, FrameDataBundle bundle) @@ -126,7 +126,7 @@ namespace osu.Game.Online.Spectator Schedule(() => { - if (scoreProcessor == null) + if (scoreInfo == null) return; replayFrames.Add(new TimedFrame(bundle.Frames.First().Time, bundle.Header)); @@ -140,7 +140,6 @@ namespace osu.Game.Online.Spectator return; Debug.Assert(spectatorState != null); - Debug.Assert(scoreProcessor != null); int frameIndex = replayFrames.BinarySearch(new TimedFrame(ReferenceClock.CurrentTime)); if (frameIndex < 0) @@ -150,14 +149,15 @@ namespace osu.Game.Online.Spectator TimedFrame frame = replayFrames[frameIndex]; Debug.Assert(frame.Header != null); + scoreInfo.Accuracy = frame.Header.Accuracy; scoreInfo.MaxCombo = frame.Header.MaxCombo; scoreInfo.Statistics = frame.Header.Statistics; scoreInfo.MaximumStatistics = spectatorState.MaximumStatistics; + scoreInfo.TotalScore = frame.Header.TotalScore; Accuracy.Value = frame.Header.Accuracy; Combo.Value = frame.Header.Combo; - - TotalScore.Value = scoreProcessor.ComputeScore(Mode.Value, scoreInfo); + TotalScore.Value = frame.Header.TotalScore; } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 7c9b03bd5b..a80639d4ff 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -28,6 +29,7 @@ using osu.Framework.Input.Events; using osu.Framework.Input.Handlers.Tablet; using osu.Framework.Localisation; using osu.Framework.Logging; +using osu.Framework.Platform; using osu.Framework.Screens; using osu.Framework.Threading; using osu.Game.Beatmaps; @@ -269,11 +271,64 @@ namespace osu.Game if (hideToolbar) Toolbar.Hide(); } + protected override UserInputManager CreateUserInputManager() + { + var userInputManager = base.CreateUserInputManager(); + (userInputManager as OsuUserInputManager)?.LocalUserPlaying.BindTo(LocalUserPlaying); + return userInputManager; + } + private DependencyContainer dependencies; protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + private readonly List dragDropFiles = new List(); + private ScheduledDelegate dragDropImportSchedule; + + public override void SetHost(GameHost host) + { + base.SetHost(host); + + if (host.Window is SDL2Window sdlWindow) + { + sdlWindow.DragDrop += path => + { + // on macOS/iOS, URL associations are handled via SDL_DROPFILE events. + if (path.StartsWith(OSU_PROTOCOL, StringComparison.Ordinal)) + { + HandleLink(path); + return; + } + + lock (dragDropFiles) + { + dragDropFiles.Add(path); + + Logger.Log($@"Adding ""{Path.GetFileName(path)}"" for import"); + + // File drag drop operations can potentially trigger hundreds or thousands of these calls on some platforms. + // In order to avoid spawning multiple import tasks for a single drop operation, debounce a touch. + dragDropImportSchedule?.Cancel(); + dragDropImportSchedule = Scheduler.AddDelayed(handlePendingDragDropImports, 100); + } + }; + } + } + + private void handlePendingDragDropImports() + { + lock (dragDropFiles) + { + Logger.Log($"Handling batch import of {dragDropFiles.Count} files"); + + string[] paths = dragDropFiles.ToArray(); + dragDropFiles.Clear(); + + Task.Factory.StartNew(() => Import(paths), TaskCreationOptions.LongRunning); + } + } + [BackgroundDependencyLoader] private void load() { @@ -1129,12 +1184,22 @@ namespace osu.Game if (entry.Level == LogLevel.Error) { - Schedule(() => Notifications.Post(new SimpleNotification + Schedule(() => { - Text = $"Encountered tablet error: \"{message}\"", - Icon = FontAwesome.Solid.PenSquare, - IconColour = Colours.RedDark, - })); + Notifications.Post(new SimpleNotification + { + Text = $"Disabling tablet support due to error: \"{message}\"", + Icon = FontAwesome.Solid.PenSquare, + IconColour = Colours.RedDark, + }); + + // We only have one tablet handler currently. + // The loop here is weakly guarding against a future where more than one is added. + // If this is ever the case, this logic needs adjustment as it should probably only + // disable the relevant tablet handler rather than all. + foreach (var tabletHandler in Host.AvailableInputHandlers.OfType()) + tabletHandler.Enabled.Value = false; + }); } else if (notifyOnWarning) { diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index c55b6c249f..63efe0e2c8 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -14,6 +14,7 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Track; using osu.Framework.Bindables; +using osu.Framework.Configuration; using osu.Framework.Development; using osu.Framework.Extensions; using osu.Framework.Graphics; @@ -27,6 +28,7 @@ using osu.Framework.Input.Handlers.Mouse; using osu.Framework.Input.Handlers.Tablet; using osu.Framework.Input.Handlers.Touch; using osu.Framework.IO.Stores; +using osu.Framework.Localisation; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Timing; @@ -36,11 +38,13 @@ using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Formats; using osu.Game.Configuration; using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Graphics.Cursor; using osu.Game.Input; using osu.Game.Input.Bindings; using osu.Game.IO; +using osu.Game.Localisation; using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Online.Chat; @@ -157,6 +161,11 @@ namespace osu.Game protected Storage Storage { get; set; } + /// + /// The language in which the game is currently displayed in. + /// + public Bindable CurrentLanguage { get; } = new Bindable(); + protected Bindable Beatmap { get; private set; } // cached via load() method /// @@ -216,6 +225,10 @@ namespace osu.Game private readonly BindableNumber globalTrackVolumeAdjust = new BindableNumber(global_track_volume_adjust); + private Bindable frameworkLocale = null!; + + private IBindable localisationParameters = null!; + /// /// Number of unhandled exceptions to allow before aborting execution. /// @@ -238,7 +251,7 @@ namespace osu.Game } [BackgroundDependencyLoader] - private void load(ReadableKeyCombinationProvider keyCombinationProvider) + private void load(ReadableKeyCombinationProvider keyCombinationProvider, FrameworkConfigManager frameworkConfig) { try { @@ -283,7 +296,15 @@ namespace osu.Game MessageFormatter.WebsiteRootUrl = endpoints.WebsiteRootUrl; - dependencies.CacheAs(API ??= new APIAccess(LocalConfig, endpoints, VersionHash)); + frameworkLocale = frameworkConfig.GetBindable(FrameworkSetting.Locale); + frameworkLocale.BindValueChanged(_ => updateLanguage()); + + localisationParameters = Localisation.CurrentParameters.GetBoundCopy(); + localisationParameters.BindValueChanged(_ => updateLanguage(), true); + + CurrentLanguage.BindValueChanged(val => frameworkLocale.Value = val.NewValue.ToCultureCode()); + + dependencies.CacheAs(API ??= new APIAccess(this, LocalConfig, endpoints, VersionHash)); var defaultBeatmap = new DummyWorkingBeatmap(Audio, Textures); @@ -394,6 +415,8 @@ namespace osu.Game Beatmap.BindValueChanged(onBeatmapChanged); } + private void updateLanguage() => CurrentLanguage.Value = LanguageExtensions.GetLanguageFor(frameworkLocale.Value, localisationParameters.Value); + private void addFilesWarning() { var realmStore = new RealmFileStore(realm, Storage); diff --git a/osu.Game/Overlays/AccountCreation/ScreenEntry.cs b/osu.Game/Overlays/AccountCreation/ScreenEntry.cs index 219cbe7eef..9ad507d82a 100644 --- a/osu.Game/Overlays/AccountCreation/ScreenEntry.cs +++ b/osu.Game/Overlays/AccountCreation/ScreenEntry.cs @@ -17,6 +17,7 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Localisation; using osu.Game.Online.API; using osu.Game.Overlays.Settings; using osu.Game.Resources.Localisation.Web; @@ -71,7 +72,7 @@ namespace osu.Game.Overlays.AccountCreation Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Font = OsuFont.GetFont(size: 20), - Text = "Let's create an account!", + Text = AccountCreationStrings.LetsCreateAnAccount }, usernameTextBox = new OsuTextBox { @@ -86,7 +87,7 @@ namespace osu.Game.Overlays.AccountCreation }, emailTextBox = new OsuTextBox { - PlaceholderText = "email address", + PlaceholderText = ModelValidationStrings.UserAttributesUserEmail.ToLower(), RelativeSizeAxes = Axes.X, TabbableContentContainer = this }, @@ -118,7 +119,7 @@ namespace osu.Game.Overlays.AccountCreation AutoSizeAxes = Axes.Y, Child = new SettingsButton { - Text = "Register", + Text = LoginPanelStrings.Register, Margin = new MarginPadding { Vertical = 20 }, Action = performRegistration } @@ -132,10 +133,10 @@ namespace osu.Game.Overlays.AccountCreation textboxes = new[] { usernameTextBox, emailTextBox, passwordTextBox }; - usernameDescription.AddText("This will be your public presence. No profanity, no impersonation. Avoid exposing your own personal details, too!"); + usernameDescription.AddText(AccountCreationStrings.UsernameDescription); - emailAddressDescription.AddText("Will be used for notifications, account verification and in the case you forget your password. No spam, ever."); - emailAddressDescription.AddText(" Make sure to get it right!", cp => cp.Font = cp.Font.With(Typeface.Torus, weight: FontWeight.Bold)); + emailAddressDescription.AddText(AccountCreationStrings.EmailDescription1); + emailAddressDescription.AddText(AccountCreationStrings.EmailDescription2, cp => cp.Font = cp.Font.With(Typeface.Torus, weight: FontWeight.Bold)); passwordDescription.AddText("At least "); characterCheckText = passwordDescription.AddText("8 characters long"); diff --git a/osu.Game/Overlays/AccountCreation/ScreenWarning.cs b/osu.Game/Overlays/AccountCreation/ScreenWarning.cs index a833a871f9..0fbf6ba59e 100644 --- a/osu.Game/Overlays/AccountCreation/ScreenWarning.cs +++ b/osu.Game/Overlays/AccountCreation/ScreenWarning.cs @@ -17,6 +17,7 @@ using osu.Game.Overlays.Settings; using osu.Game.Screens.Menu; using osuTK; using osuTK.Graphics; +using osu.Game.Localisation; namespace osu.Game.Overlays.AccountCreation { @@ -101,13 +102,13 @@ namespace osu.Game.Overlays.AccountCreation }, new SettingsButton { - Text = "Help, I can't access my account!", + Text = AccountCreationStrings.MultiAccountWarningHelp, Margin = new MarginPadding { Top = 50 }, Action = () => game?.OpenUrlExternally(help_centre_url) }, new DangerousSettingsButton { - Text = "I understand. This account isn't for me.", + Text = AccountCreationStrings.MultiAccountWarningAccept, Action = () => this.Push(new ScreenEntry()) }, furtherAssistance = new LinkFlowContainer(cp => cp.Font = cp.Font.With(size: 12)) diff --git a/osu.Game/Overlays/AccountCreation/ScreenWelcome.cs b/osu.Game/Overlays/AccountCreation/ScreenWelcome.cs index 4becb225f8..610b9ee282 100644 --- a/osu.Game/Overlays/AccountCreation/ScreenWelcome.cs +++ b/osu.Game/Overlays/AccountCreation/ScreenWelcome.cs @@ -1,9 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Screens; @@ -12,6 +11,7 @@ using osu.Game.Graphics.Sprites; using osu.Game.Overlays.Settings; using osu.Game.Screens.Menu; using osuTK; +using osu.Game.Localisation; namespace osu.Game.Overlays.AccountCreation { @@ -46,18 +46,18 @@ namespace osu.Game.Overlays.AccountCreation Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Font = OsuFont.GetFont(size: 24, weight: FontWeight.Light), - Text = "New Player Registration", + Text = AccountCreationStrings.NewPlayerRegistration.ToTitle(), }, new OsuSpriteText { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Font = OsuFont.GetFont(size: 12), - Text = "let's get you started", + Text = AccountCreationStrings.LetsGetYouStarted.ToLower(), }, new SettingsButton { - Text = "Let's create an account!", + Text = AccountCreationStrings.LetsCreateAnAccount, Margin = new MarginPadding { Vertical = 120 }, Action = () => this.Push(new ScreenWarning()) } diff --git a/osu.Game/Overlays/AccountCreationOverlay.cs b/osu.Game/Overlays/AccountCreationOverlay.cs index 6f79316670..ef2e055eae 100644 --- a/osu.Game/Overlays/AccountCreationOverlay.cs +++ b/osu.Game/Overlays/AccountCreationOverlay.cs @@ -90,7 +90,6 @@ namespace osu.Game.Overlays protected override void PopIn() { - base.PopIn(); this.FadeIn(transition_time, Easing.OutQuint); if (welcomeScreen.GetChildScreen() != null) diff --git a/osu.Game/Overlays/BeatmapSet/BasicStats.cs b/osu.Game/Overlays/BeatmapSet/BasicStats.cs index 4a9a3d8089..3cc655d561 100644 --- a/osu.Game/Overlays/BeatmapSet/BasicStats.cs +++ b/osu.Game/Overlays/BeatmapSet/BasicStats.cs @@ -68,13 +68,13 @@ namespace osu.Game.Overlays.BeatmapSet } else { - length.TooltipText = BeatmapsetsStrings.ShowStatsTotalLength(TimeSpan.FromMilliseconds(beatmapInfo.Length).ToFormattedDuration()); length.Value = TimeSpan.FromMilliseconds(beatmapInfo.Length).ToFormattedDuration(); - var onlineInfo = beatmapInfo as IBeatmapOnlineInfo; + if (beatmapInfo is not IBeatmapOnlineInfo onlineInfo) return; - circleCount.Value = (onlineInfo?.CircleCount ?? 0).ToLocalisableString(@"N0"); - sliderCount.Value = (onlineInfo?.SliderCount ?? 0).ToLocalisableString(@"N0"); + circleCount.Value = onlineInfo.CircleCount.ToLocalisableString(@"N0"); + sliderCount.Value = onlineInfo.SliderCount.ToLocalisableString(@"N0"); + length.TooltipText = BeatmapsetsStrings.ShowStatsTotalLength(TimeSpan.FromMilliseconds(onlineInfo.HitLength).ToFormattedDuration()); } } diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs index 6d89313979..b53b7826f3 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs @@ -47,9 +47,6 @@ namespace osu.Game.Overlays.BeatmapSet.Scores [Resolved] private RulesetStore rulesets { get; set; } - [Resolved] - private ScoreManager scoreManager { get; set; } - private GetScoresRequest getScoresRequest; private CancellationTokenSource loadCancellationSource; @@ -85,7 +82,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores MD5Hash = apiBeatmap.MD5Hash }; - var scores = scoreManager.OrderByTotalScore(value.Scores.Select(s => s.ToScoreInfo(rulesets, beatmapInfo))).ToArray(); + var scores = value.Scores.Select(s => s.ToScoreInfo(rulesets, beatmapInfo)).OrderByTotalScore().ToArray(); var topScore = scores.First(); scoreTable.DisplayScores(scores, apiBeatmap.Status.GrantsPerformancePoints()); diff --git a/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs b/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs index 57b6f6268c..21b6147113 100644 --- a/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs +++ b/osu.Game/Overlays/Chat/ChannelList/ChannelListItem.cs @@ -85,7 +85,7 @@ namespace osu.Game.Overlays.Chat.ChannelList new Drawable?[] { createIcon(), - text = new OsuSpriteText + text = new TruncatingSpriteText { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, @@ -94,7 +94,6 @@ namespace osu.Game.Overlays.Chat.ChannelList Colour = colourProvider.Light3, Margin = new MarginPadding { Bottom = 2 }, RelativeSizeAxes = Axes.X, - Truncate = true, }, createMentionPill(), close = createCloseButton(), diff --git a/osu.Game/Overlays/Chat/ChatLine.cs b/osu.Game/Overlays/Chat/ChatLine.cs index 2f4c175ac4..c85206d5f7 100644 --- a/osu.Game/Overlays/Chat/ChatLine.cs +++ b/osu.Game/Overlays/Chat/ChatLine.cs @@ -56,11 +56,11 @@ namespace osu.Game.Overlays.Chat [Resolved] private OverlayColourProvider? colourProvider { get; set; } - private readonly OsuSpriteText drawableTimestamp; + private OsuSpriteText drawableTimestamp = null!; - private readonly DrawableChatUsername drawableUsername; + private DrawableChatUsername drawableUsername = null!; - private readonly LinkFlowContainer drawableContentFlow; + private LinkFlowContainer drawableContentFlow = null!; private readonly Bindable prefer24HourTime = new Bindable(); @@ -69,8 +69,16 @@ namespace osu.Game.Overlays.Chat public ChatLine(Message message) { Message = message; + RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; + } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager configManager) + { + configManager.BindWith(OsuSetting.Prefer24HourTime, prefer24HourTime); + prefer24HourTime.BindValueChanged(_ => updateTimestamp()); InternalChild = new GridContainer { @@ -103,7 +111,6 @@ namespace osu.Game.Overlays.Chat Origin = Anchor.TopRight, Anchor = Anchor.TopRight, Margin = new MarginPadding { Horizontal = Spacing }, - ReportRequested = this.ShowPopover, }, drawableContentFlow = new LinkFlowContainer(styleMessageContent) { @@ -115,13 +122,6 @@ namespace osu.Game.Overlays.Chat }; } - [BackgroundDependencyLoader] - private void load(OsuConfigManager configManager) - { - configManager.BindWith(OsuSetting.Prefer24HourTime, prefer24HourTime); - prefer24HourTime.BindValueChanged(_ => updateTimestamp()); - } - protected override void LoadComplete() { base.LoadComplete(); @@ -130,6 +130,17 @@ namespace osu.Game.Overlays.Chat updateMessageContent(); FinishTransforms(true); + + if (this.FindClosestParent() != null) + { + // This guards against cases like in-game chat where there's no available popover container. + // There may be a future where a global one becomes available, at which point this code may be unnecessary. + // + // See: + // https://github.com/ppy/osu/pull/23698 + // https://github.com/ppy/osu/pull/14554 + drawableUsername.ReportRequested = this.ShowPopover; + } } public Popover GetPopover() => new ReportChatPopover(message); diff --git a/osu.Game/Overlays/Chat/ChatTextBar.cs b/osu.Game/Overlays/Chat/ChatTextBar.cs index fd5e0e9836..87e787fcb8 100644 --- a/osu.Game/Overlays/Chat/ChatTextBar.cs +++ b/osu.Game/Overlays/Chat/ChatTextBar.cs @@ -73,14 +73,13 @@ namespace osu.Game.Overlays.Chat Width = chatting_text_width, Masking = true, Padding = new MarginPadding { Horizontal = padding }, - Child = chattingText = new OsuSpriteText + Child = chattingText = new TruncatingSpriteText { MaxWidth = chatting_text_width - padding * 2, Font = OsuFont.Torus.With(size: 20), Colour = colourProvider.Background1, Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, - Truncate = true, }, }, searchIconContainer = new Container diff --git a/osu.Game/Overlays/Chat/DrawableChatUsername.cs b/osu.Game/Overlays/Chat/DrawableChatUsername.cs index 4b4afc204c..18632aa4af 100644 --- a/osu.Game/Overlays/Chat/DrawableChatUsername.cs +++ b/osu.Game/Overlays/Chat/DrawableChatUsername.cs @@ -83,11 +83,9 @@ namespace osu.Game.Overlays.Chat Action = openUserProfile; - drawableText = new OsuSpriteText + drawableText = new TruncatingSpriteText { Shadow = false, - Truncate = true, - EllipsisString = "…", Anchor = Anchor.TopRight, Origin = Anchor.TopRight, }; diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index 96dbfe31f3..87df08ceec 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -276,8 +276,6 @@ namespace osu.Game.Overlays protected override void PopIn() { - base.PopIn(); - this.MoveToY(0, transition_length, Easing.OutQuint); this.FadeIn(transition_length, Easing.OutQuint); } diff --git a/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapPanel.cs b/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapPanel.cs index 792d6cc785..f36e6b49bb 100644 --- a/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapPanel.cs +++ b/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapPanel.cs @@ -100,17 +100,15 @@ namespace osu.Game.Overlays.Dashboard.Home Direction = FillDirection.Vertical, Children = new Drawable[] { - new OsuSpriteText + new TruncatingSpriteText { RelativeSizeAxes = Axes.X, - Truncate = true, Font = OsuFont.GetFont(weight: FontWeight.Regular), Text = BeatmapSet.Title }, - new OsuSpriteText + new TruncatingSpriteText { RelativeSizeAxes = Axes.X, - Truncate = true, Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular), Text = BeatmapSet.Artist }, diff --git a/osu.Game/Overlays/DialogOverlay.cs b/osu.Game/Overlays/DialogOverlay.cs index 098a5d0a33..005162bbcc 100644 --- a/osu.Game/Overlays/DialogOverlay.cs +++ b/osu.Game/Overlays/DialogOverlay.cs @@ -99,7 +99,6 @@ namespace osu.Game.Overlays protected override void PopIn() { - base.PopIn(); lowPassFilter.CutoffTo(300, 100, Easing.OutCubic); } diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs b/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs index b8d802ad4b..68c6c78986 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenWelcome.cs @@ -13,7 +13,6 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Framework.Threading; -using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -68,13 +67,12 @@ namespace osu.Game.Overlays.FirstRunSetup private partial class LanguageSelectionFlow : FillFlowContainer { - private Bindable frameworkLocale = null!; - private IBindable localisationParameters = null!; + private Bindable language = null!; private ScheduledDelegate? updateSelectedDelegate; [BackgroundDependencyLoader] - private void load(FrameworkConfigManager frameworkConfig, LocalisationManager localisation) + private void load(OsuGameBase game) { Direction = FillDirection.Full; Spacing = new Vector2(5); @@ -82,25 +80,18 @@ namespace osu.Game.Overlays.FirstRunSetup ChildrenEnumerable = Enum.GetValues() .Select(l => new LanguageButton(l) { - Action = () => frameworkLocale.Value = l.ToCultureCode() + Action = () => language.Value = l, }); - frameworkLocale = frameworkConfig.GetBindable(FrameworkSetting.Locale); - frameworkLocale.BindValueChanged(_ => onLanguageChange()); - - localisationParameters = localisation.CurrentParameters.GetBoundCopy(); - localisationParameters.BindValueChanged(_ => onLanguageChange(), true); - } - - private void onLanguageChange() - { - var language = LanguageExtensions.GetLanguageFor(frameworkLocale.Value, localisationParameters.Value); - - // Changing language may cause a short period of blocking the UI thread while the new glyphs are loaded. - // Scheduling ensures the button animation plays smoothly after any blocking operation completes. - // Note that a delay is required (the alternative would be a double-schedule; delay feels better). - updateSelectedDelegate?.Cancel(); - updateSelectedDelegate = Scheduler.AddDelayed(() => updateSelectedStates(language), 50); + language = game.CurrentLanguage.GetBoundCopy(); + language.BindValueChanged(v => + { + // Changing language may cause a short period of blocking the UI thread while the new glyphs are loaded. + // Scheduling ensures the button animation plays smoothly after any blocking operation completes. + // Note that a delay is required (the alternative would be a double-schedule; delay feels better). + updateSelectedDelegate?.Cancel(); + updateSelectedDelegate = Scheduler.AddDelayed(() => updateSelectedStates(v.NewValue), 50); + }, true); } private void updateSelectedStates(Language language) diff --git a/osu.Game/Overlays/FullscreenOverlay.cs b/osu.Game/Overlays/FullscreenOverlay.cs index 2cc8354e50..032821f215 100644 --- a/osu.Game/Overlays/FullscreenOverlay.cs +++ b/osu.Game/Overlays/FullscreenOverlay.cs @@ -56,6 +56,7 @@ namespace osu.Game.Overlays { Colour = Color4.Black.Opacity(0), Type = EdgeEffectType.Shadow, + Hollow = true, Radius = 10 }; @@ -101,7 +102,7 @@ namespace osu.Game.Overlays protected override void PopIn() { base.PopIn(); - FadeEdgeEffectTo(0.4f, WaveContainer.APPEAR_DURATION, Easing.Out); + FadeEdgeEffectTo(WaveContainer.SHADOW_OPACITY, WaveContainer.APPEAR_DURATION, Easing.Out); } protected override void PopOut() diff --git a/osu.Game/Overlays/Login/LoginForm.cs b/osu.Game/Overlays/Login/LoginForm.cs index af145c418c..9f9b8d9342 100644 --- a/osu.Game/Overlays/Login/LoginForm.cs +++ b/osu.Game/Overlays/Login/LoginForm.cs @@ -16,6 +16,7 @@ using osu.Game.Online.API; using osu.Game.Overlays.Settings; using osu.Game.Resources.Localisation.Web; using osuTK; +using osu.Game.Localisation; namespace osu.Game.Overlays.Login { @@ -47,7 +48,7 @@ namespace osu.Game.Overlays.Login RelativeSizeAxes = Axes.X; ErrorTextFlowContainer errorText; - LinkFlowContainer forgottenPaswordLink; + LinkFlowContainer forgottenPasswordLink; Children = new Drawable[] { @@ -71,15 +72,15 @@ namespace osu.Game.Overlays.Login }, new SettingsCheckbox { - LabelText = "Remember username", + LabelText = LoginPanelStrings.RememberUsername, Current = config.GetBindable(OsuSetting.SaveUsername), }, new SettingsCheckbox { - LabelText = "Stay signed in", + LabelText = LoginPanelStrings.StaySignedIn, Current = config.GetBindable(OsuSetting.SavePassword), }, - forgottenPaswordLink = new LinkFlowContainer + forgottenPasswordLink = new LinkFlowContainer { Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS }, RelativeSizeAxes = Axes.X, @@ -105,7 +106,7 @@ namespace osu.Game.Overlays.Login }, new SettingsButton { - Text = "Register", + Text = LoginPanelStrings.Register, Action = () => { RequestHide?.Invoke(); @@ -114,7 +115,7 @@ namespace osu.Game.Overlays.Login } }; - forgottenPaswordLink.AddLink(LayoutStrings.PopupLoginLoginForgot, $"{api.WebsiteRootUrl}/home/password-reset"); + forgottenPasswordLink.AddLink(LayoutStrings.PopupLoginLoginForgot, $"{api.WebsiteRootUrl}/home/password-reset"); password.OnCommit += (_, _) => performLogin(); diff --git a/osu.Game/Overlays/Login/LoginPanel.cs b/osu.Game/Overlays/Login/LoginPanel.cs index 44f2f3273a..79569ada65 100644 --- a/osu.Game/Overlays/Login/LoginPanel.cs +++ b/osu.Game/Overlays/Login/LoginPanel.cs @@ -6,6 +6,7 @@ using System; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; @@ -81,7 +82,7 @@ namespace osu.Game.Overlays.Login { new OsuSpriteText { - Text = "ACCOUNT", + Text = LoginPanelStrings.Account.ToUpper(), Margin = new MarginPadding { Bottom = 5 }, Font = OsuFont.GetFont(weight: FontWeight.Bold), }, @@ -115,7 +116,7 @@ namespace osu.Game.Overlays.Login }, }; - linkFlow.AddLink("cancel", api.Logout, string.Empty); + linkFlow.AddLink(Resources.Localisation.Web.CommonStrings.ButtonsCancel.ToLower(), api.Logout, string.Empty); break; case APIState.Online: @@ -140,7 +141,7 @@ namespace osu.Game.Overlays.Login { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Text = "Signed in", + Text = LoginPanelStrings.SignedIn, Font = OsuFont.GetFont(size: 18, weight: FontWeight.Bold), Margin = new MarginPadding { Top = 5, Bottom = 5 }, }, diff --git a/osu.Game/Overlays/Login/UserAction.cs b/osu.Game/Overlays/Login/UserAction.cs index 7a18e38109..d4d639f2fb 100644 --- a/osu.Game/Overlays/Login/UserAction.cs +++ b/osu.Game/Overlays/Login/UserAction.cs @@ -1,11 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using System.ComponentModel; using osu.Framework.Localisation; using osu.Game.Resources.Localisation.Web; +using osu.Game.Localisation; namespace osu.Game.Overlays.Login { @@ -14,13 +12,13 @@ namespace osu.Game.Overlays.Login [LocalisableDescription(typeof(UsersStrings), nameof(UsersStrings.StatusOnline))] Online, - [Description(@"Do not disturb")] + [LocalisableDescription(typeof(LoginPanelStrings), nameof(LoginPanelStrings.DoNotDisturb))] DoNotDisturb, - [Description(@"Appear offline")] + [LocalisableDescription(typeof(LoginPanelStrings), nameof(LoginPanelStrings.AppearOffline))] AppearOffline, - [Description(@"Sign out")] + [LocalisableDescription(typeof(UserVerificationStrings), nameof(UserVerificationStrings.BoxInfoLogoutLink))] SignOut, } } diff --git a/osu.Game/Overlays/LoginOverlay.cs b/osu.Game/Overlays/LoginOverlay.cs index 536811dfcf..8b60024682 100644 --- a/osu.Game/Overlays/LoginOverlay.cs +++ b/osu.Game/Overlays/LoginOverlay.cs @@ -75,8 +75,6 @@ namespace osu.Game.Overlays protected override void PopIn() { - base.PopIn(); - panel.Bounding = true; this.FadeIn(transition_time, Easing.OutQuint); diff --git a/osu.Game/Overlays/MedalOverlay.cs b/osu.Game/Overlays/MedalOverlay.cs index bd895fe6bf..eba35ec6f9 100644 --- a/osu.Game/Overlays/MedalOverlay.cs +++ b/osu.Game/Overlays/MedalOverlay.cs @@ -246,9 +246,13 @@ namespace osu.Game.Overlays } } + protected override void PopIn() + { + this.FadeIn(200); + } + protected override void PopOut() { - base.PopOut(); this.FadeOut(200); } diff --git a/osu.Game/Overlays/Mods/DeselectAllModsButton.cs b/osu.Game/Overlays/Mods/DeselectAllModsButton.cs index 3e5a3b12d1..817b6beac3 100644 --- a/osu.Game/Overlays/Mods/DeselectAllModsButton.cs +++ b/osu.Game/Overlays/Mods/DeselectAllModsButton.cs @@ -6,16 +6,13 @@ using System.Collections.Generic; using System.Linq; using osu.Framework.Bindables; -using osu.Framework.Input.Bindings; -using osu.Framework.Input.Events; using osu.Game.Graphics.UserInterface; -using osu.Game.Input.Bindings; using osu.Game.Localisation; using osu.Game.Rulesets.Mods; namespace osu.Game.Overlays.Mods { - public partial class DeselectAllModsButton : ShearedButton, IKeyBindingHandler + public partial class DeselectAllModsButton : ShearedButton { private readonly Bindable> selectedMods = new Bindable>(); @@ -39,18 +36,5 @@ namespace osu.Game.Overlays.Mods { Enabled.Value = selectedMods.Value.Any(); } - - public bool OnPressed(KeyBindingPressEvent e) - { - if (e.Repeat || e.Action != GlobalAction.DeselectAllMods) - return false; - - TriggerClick(); - return true; - } - - public void OnReleased(KeyBindingReleaseEvent e) - { - } } } diff --git a/osu.Game/Overlays/Mods/Input/ClassicModHotkeyHandler.cs b/osu.Game/Overlays/Mods/Input/ClassicModHotkeyHandler.cs index 4f3c18fc43..59a631a7b5 100644 --- a/osu.Game/Overlays/Mods/Input/ClassicModHotkeyHandler.cs +++ b/osu.Game/Overlays/Mods/Input/ClassicModHotkeyHandler.cs @@ -42,7 +42,7 @@ namespace osu.Game.Overlays.Mods.Input if (!mod_type_lookup.TryGetValue(e.Key, out var typesToMatch)) return false; - var matchingMods = availableMods.Where(modState => matches(modState, typesToMatch) && !modState.Filtered.Value).ToArray(); + var matchingMods = availableMods.Where(modState => matches(modState, typesToMatch) && modState.Visible).ToArray(); if (matchingMods.Length == 0) return false; diff --git a/osu.Game/Overlays/Mods/Input/SequentialModHotkeyHandler.cs b/osu.Game/Overlays/Mods/Input/SequentialModHotkeyHandler.cs index dedb556304..e638063438 100644 --- a/osu.Game/Overlays/Mods/Input/SequentialModHotkeyHandler.cs +++ b/osu.Game/Overlays/Mods/Input/SequentialModHotkeyHandler.cs @@ -48,7 +48,7 @@ namespace osu.Game.Overlays.Mods.Input if (index < 0) return false; - var modState = availableMods.Where(modState => !modState.Filtered.Value).ElementAtOrDefault(index); + var modState = availableMods.Where(modState => modState.Visible).ElementAtOrDefault(index); if (modState == null) return false; diff --git a/osu.Game/Overlays/Mods/ModColumn.cs b/osu.Game/Overlays/Mods/ModColumn.cs index 5d9f616e5f..d65c94d14d 100644 --- a/osu.Game/Overlays/Mods/ModColumn.cs +++ b/osu.Game/Overlays/Mods/ModColumn.cs @@ -46,7 +46,8 @@ namespace osu.Game.Overlays.Mods foreach (var mod in availableMods) { mod.Active.BindValueChanged(_ => updateState()); - mod.Filtered.BindValueChanged(_ => updateState()); + mod.MatchingTextFilter.BindValueChanged(_ => updateState()); + mod.ValidForSelection.BindValueChanged(_ => updateState()); } updateState(); @@ -145,12 +146,17 @@ namespace osu.Game.Overlays.Mods private void updateState() { - Alpha = availableMods.All(mod => mod.Filtered.Value) ? 0 : 1; + Alpha = availableMods.All(mod => !mod.Visible) ? 0 : 1; if (toggleAllCheckbox != null && !SelectionAnimationRunning) { - toggleAllCheckbox.Alpha = availableMods.Any(panel => !panel.Filtered.Value) ? 1 : 0; - toggleAllCheckbox.Current.Value = availableMods.Where(panel => !panel.Filtered.Value).All(panel => panel.Active.Value); + bool anyPanelsVisible = availableMods.Any(panel => panel.Visible); + + toggleAllCheckbox.Alpha = anyPanelsVisible ? 1 : 0; + + // checking `anyPanelsVisible` is important since `.All()` returns `true` for empty enumerables. + if (anyPanelsVisible) + toggleAllCheckbox.Current.Value = availableMods.Where(panel => panel.Visible).All(panel => panel.Active.Value); } } @@ -176,7 +182,7 @@ namespace osu.Game.Overlays.Mods dequeuedAction(); // each time we play an animation, we decrease the time until the next animation (to ramp the visual and audible elements). - selectionDelay = Math.Max(30, selectionDelay * 0.8f); + selectionDelay = Math.Max(ModSelectPanel.SAMPLE_PLAYBACK_DELAY, selectionDelay * 0.8f); lastSelection = Time.Current; } else @@ -195,7 +201,7 @@ namespace osu.Game.Overlays.Mods { pendingSelectionOperations.Clear(); - foreach (var button in availableMods.Where(b => !b.Active.Value && !b.Filtered.Value)) + foreach (var button in availableMods.Where(b => !b.Active.Value && b.Visible)) pendingSelectionOperations.Enqueue(() => button.Active.Value = true); } @@ -206,8 +212,13 @@ namespace osu.Game.Overlays.Mods { pendingSelectionOperations.Clear(); - foreach (var button in availableMods.Where(b => b.Active.Value && !b.Filtered.Value)) - pendingSelectionOperations.Enqueue(() => button.Active.Value = false); + foreach (var button in availableMods.Where(b => b.Active.Value)) + { + if (!button.Visible) + button.Active.Value = false; + else + pendingSelectionOperations.Enqueue(() => button.Active.Value = false); + } } /// diff --git a/osu.Game/Overlays/Mods/ModPanel.cs b/osu.Game/Overlays/Mods/ModPanel.cs index b5fee9d116..f294b1892d 100644 --- a/osu.Game/Overlays/Mods/ModPanel.cs +++ b/osu.Game/Overlays/Mods/ModPanel.cs @@ -1,9 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; @@ -11,11 +14,10 @@ using osuTK; namespace osu.Game.Overlays.Mods { - public partial class ModPanel : ModSelectPanel + public partial class ModPanel : ModSelectPanel, IFilterable { public Mod Mod => modState.Mod; public override BindableBool Active => modState.Active; - public BindableBool Filtered => modState.Filtered; protected override float IdleSwitchWidth => 54; protected override float ExpandedSwitchWidth => 70; @@ -54,7 +56,8 @@ namespace osu.Game.Overlays.Mods { base.LoadComplete(); - Filtered.BindValueChanged(_ => updateFilterState(), true); + modState.ValidForSelection.BindValueChanged(_ => updateFilterState()); + modState.MatchingTextFilter.BindValueChanged(_ => updateFilterState(), true); } protected override void Select() @@ -71,9 +74,25 @@ namespace osu.Game.Overlays.Mods #region Filtering support + /// + public bool Visible => modState.Visible; + + public override IEnumerable FilterTerms => new[] + { + Mod.Name, + Mod.Acronym, + Mod.Description + }; + + public override bool MatchingFilter + { + get => modState.MatchingTextFilter.Value; + set => modState.MatchingTextFilter.Value = value; + } + private void updateFilterState() { - this.FadeTo(Filtered.Value ? 0 : 1); + this.FadeTo(Visible ? 1 : 0); } #endregion diff --git a/osu.Game/Overlays/Mods/ModPresetPanel.cs b/osu.Game/Overlays/Mods/ModPresetPanel.cs index 8bcb5e4e4e..00f6e36972 100644 --- a/osu.Game/Overlays/Mods/ModPresetPanel.cs +++ b/osu.Game/Overlays/Mods/ModPresetPanel.cs @@ -9,6 +9,7 @@ using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Graphics; @@ -81,6 +82,27 @@ namespace osu.Game.Overlays.Mods Active.Value = new HashSet(Preset.Value.Mods).SetEquals(selectedMods.Value); } + #region Filtering support + + public override IEnumerable FilterTerms => getFilterTerms(); + + private IEnumerable getFilterTerms() + { + var preset = Preset.Value; + + yield return preset.Name; + yield return preset.Description; + + foreach (Mod mod in preset.Mods) + { + yield return mod.Name; + yield return mod.Acronym; + yield return mod.Description; + } + } + + #endregion + #region IHasCustomTooltip public ModPreset TooltipContent => Preset.Value; diff --git a/osu.Game/Overlays/Mods/ModSearchContainer.cs b/osu.Game/Overlays/Mods/ModSearchContainer.cs new file mode 100644 index 0000000000..8787530d5c --- /dev/null +++ b/osu.Game/Overlays/Mods/ModSearchContainer.cs @@ -0,0 +1,27 @@ +// 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.Graphics.Containers; + +namespace osu.Game.Overlays.Mods +{ + public partial class ModSearchContainer : SearchContainer + { + public new string SearchTerm + { + get => base.SearchTerm; + set + { + if (value == SearchTerm) + return; + + base.SearchTerm = value; + + // Manual filtering here is required because ModColumn can be hidden when search term applied, + // causing the whole SearchContainer to become non-present and never actually perform a subsequent + // filter. + Filter(); + } + } + } +} diff --git a/osu.Game/Overlays/Mods/ModSelectColumn.cs b/osu.Game/Overlays/Mods/ModSelectColumn.cs index e6d7bcd97d..338ebdaef4 100644 --- a/osu.Game/Overlays/Mods/ModSelectColumn.cs +++ b/osu.Game/Overlays/Mods/ModSelectColumn.cs @@ -43,10 +43,15 @@ namespace osu.Game.Overlays.Mods /// public readonly Bindable Active = new BindableBool(true); + public string SearchTerm + { + set => ItemsFlow.SearchTerm = value; + } + protected override bool ReceivePositionalInputAtSubTree(Vector2 screenSpacePos) => base.ReceivePositionalInputAtSubTree(screenSpacePos) && Active.Value; protected readonly Container ControlContainer; - protected readonly FillFlowContainer ItemsFlow; + protected readonly ModSearchContainer ItemsFlow; private readonly TextFlowContainer headerText; private readonly Box headerBackground; @@ -150,7 +155,7 @@ namespace osu.Game.Overlays.Mods RelativeSizeAxes = Axes.Both, ClampExtension = 100, ScrollbarOverlapsContent = false, - Child = ItemsFlow = new FillFlowContainer + Child = ItemsFlow = new ModSearchContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index 38ae8c68cb..9035503723 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -12,6 +12,8 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; +using osu.Framework.Input; +using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Framework.Utils; using osu.Game.Audio; @@ -25,10 +27,11 @@ using osu.Game.Localisation; using osu.Game.Rulesets.Mods; using osu.Game.Utils; using osuTK; +using osuTK.Input; namespace osu.Game.Overlays.Mods { - public abstract partial class ModSelectOverlay : ShearedOverlayContainer, ISamplePlaybackDisabler + public abstract partial class ModSelectOverlay : ShearedOverlayContainer, ISamplePlaybackDisabler, IKeyBindingHandler { public const int BUTTON_WIDTH = 200; @@ -64,6 +67,14 @@ namespace osu.Game.Overlays.Mods } } + public string SearchTerm + { + get => SearchTextBox.Current.Value; + set => SearchTextBox.Current.Value = value; + } + + public ShearedSearchTextBox SearchTextBox { get; private set; } = null!; + /// /// Whether the total score multiplier calculated from the current selected set of mods should be shown. /// @@ -94,7 +105,7 @@ namespace osu.Game.Overlays.Mods }; } - yield return new DeselectAllModsButton(this); + yield return deselectAllModsButton = new DeselectAllModsButton(this); } private readonly Bindable>> globalAvailableMods = new Bindable>>(); @@ -107,11 +118,14 @@ namespace osu.Game.Overlays.Mods private ColumnScrollContainer columnScroll = null!; private ColumnFlowContainer columnFlow = null!; private FillFlowContainer footerButtonFlow = null!; + private DeselectAllModsButton deselectAllModsButton = null!; + private Container aboveColumnsContent = null!; private DifficultyMultiplierDisplay? multiplierDisplay; protected ShearedButton BackButton { get; private set; } = null!; protected ShearedToggleButton? CustomisationButton { get; private set; } + protected SelectAllModsButton? SelectAllModsButton { get; set; } private Sample? columnAppearSample; @@ -146,6 +160,17 @@ namespace osu.Game.Overlays.Mods MainAreaContent.AddRange(new Drawable[] { + aboveColumnsContent = new Container + { + RelativeSizeAxes = Axes.X, + Height = ModsEffectDisplay.HEIGHT, + Padding = new MarginPadding { Horizontal = 100 }, + Child = SearchTextBox = new ShearedSearchTextBox + { + HoldFocus = false, + Width = 300 + } + }, new OsuContextMenuContainer { RelativeSizeAxes = Axes.Both, @@ -153,7 +178,7 @@ namespace osu.Game.Overlays.Mods { Padding = new MarginPadding { - Top = (ShowTotalMultiplier ? ModsEffectDisplay.HEIGHT : 0) + PADDING, + Top = ModsEffectDisplay.HEIGHT + PADDING, Bottom = PADDING }, RelativeSizeAxes = Axes.Both, @@ -186,18 +211,10 @@ namespace osu.Game.Overlays.Mods if (ShowTotalMultiplier) { - MainAreaContent.Add(new Container + aboveColumnsContent.Add(multiplierDisplay = new DifficultyMultiplierDisplay { Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - AutoSizeAxes = Axes.X, - Height = ModsEffectDisplay.HEIGHT, - Margin = new MarginPadding { Horizontal = 100 }, - Child = multiplierDisplay = new DifficultyMultiplierDisplay - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre - }, + Origin = Anchor.TopRight }); } @@ -226,6 +243,14 @@ namespace osu.Game.Overlays.Mods globalAvailableMods.BindTo(game.AvailableMods); } + public override void Hide() + { + base.Hide(); + + // clear search for next user interaction with mod overlay + SearchTextBox.Current.Value = string.Empty; + } + private ModSettingChangeTracker? modSettingChangeTracker; protected override void LoadComplete() @@ -263,6 +288,12 @@ namespace osu.Game.Overlays.Mods customisationVisible.BindValueChanged(_ => updateCustomisationVisualState(), true); + SearchTextBox.Current.BindValueChanged(query => + { + foreach (var column in columnFlow.Columns) + column.SearchTerm = query.NewValue; + }, true); + // Start scrolled slightly to the right to give the user a sense that // there is more horizontal content available. ScheduleAfterChildren(() => @@ -272,6 +303,13 @@ namespace osu.Game.Overlays.Mods }); } + protected override void Update() + { + base.Update(); + + SearchTextBox.PlaceholderText = SearchTextBox.HasFocus ? Resources.Localisation.Web.CommonStrings.InputSearch : ModSelectOverlayStrings.TabToSearch; + } + /// /// Select all visible mods in all columns. /// @@ -344,7 +382,7 @@ namespace osu.Game.Overlays.Mods private void filterMods() { foreach (var modState in allAvailableMods) - modState.Filtered.Value = !modState.Mod.HasImplementation || !IsValidMod.Invoke(modState.Mod); + modState.ValidForSelection.Value = modState.Mod.HasImplementation && IsValidMod.Invoke(modState.Mod); } private void updateMultiplier() @@ -469,7 +507,7 @@ namespace osu.Game.Overlays.Mods base.PopIn(); - multiplierDisplay? + aboveColumnsContent .FadeIn(fade_in_duration, Easing.OutQuint) .MoveToY(0, fade_in_duration, Easing.OutQuint); @@ -479,7 +517,7 @@ namespace osu.Game.Overlays.Mods { var column = columnFlow[i].Column; - bool allFiltered = column is ModColumn modColumn && modColumn.AvailableMods.All(modState => modState.Filtered.Value); + bool allFiltered = column is ModColumn modColumn && modColumn.AvailableMods.All(modState => !modState.Visible); double delay = allFiltered ? 0 : nonFilteredColumnCount * 30; double duration = allFiltered ? 0 : fade_in_duration; @@ -527,7 +565,7 @@ namespace osu.Game.Overlays.Mods base.PopOut(); - multiplierDisplay? + aboveColumnsContent .FadeOut(fade_out_duration / 2, Easing.OutQuint) .MoveToY(-distance, fade_out_duration / 2, Easing.OutQuint); @@ -541,7 +579,7 @@ namespace osu.Game.Overlays.Mods if (column is ModColumn modColumn) { - allFiltered = modColumn.AvailableMods.All(modState => modState.Filtered.Value); + allFiltered = modColumn.AvailableMods.All(modState => !modState.Visible); modColumn.FlushPendingSelections(); } @@ -578,10 +616,38 @@ namespace osu.Game.Overlays.Mods // This is handled locally here because this overlay is being registered at the game level // and therefore takes away keyboard focus from the screen stack. case GlobalAction.ToggleModSelection: + // Pressing toggle should completely hide the overlay in one shot. + hideOverlay(true); + return true; + + // This is handled locally here due to conflicts in input handling between the search text box and the deselect all mods button. + // Attempting to handle this action locally in both places leads to a possible scenario + // wherein activating the binding will both change the contents of the search text box and deselect all mods. + case GlobalAction.DeselectAllMods: + { + if (!SearchTextBox.HasFocus) + { + deselectAllModsButton.TriggerClick(); + return true; + } + + break; + } + case GlobalAction.Select: { - // Pressing toggle or select should completely hide the overlay in one shot. - hideOverlay(true); + // Pressing select should select first filtered mod or completely hide the overlay in one shot if search term is empty. + if (string.IsNullOrEmpty(SearchTerm)) + { + hideOverlay(true); + return true; + } + + ModState? firstMod = columnFlow.Columns.OfType().FirstOrDefault(m => m.IsPresent)?.AvailableMods.FirstOrDefault(x => x.Visible); + + if (firstMod is not null) + firstMod.Active.Value = !firstMod.Active.Value; + return true; } } @@ -603,6 +669,39 @@ namespace osu.Game.Overlays.Mods } } + /// + /// + /// This is handled locally here due to conflicts in input handling between the search text box and the select all mods button. + /// Attempting to handle this action locally in both places leads to a possible scenario + /// wherein activating the "select all" platform binding will both select all text in the search box and select all mods. + /// > + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Repeat || e.Action != PlatformAction.SelectAll || SelectAllModsButton is null) + return false; + + SelectAllModsButton.TriggerClick(); + return true; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.Repeat || e.Key != Key.Tab) + return false; + + // TODO: should probably eventually support typical platform search shortcuts (`Ctrl-F`, `/`) + if (SearchTextBox.HasFocus) + SearchTextBox.KillFocus(); + else + SearchTextBox.TakeFocus(); + + return true; + } + #endregion #region Sample playback control @@ -743,6 +842,9 @@ namespace osu.Game.Overlays.Mods if (!Active.Value) RequestScroll?.Invoke(this); + // Killing focus is done here because it's the only feasible place on ModSelectOverlay you can click on without triggering any action. + Scheduler.Add(() => GetContainingInputManager().ChangeFocus(null)); + return true; } diff --git a/osu.Game/Overlays/Mods/ModSelectPanel.cs b/osu.Game/Overlays/Mods/ModSelectPanel.cs index 81285833bd..29f4c93e88 100644 --- a/osu.Game/Overlays/Mods/ModSelectPanel.cs +++ b/osu.Game/Overlays/Mods/ModSelectPanel.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.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -14,6 +15,7 @@ using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Audio; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -24,7 +26,7 @@ using osuTK.Input; namespace osu.Game.Overlays.Mods { - public abstract partial class ModSelectPanel : OsuClickableContainer, IHasAccentColour + public abstract partial class ModSelectPanel : OsuClickableContainer, IHasAccentColour, IFilterable { public abstract BindableBool Active { get; } @@ -45,6 +47,8 @@ namespace osu.Game.Overlays.Mods public const float CORNER_RADIUS = 7; public const float HEIGHT = 42; + public const double SAMPLE_PLAYBACK_DELAY = 30; + protected virtual float IdleSwitchWidth => 14; protected virtual float ExpandedSwitchWidth => 30; protected virtual Colour4 BackgroundColour => Active.Value ? AccentColour.Darken(0.3f) : ColourProvider.Background3; @@ -69,6 +73,8 @@ namespace osu.Game.Overlays.Mods private Sample? sampleOff; private Sample? sampleOn; + private Bindable lastPlaybackTime = null!; + protected ModSelectPanel() { RelativeSizeAxes = Axes.X; @@ -118,23 +124,23 @@ namespace osu.Game.Overlays.Mods Direction = FillDirection.Vertical, Children = new[] { - titleText = new OsuSpriteText + titleText = new TruncatingSpriteText { Font = OsuFont.TorusAlternate.With(size: 18, weight: FontWeight.SemiBold), RelativeSizeAxes = Axes.X, - Truncate = true, Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), Margin = new MarginPadding { Left = -18 * ShearedOverlayContainer.SHEAR - } + }, + ShowTooltip = false, // Tooltip is handled by `IncompatibilityDisplayingModPanel`. }, - descriptionText = new OsuSpriteText + descriptionText = new TruncatingSpriteText { Font = OsuFont.Default.With(size: 12), RelativeSizeAxes = Axes.X, - Truncate = true, - Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0) + Shear = new Vector2(-ShearedOverlayContainer.SHEAR, 0), + ShowTooltip = false, // Tooltip is handled by `IncompatibilityDisplayingModPanel`. } } } @@ -163,13 +169,15 @@ namespace osu.Game.Overlays.Mods protected abstract void Deselect(); [BackgroundDependencyLoader] - private void load(AudioManager audio, ISamplePlaybackDisabler? samplePlaybackDisabler) + private void load(AudioManager audio, SessionStatics statics, ISamplePlaybackDisabler? samplePlaybackDisabler) { sampleOn = audio.Samples.Get(@"UI/check-on"); sampleOff = audio.Samples.Get(@"UI/check-off"); if (samplePlaybackDisabler != null) ((IBindable)samplePlaybackDisabled).BindTo(samplePlaybackDisabler.SamplePlaybackDisabled); + + lastPlaybackTime = statics.GetBindable(Static.LastHoverSoundPlaybackTime); } protected sealed override HoverSounds CreateHoverSounds(HoverSampleSet sampleSet) => new HoverSounds(sampleSet); @@ -192,10 +200,20 @@ namespace osu.Game.Overlays.Mods if (samplePlaybackDisabled.Value) return; - if (Active.Value) - sampleOn?.Play(); - else - sampleOff?.Play(); + if (!IsPresent) + return; + + bool enoughTimePassedSinceLastPlayback = !lastPlaybackTime.Value.HasValue || Time.Current - lastPlaybackTime.Value >= SAMPLE_PLAYBACK_DELAY; + + if (enoughTimePassedSinceLastPlayback) + { + if (Active.Value) + sampleOn?.Play(); + else + sampleOff?.Play(); + + lastPlaybackTime.Value = Time.Current; + } } protected override bool OnHover(HoverEvent e) @@ -263,5 +281,28 @@ namespace osu.Game.Overlays.Mods TextBackground.FadeColour(foregroundColour, transitionDuration, Easing.OutQuint); TextFlow.FadeColour(textColour, transitionDuration, Easing.OutQuint); } + + #region IFilterable + + public abstract IEnumerable FilterTerms { get; } + + private bool matchingFilter = true; + + public virtual bool MatchingFilter + { + get => matchingFilter; + set + { + if (matchingFilter == value) + return; + + matchingFilter = value; + this.FadeTo(value ? 1 : 0); + } + } + + public bool FilteringActive { set { } } + + #endregion } } diff --git a/osu.Game/Overlays/Mods/ModState.cs b/osu.Game/Overlays/Mods/ModState.cs index 3ee890e876..7a5bc0f3ae 100644 --- a/osu.Game/Overlays/Mods/ModState.cs +++ b/osu.Game/Overlays/Mods/ModState.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Bindables; using osu.Game.Rulesets.Mods; @@ -32,9 +30,21 @@ namespace osu.Game.Overlays.Mods public bool PendingConfiguration { get; set; } /// - /// Whether the mod is currently filtered out due to not matching imposed criteria. + /// Whether the mod is currently valid for selection. + /// This can be in scenarios such as the free mod select overlay, where not all mods are selectable + /// regardless of search criteria imposed by the user selecting. /// - public BindableBool Filtered { get; } = new BindableBool(); + public BindableBool ValidForSelection { get; } = new BindableBool(true); + + /// + /// Whether the mod is matching the current textual filter. + /// + public BindableBool MatchingTextFilter { get; } = new BindableBool(true); + + /// + /// Whether the matches all applicable filters and visible for the user to select. + /// + public bool Visible => MatchingTextFilter.Value && ValidForSelection.Value; public ModState(Mod mod) { diff --git a/osu.Game/Overlays/Mods/SelectAllModsButton.cs b/osu.Game/Overlays/Mods/SelectAllModsButton.cs index f4b8025227..bb61cdc35d 100644 --- a/osu.Game/Overlays/Mods/SelectAllModsButton.cs +++ b/osu.Game/Overlays/Mods/SelectAllModsButton.cs @@ -1,14 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Collections.Generic; using System.Linq; using osu.Framework.Bindables; -using osu.Framework.Input; -using osu.Framework.Input.Bindings; -using osu.Framework.Input.Events; using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Rulesets.Mods; @@ -16,10 +11,11 @@ using osu.Game.Screens.OnlinePlay; namespace osu.Game.Overlays.Mods { - public partial class SelectAllModsButton : ShearedButton, IKeyBindingHandler + public partial class SelectAllModsButton : ShearedButton { private readonly Bindable> selectedMods = new Bindable>(); private readonly Bindable>> availableMods = new Bindable>>(); + private readonly Bindable searchTerm = new Bindable(); public SelectAllModsButton(FreeModSelectOverlay modSelectOverlay) : base(ModSelectOverlay.BUTTON_WIDTH) @@ -29,6 +25,7 @@ namespace osu.Game.Overlays.Mods selectedMods.BindTo(modSelectOverlay.SelectedMods); availableMods.BindTo(modSelectOverlay.AvailableMods); + searchTerm.BindTo(modSelectOverlay.SearchTextBox.Current); } protected override void LoadComplete() @@ -37,6 +34,7 @@ namespace osu.Game.Overlays.Mods selectedMods.BindValueChanged(_ => Scheduler.AddOnce(updateEnabledState)); availableMods.BindValueChanged(_ => Scheduler.AddOnce(updateEnabledState)); + searchTerm.BindValueChanged(_ => Scheduler.AddOnce(updateEnabledState)); updateEnabledState(); } @@ -44,20 +42,7 @@ namespace osu.Game.Overlays.Mods { Enabled.Value = availableMods.Value .SelectMany(pair => pair.Value) - .Any(modState => !modState.Active.Value && !modState.Filtered.Value); - } - - public bool OnPressed(KeyBindingPressEvent e) - { - if (e.Repeat || e.Action != PlatformAction.SelectAll) - return false; - - TriggerClick(); - return true; - } - - public void OnReleased(KeyBindingReleaseEvent e) - { + .Any(modState => !modState.Active.Value && modState.Visible); } } } diff --git a/osu.Game/Overlays/Mods/ShearedOverlayContainer.cs b/osu.Game/Overlays/Mods/ShearedOverlayContainer.cs index 7f7b09a62c..a372ec70db 100644 --- a/osu.Game/Overlays/Mods/ShearedOverlayContainer.cs +++ b/osu.Game/Overlays/Mods/ShearedOverlayContainer.cs @@ -130,7 +130,6 @@ namespace osu.Game.Overlays.Mods { const double fade_in_duration = 400; - base.PopIn(); this.FadeIn(fade_in_duration, Easing.OutQuint); Header.MoveToY(0, fade_in_duration, Easing.OutQuint); diff --git a/osu.Game/Overlays/News/NewsCard.cs b/osu.Game/Overlays/News/NewsCard.cs index e0be5cc4a9..b12aa4509e 100644 --- a/osu.Game/Overlays/News/NewsCard.cs +++ b/osu.Game/Overlays/News/NewsCard.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using osu.Framework.Allocation; @@ -13,7 +11,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; -using osu.Framework.Platform; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -27,8 +24,8 @@ namespace osu.Game.Overlays.News private readonly APINewsPost post; - private Box background; - private TextFlowContainer main; + private Box background = null!; + private TextFlowContainer main = null!; public NewsCard(APINewsPost post) { @@ -41,12 +38,12 @@ namespace osu.Game.Overlays.News } [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider, GameHost host) + private void load(OverlayColourProvider colourProvider, OsuGame? game) { if (post.Slug != null) { TooltipText = "view in browser"; - Action = () => host.OpenUrlExternally("https://osu.ppy.sh/home/news/" + post.Slug); + Action = () => game?.OpenUrlExternally(@"/home/news/" + post.Slug); } AddRange(new Drawable[] diff --git a/osu.Game/Overlays/News/Sidebar/MonthSection.cs b/osu.Game/Overlays/News/Sidebar/MonthSection.cs index 30d29048ba..9a748b2001 100644 --- a/osu.Game/Overlays/News/Sidebar/MonthSection.cs +++ b/osu.Game/Overlays/News/Sidebar/MonthSection.cs @@ -20,7 +20,7 @@ using System.Diagnostics; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Extensions.LocalisationExtensions; -using osu.Framework.Platform; +using osu.Game.Online.Chat; namespace osu.Game.Overlays.News.Sidebar { @@ -59,7 +59,7 @@ namespace osu.Game.Overlays.News.Sidebar new PostsContainer { Expanded = { BindTarget = Expanded }, - Children = posts.Select(p => new PostButton(p)).ToArray() + Children = posts.Select(p => new PostLink(p)).ToArray() } } }; @@ -123,35 +123,14 @@ namespace osu.Game.Overlays.News.Sidebar } } - private partial class PostButton : OsuHoverContainer + private partial class PostLink : LinkFlowContainer { - protected override IEnumerable EffectTargets => new[] { text }; - - private readonly TextFlowContainer text; - private readonly APINewsPost post; - - public PostButton(APINewsPost post) + public PostLink(APINewsPost post) + : base(t => t.Font = OsuFont.GetFont(size: 12)) { - this.post = post; - RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - Child = text = new TextFlowContainer(t => t.Font = OsuFont.GetFont(size: 12)) - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Text = post.Title - }; - } - - [BackgroundDependencyLoader] - private void load(OverlayColourProvider overlayColours, GameHost host) - { - IdleColour = overlayColours.Light2; - HoverColour = overlayColours.Light1; - - TooltipText = "view in browser"; - Action = () => host.OpenUrlExternally("https://osu.ppy.sh/home/news/" + post.Slug); + AddLink(post.Title, LinkAction.External, @"/home/news/" + post.Slug, "view in browser"); } } diff --git a/osu.Game/Overlays/NotificationOverlay.cs b/osu.Game/Overlays/NotificationOverlay.cs index 71a4c58afd..15e6c94b34 100644 --- a/osu.Game/Overlays/NotificationOverlay.cs +++ b/osu.Game/Overlays/NotificationOverlay.cs @@ -5,9 +5,11 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; using osu.Framework.Logging; @@ -16,6 +18,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Overlays.Notifications; using osu.Game.Resources.Localisation.Web; using osuTK; +using osuTK.Graphics; using NotificationsStrings = osu.Game.Localisation.NotificationsStrings; namespace osu.Game.Overlays @@ -72,6 +75,14 @@ namespace osu.Game.Overlays mainContent = new Container { RelativeSizeAxes = Axes.Both, + Masking = true, + EdgeEffect = new EdgeEffectParameters + { + Colour = Color4.Black.Opacity(0), + Type = EdgeEffectType.Shadow, + Radius = 10, + Hollow = true, + }, Children = new Drawable[] { new Box @@ -195,10 +206,9 @@ namespace osu.Game.Overlays protected override void PopIn() { - base.PopIn(); - this.MoveToX(0, TRANSITION_LENGTH, Easing.OutQuint); mainContent.FadeTo(1, TRANSITION_LENGTH, Easing.OutQuint); + mainContent.FadeEdgeEffectTo(WaveContainer.SHADOW_OPACITY, WaveContainer.APPEAR_DURATION, Easing.Out); toastTray.FlushAllToasts(); } @@ -211,6 +221,7 @@ namespace osu.Game.Overlays this.MoveToX(WIDTH, TRANSITION_LENGTH, Easing.OutQuint); mainContent.FadeTo(0, TRANSITION_LENGTH, Easing.OutQuint); + mainContent.FadeEdgeEffectTo(0, WaveContainer.DISAPPEAR_DURATION, Easing.In); } private void notificationClosed() => Schedule(() => diff --git a/osu.Game/Overlays/NowPlayingOverlay.cs b/osu.Game/Overlays/NowPlayingOverlay.cs index 66fb3571ba..15eefb2d9f 100644 --- a/osu.Game/Overlays/NowPlayingOverlay.cs +++ b/osu.Game/Overlays/NowPlayingOverlay.cs @@ -229,8 +229,6 @@ namespace osu.Game.Overlays protected override void PopIn() { - base.PopIn(); - this.FadeIn(transition_length, Easing.OutQuint); dragContainer.ScaleTo(1, transition_length, Easing.OutElastic); } @@ -415,7 +413,7 @@ namespace osu.Game.Overlays [BackgroundDependencyLoader] private void load(LargeTextureStore textures) { - sprite.Texture = beatmap?.Background ?? textures.Get(@"Backgrounds/bg4"); + sprite.Texture = beatmap?.GetBackground() ?? textures.Get(@"Backgrounds/bg4"); } } diff --git a/osu.Game/Overlays/OnlineOverlay.cs b/osu.Game/Overlays/OnlineOverlay.cs index 4d2c6bc9d0..8b7a82f899 100644 --- a/osu.Game/Overlays/OnlineOverlay.cs +++ b/osu.Game/Overlays/OnlineOverlay.cs @@ -77,6 +77,14 @@ namespace osu.Game.Overlays base.Content.Add(mainContent); } + protected override void LoadComplete() + { + base.LoadComplete(); + + // Ensure the scroll-to-top button is displayed above the fixed header. + AddInternal(ScrollFlow.Button.CreateProxy()); + } + protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); diff --git a/osu.Game/Overlays/OverlayActivation.cs b/osu.Game/Overlays/OverlayActivation.cs index 354153734e..68d7ee8ea9 100644 --- a/osu.Game/Overlays/OverlayActivation.cs +++ b/osu.Game/Overlays/OverlayActivation.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Overlays { public enum OverlayActivation diff --git a/osu.Game/Overlays/OverlayScrollContainer.cs b/osu.Game/Overlays/OverlayScrollContainer.cs index 2a615f0e12..9ff0a65652 100644 --- a/osu.Game/Overlays/OverlayScrollContainer.cs +++ b/osu.Game/Overlays/OverlayScrollContainer.cs @@ -31,7 +31,7 @@ namespace osu.Game.Overlays /// private const int button_scroll_position = 200; - protected ScrollBackButton Button; + public ScrollBackButton Button { get; private set; } private readonly Bindable lastScrollTarget = new Bindable(); diff --git a/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs index 1755c12f94..fc354027c1 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/OffsetSettings.cs @@ -18,7 +18,7 @@ namespace osu.Game.Overlays.Settings.Sections.Audio { protected override LocalisableString Header => AudioSettingsStrings.OffsetHeader; - public override IEnumerable FilterTerms => base.FilterTerms.Concat(new LocalisableString[] { "universal", "uo", "timing" }); + public override IEnumerable FilterTerms => base.FilterTerms.Concat(new LocalisableString[] { "universal", "uo", "timing", "delay", "latency" }); [BackgroundDependencyLoader] private void load(OsuConfigManager config) diff --git a/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs b/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs index 982cbec376..cf7f63211e 100644 --- a/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/LanguageSettings.cs @@ -2,35 +2,27 @@ // 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.Localisation; using osu.Game.Configuration; -using osu.Game.Extensions; using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.General { public partial class LanguageSettings : SettingsSubsection { - private SettingsDropdown languageSelection = null!; - private Bindable frameworkLocale = null!; - private IBindable localisationParameters = null!; - protected override LocalisableString Header => GeneralSettingsStrings.LanguageHeader; [BackgroundDependencyLoader] - private void load(FrameworkConfigManager frameworkConfig, OsuConfigManager config, LocalisationManager localisation) + private void load(OsuGameBase game, OsuConfigManager config, FrameworkConfigManager frameworkConfig) { - frameworkLocale = frameworkConfig.GetBindable(FrameworkSetting.Locale); - localisationParameters = localisation.CurrentParameters.GetBoundCopy(); - Children = new Drawable[] { - languageSelection = new SettingsEnumDropdown + new SettingsEnumDropdown { LabelText = GeneralSettingsStrings.LanguageDropdown, + Current = game.CurrentLanguage, }, new SettingsCheckbox { @@ -43,14 +35,6 @@ namespace osu.Game.Overlays.Settings.Sections.General Current = config.GetBindable(OsuSetting.Prefer24HourTime) }, }; - - frameworkLocale.BindValueChanged(_ => updateSelection()); - localisationParameters.BindValueChanged(_ => updateSelection(), true); - - languageSelection.Current.BindValueChanged(val => frameworkLocale.Value = val.NewValue.ToCultureCode()); } - - private void updateSelection() => - languageSelection.Current.Value = LanguageExtensions.GetLanguageFor(frameworkLocale.Value, localisationParameters.Value); } } diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs index 2e26d15105..a3290bc81c 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/LayoutSettings.cs @@ -193,17 +193,18 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics currentDisplay.BindValueChanged(display => Schedule(() => { - resolutions.RemoveRange(1, resolutions.Count - 1); - - if (display.NewValue != null) + if (display.NewValue == null) { - resolutions.AddRange(display.NewValue.DisplayModes - .Where(m => m.Size.Width >= 800 && m.Size.Height >= 600) - .OrderByDescending(m => Math.Max(m.Size.Height, m.Size.Width)) - .Select(m => m.Size) - .Distinct()); + resolutions.Clear(); + return; } + resolutions.ReplaceRange(1, resolutions.Count - 1, display.NewValue.DisplayModes + .Where(m => m.Size.Width >= 800 && m.Size.Height >= 600) + .OrderByDescending(m => Math.Max(m.Size.Height, m.Size.Width)) + .Select(m => m.Size) + .Distinct()); + updateDisplaySettingsVisibility(); }), true); @@ -244,7 +245,8 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics { Scheduler.AddOnce(d => { - displayDropdown.Items = d; + if (!displayDropdown.Items.SequenceEqual(d, DisplayListComparer.DEFAULT)) + displayDropdown.Items = d; updateDisplaySettingsVisibility(); }, displays); } @@ -376,5 +378,43 @@ namespace osu.Game.Overlays.Settings.Sections.Graphics } } } + + /// + /// Contrary to , this comparer disregards the value of . + /// We want to just show a list of displays, and for the purposes of settings we don't care about their bounds when it comes to the list. + /// However, fires even if only the resolution of the current display was changed + /// (because it causes the bounds of all displays to also change). + /// We're not interested in those changes, so compare only the rest that we actually care about. + /// This helps to avoid a bindable/event feedback loop, in which a resolution change + /// would trigger a display "change", which would in turn reset resolution again. + /// + private class DisplayListComparer : IEqualityComparer + { + public static readonly DisplayListComparer DEFAULT = new DisplayListComparer(); + + public bool Equals(Display? x, Display? y) + { + if (ReferenceEquals(x, y)) return true; + if (ReferenceEquals(x, null)) return false; + if (ReferenceEquals(y, null)) return false; + + return x.Index == y.Index + && x.Name == y.Name + && x.DisplayModes.SequenceEqual(y.DisplayModes); + } + + public int GetHashCode(Display obj) + { + var hashCode = new HashCode(); + + hashCode.Add(obj.Index); + hashCode.Add(obj.Name); + hashCode.Add(obj.DisplayModes.Length); + foreach (var displayMode in obj.DisplayModes) + hashCode.Add(displayMode); + + return hashCode.ToHashCode(); + } + } } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs index 951cf3802f..4c9320c2a6 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs @@ -3,6 +3,8 @@ #nullable disable +using System.Collections.Generic; +using System.Linq; using osu.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -23,6 +25,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input { public partial class TabletSettings : SettingsSubsection { + public override IEnumerable FilterTerms => base.FilterTerms.Concat(new LocalisableString[] { "area" }); + public TabletAreaSelection AreaSelection { get; private set; } private readonly ITabletHandler tabletHandler; diff --git a/osu.Game/Overlays/SettingsPanel.cs b/osu.Game/Overlays/SettingsPanel.cs index aefaccdb5d..d7f39a9d8f 100644 --- a/osu.Game/Overlays/SettingsPanel.cs +++ b/osu.Game/Overlays/SettingsPanel.cs @@ -10,15 +10,18 @@ using System.Threading.Tasks; using osuTK; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Settings; +using osuTK.Graphics; namespace osu.Game.Overlays { @@ -105,6 +108,14 @@ namespace osu.Game.Overlays Add(SectionsContainer = new SettingsSectionsContainer { Masking = true, + EdgeEffect = new EdgeEffectParameters + { + Colour = Color4.Black.Opacity(0), + Type = EdgeEffectType.Shadow, + Hollow = true, + Radius = 10 + }, + MaskingSmoothness = 0, RelativeSizeAxes = Axes.Both, ExpandableHeader = CreateHeader(), SelectedSection = { BindTarget = CurrentSection }, @@ -152,10 +163,10 @@ namespace osu.Game.Overlays protected override void PopIn() { - base.PopIn(); - ContentContainer.MoveToX(ExpandedPosition, TRANSITION_LENGTH, Easing.OutQuint); + SectionsContainer.FadeEdgeEffectTo(WaveContainer.SHADOW_OPACITY, WaveContainer.APPEAR_DURATION, Easing.Out); + // delay load enough to ensure it doesn't overlap with the initial animation. // this is done as there is still a brief stutter during load completion which is more visible if the transition is in progress. // the eventual goal would be to remove the need for this by splitting up load into smaller work pieces, or fixing the remaining @@ -175,6 +186,7 @@ namespace osu.Game.Overlays { base.PopOut(); + SectionsContainer.FadeEdgeEffectTo(0, WaveContainer.DISAPPEAR_DURATION, Easing.In); ContentContainer.MoveToX(-WIDTH + ExpandedPosition, TRANSITION_LENGTH, Easing.OutQuint); Sidebar?.MoveToX(-sidebar_width, TRANSITION_LENGTH, Easing.OutQuint); @@ -314,7 +326,7 @@ namespace osu.Game.Overlays base.UpdateAfterChildren(); // no null check because the usage of this class is strict - HeaderBackground.Alpha = -ExpandableHeader.Y / ExpandableHeader.LayoutSize.Y; + HeaderBackground!.Alpha = -ExpandableHeader!.Y / ExpandableHeader.LayoutSize.Y; } } } diff --git a/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs b/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs index 61195d7175..9b021632cf 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditorSceneLibrary.cs @@ -113,7 +113,7 @@ namespace osu.Game.Overlays.SkinEditor if (replayGeneratingMod != null) screen.Push(new PlayerLoader(() => new ReplayPlayer((beatmap, mods) => replayGeneratingMod.CreateScoreFromReplayData(beatmap, mods)))); - }, new[] { typeof(Player), typeof(SongSelect) }) + }, new[] { typeof(Player), typeof(PlaySongSelect) }) }, } }, diff --git a/osu.Game/Overlays/SortDirection.cs b/osu.Game/Overlays/SortDirection.cs index 98ac31103f..3af9614972 100644 --- a/osu.Game/Overlays/SortDirection.cs +++ b/osu.Game/Overlays/SortDirection.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Overlays { public enum SortDirection diff --git a/osu.Game/Overlays/UserProfileOverlay.cs b/osu.Game/Overlays/UserProfileOverlay.cs index d1fe877e55..0ab842c907 100644 --- a/osu.Game/Overlays/UserProfileOverlay.cs +++ b/osu.Game/Overlays/UserProfileOverlay.cs @@ -120,7 +120,7 @@ namespace osu.Game.Overlays if (lastSection != section.NewValue) { lastSection = section.NewValue; - tabs.Current.Value = lastSection; + tabs.Current.Value = lastSection!; } }; @@ -249,12 +249,14 @@ namespace osu.Game.Overlays private partial class ProfileSectionsContainer : SectionsContainer { + private OverlayScrollContainer scroll = null!; + public ProfileSectionsContainer() { RelativeSizeAxes = Axes.Both; } - protected override UserTrackingScrollContainer CreateScrollContainer() => new OverlayScrollContainer(); + protected override UserTrackingScrollContainer CreateScrollContainer() => scroll = new OverlayScrollContainer(); // Reverse child ID is required so expanding beatmap panels can appear above sections below them. // This can also be done by setting Depth when adding new sections above if using ReverseChildID turns out to have any issues. @@ -267,6 +269,14 @@ namespace osu.Game.Overlays Padding = new MarginPadding { Horizontal = 10 }, Margin = new MarginPadding { Bottom = 10 }, }; + + protected override void LoadComplete() + { + base.LoadComplete(); + + // Ensure the scroll-to-top button is displayed above the fixed header. + AddInternal(scroll.Button.CreateProxy()); + } } } } diff --git a/osu.Game/Overlays/WaveOverlayContainer.cs b/osu.Game/Overlays/WaveOverlayContainer.cs index 00474cc0d8..34fbec93b7 100644 --- a/osu.Game/Overlays/WaveOverlayContainer.cs +++ b/osu.Game/Overlays/WaveOverlayContainer.cs @@ -34,8 +34,6 @@ namespace osu.Game.Overlays protected override void PopIn() { - base.PopIn(); - Waves.Show(); this.FadeIn(100, Easing.OutQuint); } diff --git a/osu.Game/Rulesets/Configuration/IRulesetConfigManager.cs b/osu.Game/Rulesets/Configuration/IRulesetConfigManager.cs index af315bfb28..5a3ad5e786 100644 --- a/osu.Game/Rulesets/Configuration/IRulesetConfigManager.cs +++ b/osu.Game/Rulesets/Configuration/IRulesetConfigManager.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using osu.Framework.Configuration.Tracking; diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index 8dd1b51cae..00c90bd317 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -137,7 +137,7 @@ namespace osu.Game.Rulesets.Difficulty foreach (var combination in CreateDifficultyAdjustmentModCombinations()) { - Mod classicMod = rulesetInstance.CreateAllMods().SingleOrDefault(m => m is ModClassic); + Mod classicMod = rulesetInstance.CreateMod(); var finalCombination = ModUtils.FlattenMod(combination); if (classicMod != null) diff --git a/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs b/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs index 4f802a22a1..64a04f896f 100644 --- a/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/PerformanceBreakdownCalculator.cs @@ -62,11 +62,13 @@ namespace osu.Game.Rulesets.Difficulty .GroupBy(hr => hr, (hr, list) => (hitResult: hr, count: list.Count())) .ToDictionary(pair => pair.hitResult, pair => pair.count); perfectPlay.Statistics = statistics; + perfectPlay.MaximumStatistics = statistics; // calculate total score ScoreProcessor scoreProcessor = ruleset.CreateScoreProcessor(); scoreProcessor.Mods.Value = perfectPlay.Mods; - perfectPlay.TotalScore = scoreProcessor.ComputeScore(ScoringMode.Standardised, perfectPlay); + scoreProcessor.ApplyBeatmap(playableBeatmap); + perfectPlay.TotalScore = scoreProcessor.MaximumTotalScore; // compute rank achieved // default to SS, then adjust the rank with mods diff --git a/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs b/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs index 23fa28e7bc..8c3a5c026d 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckBackgroundQuality.cs @@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Edit.Checks if (backgroundFile == null) yield break; - var texture = context.WorkingBeatmap.Background; + var texture = context.WorkingBeatmap.GetBackground(); if (texture == null) yield break; diff --git a/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs b/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs index a8972775de..817e8bd5fe 100644 --- a/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs @@ -125,6 +125,7 @@ namespace osu.Game.Rulesets.Edit if (currentSnap > DistanceSpacingMultiplier.MinValue) { currentDistanceSpacingButton.Enabled.Value = currentDistanceSpacingButton.Expanded.Value + && !DistanceSpacingMultiplier.Disabled && !Precision.AlmostEquals(currentSnap, DistanceSpacingMultiplier.Value, DistanceSpacingMultiplier.Precision / 2); currentDistanceSpacingButton.ContractedLabelText = $"current {currentSnap:N2}x"; currentDistanceSpacingButton.ExpandedLabelText = $"Use current ({currentSnap:N2}x)"; @@ -141,28 +142,31 @@ namespace osu.Game.Rulesets.Edit { base.LoadComplete(); - if (!DistanceSpacingMultiplier.Disabled) + if (DistanceSpacingMultiplier.Disabled) { - DistanceSpacingMultiplier.Value = EditorBeatmap.BeatmapInfo.DistanceSpacing; - DistanceSpacingMultiplier.BindValueChanged(multiplier => - { - distanceSpacingSlider.ContractedLabelText = $"D. S. ({multiplier.NewValue:0.##x})"; - distanceSpacingSlider.ExpandedLabelText = $"Distance Spacing ({multiplier.NewValue:0.##x})"; - - if (multiplier.NewValue != multiplier.OldValue) - onScreenDisplay?.Display(new DistanceSpacingToast(multiplier.NewValue.ToLocalisableString(@"0.##x"), multiplier)); - - EditorBeatmap.BeatmapInfo.DistanceSpacing = multiplier.NewValue; - }, true); - - // Manual binding to handle enabling distance spacing when the slider is interacted with. - distanceSpacingSlider.Current.BindValueChanged(spacing => - { - DistanceSpacingMultiplier.Value = spacing.NewValue; - DistanceSnapToggle.Value = TernaryState.True; - }); - DistanceSpacingMultiplier.BindValueChanged(spacing => distanceSpacingSlider.Current.Value = spacing.NewValue); + distanceSpacingSlider.Hide(); + return; } + + DistanceSpacingMultiplier.Value = EditorBeatmap.BeatmapInfo.DistanceSpacing; + DistanceSpacingMultiplier.BindValueChanged(multiplier => + { + distanceSpacingSlider.ContractedLabelText = $"D. S. ({multiplier.NewValue:0.##x})"; + distanceSpacingSlider.ExpandedLabelText = $"Distance Spacing ({multiplier.NewValue:0.##x})"; + + if (multiplier.NewValue != multiplier.OldValue) + onScreenDisplay?.Display(new DistanceSpacingToast(multiplier.NewValue.ToLocalisableString(@"0.##x"), multiplier)); + + EditorBeatmap.BeatmapInfo.DistanceSpacing = multiplier.NewValue; + }, true); + + // Manual binding to handle enabling distance spacing when the slider is interacted with. + distanceSpacingSlider.Current.BindValueChanged(spacing => + { + DistanceSpacingMultiplier.Value = spacing.NewValue; + DistanceSnapToggle.Value = TernaryState.True; + }); + DistanceSpacingMultiplier.BindValueChanged(spacing => distanceSpacingSlider.Current.Value = spacing.NewValue); } protected override IEnumerable CreateTernaryButtons() => base.CreateTernaryButtons().Concat(new[] diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index e2dbd2acdc..6fcb8f62ee 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -77,6 +77,8 @@ namespace osu.Game.Rulesets.Edit private FillFlowContainer togglesCollection; + private FillFlowContainer sampleBankTogglesCollection; + private IBindable hasTiming; private Bindable autoSeekOnPlacement; @@ -160,6 +162,16 @@ namespace osu.Game.Rulesets.Edit Direction = FillDirection.Vertical, Spacing = new Vector2(0, 5), }, + }, + new EditorToolboxGroup("bank (Shift-Q~R)") + { + Child = sampleBankTogglesCollection = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + }, } } }, @@ -197,6 +209,8 @@ namespace osu.Game.Rulesets.Edit TernaryStates = CreateTernaryButtons().ToArray(); togglesCollection.AddRange(TernaryStates.Select(b => new DrawableTernaryButton(b))); + sampleBankTogglesCollection.AddRange(BlueprintContainer.SampleBankTernaryStates.Select(b => new DrawableTernaryButton(b))); + setSelectTool(); EditorBeatmap.SelectedHitObjects.CollectionChanged += selectionChanged; @@ -249,7 +263,7 @@ namespace osu.Game.Rulesets.Edit /// /// Create all ternary states required to be displayed to the user. /// - protected virtual IEnumerable CreateTernaryButtons() => BlueprintContainer.TernaryStates; + protected virtual IEnumerable CreateTernaryButtons() => BlueprintContainer.MainTernaryStates; /// /// Construct a relevant blueprint container. This will manage hitobject selection/placement input handling and display logic. @@ -274,7 +288,7 @@ namespace osu.Game.Rulesets.Edit protected override bool OnKeyDown(KeyDownEvent e) { - if (e.ControlPressed || e.AltPressed || e.SuperPressed || e.ShiftPressed) + if (e.ControlPressed || e.AltPressed || e.SuperPressed) return false; if (checkLeftToggleFromKey(e.Key, out int leftIndex)) @@ -291,7 +305,9 @@ namespace osu.Game.Rulesets.Edit if (checkRightToggleFromKey(e.Key, out int rightIndex)) { - var item = togglesCollection.ElementAtOrDefault(rightIndex); + var item = e.ShiftPressed + ? sampleBankTogglesCollection.ElementAtOrDefault(rightIndex) + : togglesCollection.ElementAtOrDefault(rightIndex); if (item is DrawableTernaryButton button) { @@ -428,7 +444,7 @@ namespace osu.Game.Rulesets.Edit var playfield = PlayfieldAtScreenSpacePosition(screenSpacePosition); double? targetTime = null; - if (snapType.HasFlagFast(SnapType.Grids)) + if (snapType.HasFlagFast(SnapType.GlobalGrids)) { if (playfield is ScrollingPlayfield scrollingPlayfield) { diff --git a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs index bdcb334738..717c026ded 100644 --- a/osu.Game/Rulesets/Edit/PlacementBlueprint.cs +++ b/osu.Game/Rulesets/Edit/PlacementBlueprint.cs @@ -1,19 +1,20 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Linq; using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Input.Bindings; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose; using osuTK; @@ -24,35 +25,51 @@ namespace osu.Game.Rulesets.Edit /// /// A blueprint which governs the creation of a new to actualisation. /// - public abstract partial class PlacementBlueprint : CompositeDrawable + public abstract partial class PlacementBlueprint : CompositeDrawable, IKeyBindingHandler { /// /// Whether the is currently mid-placement, but has not necessarily finished being placed. /// public PlacementState PlacementActive { get; private set; } + /// + /// Whether the sample bank should be taken from the previous hit object. + /// + public bool AutomaticBankAssignment { get; set; } + /// /// The that is being placed. /// public readonly HitObject HitObject; - [Resolved(canBeNull: true)] - protected EditorClock EditorClock { get; private set; } + [Resolved] + protected EditorClock EditorClock { get; private set; } = null!; [Resolved] - private EditorBeatmap beatmap { get; set; } + private EditorBeatmap beatmap { get; set; } = null!; - private Bindable startTimeBindable; + private Bindable startTimeBindable = null!; + + private HitObject? getPreviousHitObject() => beatmap.HitObjects.TakeWhile(h => h.StartTime <= startTimeBindable.Value).LastOrDefault(); [Resolved] - private IPlacementHandler placementHandler { get; set; } + private IPlacementHandler placementHandler { get; set; } = null!; + + /// + /// Whether this blueprint is currently in a state that can be committed. + /// + /// + /// Override this with any preconditions that should be double-checked on committing. + /// If false is returned and a commit is attempted, the blueprint will be destroyed instead. + /// + protected virtual bool IsValidForPlacement => true; protected PlacementBlueprint(HitObject hitObject) { HitObject = hitObject; // adding the default hit sample should be the case regardless of the ruleset. - HitObject.Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK, volume: 100)); + HitObject.Samples.Add(new HitSampleInfo(HitSampleInfo.HIT_NORMAL)); RelativeSizeAxes = Axes.Both; @@ -74,11 +91,6 @@ namespace osu.Game.Rulesets.Edit /// Whether this call is committing a value for HitObject.StartTime and continuing with further adjustments. protected void BeginPlacement(bool commitStart = false) { - // Take the hitnormal sample of the last hit object - var lastHitNormal = beatmap.HitObjects.LastOrDefault(h => h.GetEndTime() < HitObject.StartTime)?.Samples?.FirstOrDefault(o => o.Name == HitSampleInfo.HIT_NORMAL); - if (lastHitNormal != null) - HitObject.Samples[0] = lastHitNormal; - placementHandler.BeginPlacement(HitObject); if (commitStart) PlacementActive = PlacementState.Active; @@ -88,7 +100,7 @@ namespace osu.Game.Rulesets.Edit /// Signals that the placement of has finished. /// This will destroy this , and add the HitObject.StartTime to the . /// - /// Whether the object should be committed. + /// Whether the object should be committed. Note that a commit may fail if is false. public void EndPlacement(bool commit) { switch (PlacementActive) @@ -102,10 +114,34 @@ namespace osu.Game.Rulesets.Edit break; } - placementHandler.EndPlacement(HitObject, commit); + placementHandler.EndPlacement(HitObject, IsValidForPlacement && commit); PlacementActive = PlacementState.Finished; } + public bool OnPressed(KeyBindingPressEvent e) + { + if (PlacementActive == PlacementState.Waiting) + return false; + + switch (e.Action) + { + case GlobalAction.Select: + EndPlacement(true); + return true; + + case GlobalAction.Back: + EndPlacement(false); + return true; + + default: + return false; + } + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + /// /// Updates the time and position of this based on the provided snap information. /// @@ -113,7 +149,20 @@ namespace osu.Game.Rulesets.Edit public virtual void UpdateTimeAndPosition(SnapResult result) { if (PlacementActive == PlacementState.Waiting) - HitObject.StartTime = result.Time ?? EditorClock?.CurrentTime ?? Time.Current; + { + HitObject.StartTime = result.Time ?? EditorClock.CurrentTime; + + if (HitObject is IHasComboInformation comboInformation) + comboInformation.UpdateComboInformation(getPreviousHitObject() as IHasComboInformation); + } + + if (AutomaticBankAssignment) + { + // Take the hitnormal sample of the last hit object + var lastHitNormal = getPreviousHitObject()?.Samples?.FirstOrDefault(o => o.Name == HitSampleInfo.HIT_NORMAL); + if (lastHitNormal != null) + HitObject.Samples[0] = lastHitNormal; + } } /// diff --git a/osu.Game/Rulesets/Edit/SnapType.cs b/osu.Game/Rulesets/Edit/SnapType.cs index 6eb46457c8..f5f9ab0437 100644 --- a/osu.Game/Rulesets/Edit/SnapType.cs +++ b/osu.Game/Rulesets/Edit/SnapType.cs @@ -11,8 +11,24 @@ namespace osu.Game.Rulesets.Edit public enum SnapType { None = 0, + + /// + /// Snapping to visible nearby objects. + /// NearbyObjects = 1 << 0, - Grids = 1 << 1, - All = NearbyObjects | Grids, + + /// + /// Grids which are global to the playfield. + /// + GlobalGrids = 1 << 1, + + /// + /// Grids which are relative to other nearby hit objects. + /// + RelativeGrids = 1 << 2, + + AllGrids = RelativeGrids | GlobalGrids, + + All = NearbyObjects | GlobalGrids | RelativeGrids, } } diff --git a/osu.Game/Rulesets/Judgements/JudgementResult.cs b/osu.Game/Rulesets/Judgements/JudgementResult.cs index bf29919e34..34d1f1f6e9 100644 --- a/osu.Game/Rulesets/Judgements/JudgementResult.cs +++ b/osu.Game/Rulesets/Judgements/JudgementResult.cs @@ -64,6 +64,11 @@ namespace osu.Game.Rulesets.Judgements /// public int ComboAtJudgement { get; internal set; } + /// + /// The combo after this occurred. + /// + public int ComboAfterJudgement { get; internal set; } + /// /// The highest combo achieved prior to this occurring. /// diff --git a/osu.Game/Rulesets/Mods/IHasNoTimedInputs.cs b/osu.Game/Rulesets/Mods/IHasNoTimedInputs.cs new file mode 100644 index 0000000000..c0d709ad4a --- /dev/null +++ b/osu.Game/Rulesets/Mods/IHasNoTimedInputs.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.Rulesets.Mods +{ + /// + /// Denotes a mod which removes timed inputs from a ruleset which would usually have them. + /// + /// + /// This will be used, for instance, to omit showing offset calibration UI post-gameplay. + /// + public interface IHasNoTimedInputs + { + } +} diff --git a/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs b/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs index d4223a80c2..0072c21053 100644 --- a/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs +++ b/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs @@ -7,6 +7,7 @@ using System.Linq; using osu.Framework.Bindables; using osu.Framework.Localisation; using osu.Game.Configuration; +using osu.Game.Localisation.HUD; using osu.Game.Overlays.Settings; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Judgements; @@ -42,30 +43,44 @@ namespace osu.Game.Rulesets.Mods Value = 0.9, }; - private ScoreProcessor scoreProcessor = null!; + [SettingSource("Accuracy mode", "The mode of accuracy that will trigger failure.")] + public Bindable AccuracyJudgeMode { get; } = new Bindable(); - public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) => this.scoreProcessor = scoreProcessor; + private readonly Bindable currentAccuracy = new Bindable(); + + public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) + { + switch (AccuracyJudgeMode.Value) + { + case AccuracyMode.Standard: + currentAccuracy.BindTo(scoreProcessor.Accuracy); + break; + + case AccuracyMode.MaximumAchievable: + currentAccuracy.BindTo(scoreProcessor.MaximumAccuracy); + break; + } + + currentAccuracy.BindValueChanged(s => + { + if (s.NewValue < MinimumAccuracy.Value) + { + TriggerFailure(); + } + }); + } public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank; - protected override bool FailCondition(HealthProcessor healthProcessor, JudgementResult result) + protected override bool FailCondition(HealthProcessor healthProcessor, JudgementResult result) => false; + + public enum AccuracyMode { - if (!result.Type.AffectsAccuracy()) - return false; + [LocalisableDescription(typeof(GameplayAccuracyCounterStrings), nameof(GameplayAccuracyCounterStrings.AccuracyDisplayModeMax))] + MaximumAchievable, - return getAccuracyWithImminentResultAdded(result) < MinimumAccuracy.Value; - } - - private double getAccuracyWithImminentResultAdded(JudgementResult result) - { - var score = new ScoreInfo { Ruleset = scoreProcessor.Ruleset.RulesetInfo }; - - // This is super ugly, but if we don't do it this way we will not have the most recent result added to the accuracy value. - // Hopefully we can improve this in the future. - scoreProcessor.PopulateScore(score); - score.Statistics[result.Type]++; - - return scoreProcessor.ComputeAccuracy(score); + [LocalisableDescription(typeof(GameplayAccuracyCounterStrings), nameof(GameplayAccuracyCounterStrings.AccuracyDisplayModeStandard))] + Standard, } } } diff --git a/osu.Game/Rulesets/Objects/Drawables/ArmedState.cs b/osu.Game/Rulesets/Objects/Drawables/ArmedState.cs index 4faf0920d1..b2d9f50602 100644 --- a/osu.Game/Rulesets/Objects/Drawables/ArmedState.cs +++ b/osu.Game/Rulesets/Objects/Drawables/ArmedState.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Rulesets.Objects.Drawables { public enum ArmedState diff --git a/osu.Game/Rulesets/Objects/HitObject.cs b/osu.Game/Rulesets/Objects/HitObject.cs index a4cb976d50..ed3d3a6eb2 100644 --- a/osu.Game/Rulesets/Objects/HitObject.cs +++ b/osu.Game/Rulesets/Objects/HitObject.cs @@ -206,14 +206,20 @@ namespace osu.Game.Rulesets.Objects } /// - /// Create a SampleInfo based on the sample settings of the hit normal sample in . + /// Create a based on the sample settings of the first sample in . + /// If no sample is available, sane default settings will be used instead. /// + /// + /// In the case an existing sample exists, all settings apart from the sample name will be inherited. This includes volume, bank and suffix. + /// /// The name of the sample. /// A populated . - protected HitSampleInfo GetSampleInfo(string sampleName = HitSampleInfo.HIT_NORMAL) + public HitSampleInfo CreateHitSampleInfo(string sampleName = HitSampleInfo.HIT_NORMAL) { - var hitnormalSample = Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL); - return hitnormalSample == null ? new HitSampleInfo(sampleName) : hitnormalSample.With(newName: sampleName); + if (Samples.FirstOrDefault(s => s.Name == HitSampleInfo.HIT_NORMAL) is HitSampleInfo existingSample) + return existingSample.With(newName: sampleName); + + return new HitSampleInfo(sampleName); } } diff --git a/osu.Game/Rulesets/Objects/IBarLine.cs b/osu.Game/Rulesets/Objects/IBarLine.cs index 8cdead6776..14df80e3b9 100644 --- a/osu.Game/Rulesets/Objects/IBarLine.cs +++ b/osu.Game/Rulesets/Objects/IBarLine.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Rulesets.Objects { /// diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs index ba5de6c14b..8eda2a8f61 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertHitObjectParser.cs @@ -14,6 +14,7 @@ using System.Linq; using JetBrains.Annotations; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Utils; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Legacy; using osu.Game.Skinning; using osu.Game.Utils; @@ -446,9 +447,9 @@ namespace osu.Game.Rulesets.Objects.Legacy if (string.IsNullOrEmpty(bankInfo.Filename)) { soundTypes.Add(new LegacyHitSampleInfo(HitSampleInfo.HIT_NORMAL, bankInfo.BankForNormal, bankInfo.Volume, bankInfo.CustomSampleBank, - // if the sound type doesn't have the Normal flag set, attach it anyway as a layered sample. - // None also counts as a normal non-layered sample: https://osu.ppy.sh/help/wiki/osu!_File_Formats/Osu_(file_format)#hitsounds - type != LegacyHitSoundType.None && !type.HasFlagFast(LegacyHitSoundType.Normal))); + // if the sound type doesn't have the Normal flag set, attach it anyway as a layered sample. + // None also counts as a normal non-layered sample: https://osu.ppy.sh/help/wiki/osu!_File_Formats/Osu_(file_format)#hitsounds + type != LegacyHitSoundType.None && !type.HasFlagFast(LegacyHitSoundType.Normal))); } else { @@ -479,12 +480,14 @@ namespace osu.Game.Rulesets.Objects.Legacy /// The bank identifier to use for the base ("hitnormal") sample. /// Transferred to when appropriate. /// + [CanBeNull] public string BankForNormal; /// /// The bank identifier to use for additions ("hitwhistle", "hitfinish", "hitclap"). /// Transferred to when appropriate. /// + [CanBeNull] public string BankForAdditions; /// @@ -518,17 +521,24 @@ namespace osu.Game.Rulesets.Objects.Legacy /// public readonly bool IsLayered; + /// + /// Whether a bank was specified locally to the relevant hitobject. + /// If false, a bank will be retrieved from the closest control point. + /// + public bool BankSpecified; + public LegacyHitSampleInfo(string name, string? bank = null, int volume = 0, int customSampleBank = 0, bool isLayered = false) - : base(name, bank, customSampleBank >= 2 ? customSampleBank.ToString() : null, volume) + : base(name, bank ?? SampleControlPoint.DEFAULT_BANK, customSampleBank >= 2 ? customSampleBank.ToString() : null, volume) { CustomSampleBank = customSampleBank; + BankSpecified = !string.IsNullOrEmpty(bank); IsLayered = isLayered; } - public sealed override HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default) + public sealed override HitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newSuffix = default, Optional newVolume = default) => With(newName, newBank, newVolume); - public virtual LegacyHitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newVolume = default, + public virtual LegacyHitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newVolume = default, Optional newCustomSampleBank = default, Optional newIsLayered = default) => new LegacyHitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newVolume.GetOr(Volume), newCustomSampleBank.GetOr(CustomSampleBank), newIsLayered.GetOr(IsLayered)); @@ -563,7 +573,7 @@ namespace osu.Game.Rulesets.Objects.Legacy Path.ChangeExtension(Filename, null) }; - public sealed override LegacyHitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newVolume = default, + public sealed override LegacyHitSampleInfo With(Optional newName = default, Optional newBank = default, Optional newVolume = default, Optional newCustomSampleBank = default, Optional newIsLayered = default) => new FileHitSampleInfo(Filename, newVolume.GetOr(Volume)); @@ -576,7 +586,5 @@ namespace osu.Game.Rulesets.Objects.Legacy public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), Filename); } - -#nullable disable } } diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHit.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHit.cs index 639cacb128..0b69817c13 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHit.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertHit.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Rulesets.Objects.Legacy.Mania diff --git a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSlider.cs index 330ebf72c7..84cde5fa95 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Mania/ConvertSlider.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Game.Rulesets.Objects.Types; namespace osu.Game.Rulesets.Objects.Legacy.Mania diff --git a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHit.cs b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHit.cs index 980d37ccd5..cb5178ce48 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHit.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertHit.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Rulesets.Objects.Legacy.Taiko { /// diff --git a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSlider.cs index a391c8cb43..821554f7ee 100644 --- a/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/Taiko/ConvertSlider.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Rulesets.Objects.Legacy.Taiko { /// diff --git a/osu.Game/Rulesets/Objects/Types/IHasColumn.cs b/osu.Game/Rulesets/Objects/Types/IHasColumn.cs index 3978a7e765..dc07cfbb6a 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasColumn.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasColumn.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Rulesets.Objects.Types { /// diff --git a/osu.Game/Rulesets/Objects/Types/IHasCombo.cs b/osu.Game/Rulesets/Objects/Types/IHasCombo.cs index d02b97a3e4..d1a4683a1d 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasCombo.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasCombo.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Rulesets.Objects.Types { /// diff --git a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs index b45ea989f3..d34e71021f 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasComboInformation.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using osu.Framework.Bindables; using osu.Game.Skinning; using osuTK.Graphics; @@ -65,5 +63,26 @@ namespace osu.Game.Rulesets.Objects.Types { return skin.GetConfig(new SkinComboColourLookup(comboIndex, combo))?.Value ?? Color4.White; } + + /// + /// Given the previous object in the beatmap, update relevant combo information. + /// + /// The previous hitobject, or null if this is the first object in the beatmap. + void UpdateComboInformation(IHasComboInformation? lastObj) + { + ComboIndex = lastObj?.ComboIndex ?? 0; + ComboIndexWithOffsets = lastObj?.ComboIndexWithOffsets ?? 0; + IndexInCurrentCombo = (lastObj?.IndexInCurrentCombo + 1) ?? 0; + + if (NewCombo || lastObj == null) + { + IndexInCurrentCombo = 0; + ComboIndex++; + ComboIndexWithOffsets += ComboOffset + 1; + + if (lastObj != null) + lastObj.LastInCombo = true; + } + } } } diff --git a/osu.Game/Rulesets/Objects/Types/IHasDistance.cs b/osu.Game/Rulesets/Objects/Types/IHasDistance.cs index 549abc046a..b497ca5da3 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasDistance.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasDistance.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Rulesets.Objects.Types { /// diff --git a/osu.Game/Rulesets/Objects/Types/IHasDuration.cs b/osu.Game/Rulesets/Objects/Types/IHasDuration.cs index 06ed8eba76..ca734da5ad 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasDuration.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasDuration.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Rulesets.Objects.Types { /// diff --git a/osu.Game/Rulesets/Objects/Types/IHasHold.cs b/osu.Game/Rulesets/Objects/Types/IHasHold.cs index 91b05dc3fd..469b8b7892 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasHold.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasHold.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Rulesets.Objects.Types { /// diff --git a/osu.Game/Rulesets/Objects/Types/IHasLegacyLastTickOffset.cs b/osu.Game/Rulesets/Objects/Types/IHasLegacyLastTickOffset.cs index dfc526383a..caf22c3023 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasLegacyLastTickOffset.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasLegacyLastTickOffset.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Rulesets.Objects.Types { /// diff --git a/osu.Game/Rulesets/Objects/Types/IHasXPosition.cs b/osu.Game/Rulesets/Objects/Types/IHasXPosition.cs index f688c783e1..7e55b21050 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasXPosition.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasXPosition.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Rulesets.Objects.Types { /// diff --git a/osu.Game/Rulesets/Objects/Types/IHasYPosition.cs b/osu.Game/Rulesets/Objects/Types/IHasYPosition.cs index 3c0cc595fb..d2561b10a7 100644 --- a/osu.Game/Rulesets/Objects/Types/IHasYPosition.cs +++ b/osu.Game/Rulesets/Objects/Types/IHasYPosition.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Rulesets.Objects.Types { /// diff --git a/osu.Game/Rulesets/Objects/Types/PathType.cs b/osu.Game/Rulesets/Objects/Types/PathType.cs index 266a3de6ec..923ce9eba4 100644 --- a/osu.Game/Rulesets/Objects/Types/PathType.cs +++ b/osu.Game/Rulesets/Objects/Types/PathType.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Rulesets.Objects.Types { public enum PathType diff --git a/osu.Game/Rulesets/Ruleset.cs b/osu.Game/Rulesets/Ruleset.cs index a77068eb14..490ec1475c 100644 --- a/osu.Game/Rulesets/Ruleset.cs +++ b/osu.Game/Rulesets/Ruleset.cs @@ -321,8 +321,8 @@ namespace osu.Game.Rulesets /// /// The to create the statistics for. The score is guaranteed to have populated. /// The , converted for this with all relevant s applied. - /// The s to display. Each may contain 0 or more . - public virtual StatisticRow[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => Array.Empty(); + /// The s to display. + public virtual StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatmap playableBeatmap) => Array.Empty(); /// /// Get all valid s for this ruleset. diff --git a/osu.Game/Rulesets/Scoring/HitResult.cs b/osu.Game/Rulesets/Scoring/HitResult.cs index 83ed98768c..0013a9f20d 100644 --- a/osu.Game/Rulesets/Scoring/HitResult.cs +++ b/osu.Game/Rulesets/Scoring/HitResult.cs @@ -150,7 +150,7 @@ namespace osu.Game.Rulesets.Scoring => AffectsCombo(result) && !IsHit(result); /// - /// Whether a increases/breaks the combo, and affects the combo portion of the score. + /// Whether a increases or breaks the combo. /// public static bool AffectsCombo(this HitResult result) { diff --git a/osu.Game/Rulesets/Scoring/JudgementProcessor.cs b/osu.Game/Rulesets/Scoring/JudgementProcessor.cs index 09b5f0a6bc..b16c307206 100644 --- a/osu.Game/Rulesets/Scoring/JudgementProcessor.cs +++ b/osu.Game/Rulesets/Scoring/JudgementProcessor.cs @@ -30,6 +30,11 @@ namespace osu.Game.Rulesets.Scoring /// protected int MaxHits { get; private set; } + /// + /// Whether is currently running. + /// + protected bool IsSimulating { get; private set; } + /// /// The total number of judged s at the current point in time. /// @@ -146,6 +151,8 @@ namespace osu.Game.Rulesets.Scoring /// The to simulate. protected virtual void SimulateAutoplay(IBeatmap beatmap) { + IsSimulating = true; + foreach (var obj in beatmap.HitObjects) simulate(obj); @@ -163,6 +170,8 @@ namespace osu.Game.Rulesets.Scoring result.Type = GetSimulatedHitResult(judgement); ApplyResult(result); } + + IsSimulating = false; } protected override void Update() diff --git a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs index 96f6922224..35a7dfe369 100644 --- a/osu.Game/Rulesets/Scoring/ScoreProcessor.cs +++ b/osu.Game/Rulesets/Scoring/ScoreProcessor.cs @@ -4,11 +4,10 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Diagnostics.Contracts; using System.Linq; +using MessagePack; using osu.Framework.Bindables; using osu.Framework.Localisation; -using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Extensions; using osu.Game.Localisation; @@ -22,6 +21,8 @@ namespace osu.Game.Rulesets.Scoring { public partial class ScoreProcessor : JudgementProcessor { + public const double MAX_SCORE = 1000000; + private const double accuracy_cutoff_x = 1; private const double accuracy_cutoff_s = 0.95; private const double accuracy_cutoff_a = 0.9; @@ -29,7 +30,13 @@ namespace osu.Game.Rulesets.Scoring private const double accuracy_cutoff_c = 0.7; private const double accuracy_cutoff_d = 0; - private const double max_score = 1000000; + /// + /// Whether should be populated during application of results. + /// + /// + /// Should only be disabled for special cases. + /// When disabled, cannot be used. + internal bool TrackHitEvents = true; /// /// Invoked when this was reset from a replay frame. @@ -78,39 +85,72 @@ namespace osu.Game.Rulesets.Scoring /// public readonly BindableInt HighestCombo = new BindableInt(); - /// - /// The used to calculate scores. - /// - public readonly Bindable Mode = new Bindable(); - /// /// The s collected during gameplay thus far. /// Intended for use with various statistics displays. /// public IReadOnlyList HitEvents => hitEvents; - /// - /// The default portion of awarded for hitting s accurately. Defaults to 30%. - /// - protected virtual double DefaultAccuracyPortion => 0.3; - - /// - /// The default portion of awarded for achieving a high combo. Default to 70%. - /// - protected virtual double DefaultComboPortion => 0.7; - - /// - /// An arbitrary multiplier to scale scores in the scoring mode. - /// - protected virtual double ClassicScoreMultiplier => 36; - /// /// The ruleset this score processor is valid for. /// public readonly Ruleset Ruleset; - private readonly double accuracyPortion; - private readonly double comboPortion; + /// + /// The maximum achievable total score. + /// + public long MaximumTotalScore { get; private set; } + + /// + /// The maximum sum of accuracy-affecting judgements at the current point in time. + /// + /// + /// Used to compute accuracy. + /// + private double currentMaximumBaseScore; + + /// + /// The sum of all accuracy-affecting judgements at the current point in time. + /// + /// + /// Used to compute accuracy. + /// + private double currentBaseScore; + + /// + /// The maximum sum of all accuracy-affecting judgements in the beatmap. + /// + private double maximumBaseScore; + + /// + /// The count of all accuracy-affecting judgements in the beatmap. + /// + private int maximumAccuracyJudgementCount; + + /// + /// The count of accuracy-affecting judgements at the current point in time. + /// + private int currentAccuracyJudgementCount; + + /// + /// The maximum combo score in the beatmap. + /// + private double maximumComboPortion; + + /// + /// The combo score at the current point in time. + /// + private double currentComboPortion; + + /// + /// The bonus score at the current point in time. + /// + private double currentBonusPortion; + + /// + /// The total score multiplier. + /// + private double scoreMultiplier = 1; public Dictionary MaximumStatistics { @@ -123,27 +163,6 @@ namespace osu.Game.Rulesets.Scoring } } - private ScoringValues maximumScoringValues; - - /// - /// Scoring values for the current play assuming all perfect hits. - /// - /// - /// This is only used to determine the accuracy with respect to the current point in time for an ongoing play session. - /// - private ScoringValues currentMaximumScoringValues; - - /// - /// Scoring values for the current play. - /// - private ScoringValues currentScoringValues; - - /// - /// The maximum of a basic (non-tick and non-bonus) hitobject. - /// Only populated via or . - /// - private HitResult? maxBasicResult; - private bool beatmapApplied; private readonly Dictionary scoreResultCounts = new Dictionary(); @@ -152,18 +171,10 @@ namespace osu.Game.Rulesets.Scoring private readonly List hitEvents = new List(); private HitObject? lastHitObject; - private double scoreMultiplier = 1; - public ScoreProcessor(Ruleset ruleset) { Ruleset = ruleset; - accuracyPortion = DefaultAccuracyPortion; - comboPortion = DefaultComboPortion; - - if (!Precision.AlmostEquals(1.0, accuracyPortion + comboPortion)) - throw new InvalidOperationException($"{nameof(DefaultAccuracyPortion)} + {nameof(DefaultComboPortion)} must equal 1."); - Combo.ValueChanged += combo => HighestCombo.Value = Math.Max(HighestCombo.Value, combo.NewValue); Accuracy.ValueChanged += accuracy => { @@ -172,7 +183,6 @@ namespace osu.Game.Rulesets.Scoring Rank.Value = mod.AdjustRank(Rank.Value, accuracy.NewValue); }; - Mode.ValueChanged += _ => updateScore(); Mods.ValueChanged += mods => { scoreMultiplier = 1; @@ -200,10 +210,6 @@ namespace osu.Game.Rulesets.Scoring scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) + 1; - // Always update the maximum scoring values. - applyResult(result.Judgement.MaxResult, ref currentMaximumScoringValues); - currentMaximumScoringValues.MaxCombo += result.Judgement.MaxResult.IncreasesCombo() ? 1 : 0; - if (!result.Type.IsScorable()) return; @@ -212,27 +218,32 @@ namespace osu.Game.Rulesets.Scoring else if (result.Type.BreaksCombo()) Combo.Value = 0; - applyResult(result.Type, ref currentScoringValues); - currentScoringValues.MaxCombo = HighestCombo.Value; + result.ComboAfterJudgement = Combo.Value; - hitEvents.Add(CreateHitEvent(result)); - lastHitObject = result.HitObject; + if (result.Type.AffectsAccuracy()) + { + currentMaximumBaseScore += Judgement.ToNumericResult(result.Judgement.MaxResult); + currentBaseScore += Judgement.ToNumericResult(result.Type); + currentAccuracyJudgementCount++; + } - updateScore(); - } - - private static void applyResult(HitResult result, ref ScoringValues scoringValues) - { - if (!result.IsScorable()) - return; - - if (result.IsBonus()) - scoringValues.BonusScore += result.IsHit() ? Judgement.ToNumericResult(result) : 0; + if (result.Type.IsBonus()) + currentBonusPortion += GetBonusScoreChange(result); else - scoringValues.BaseScore += result.IsHit() ? Judgement.ToNumericResult(result) : 0; + currentComboPortion += GetComboScoreChange(result); - if (result.IsBasic()) - scoringValues.CountBasicHitObjects++; + ApplyScoreChange(result); + + if (!IsSimulating) + { + if (TrackHitEvents) + { + hitEvents.Add(CreateHitEvent(result)); + lastHitObject = result.HitObject; + } + + updateScore(); + } } /// @@ -245,6 +256,9 @@ namespace osu.Game.Rulesets.Scoring protected sealed override void RevertResultInternal(JudgementResult result) { + if (!TrackHitEvents) + throw new InvalidOperationException(@$"Rewind is not supported when {nameof(TrackHitEvents)} is disabled."); + Combo.Value = result.ComboAtJudgement; HighestCombo.Value = result.HighestComboAtJudgement; @@ -253,15 +267,22 @@ namespace osu.Game.Rulesets.Scoring scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) - 1; - // Always update the maximum scoring values. - revertResult(result.Judgement.MaxResult, ref currentMaximumScoringValues); - currentMaximumScoringValues.MaxCombo -= result.Judgement.MaxResult.IncreasesCombo() ? 1 : 0; - if (!result.Type.IsScorable()) return; - revertResult(result.Type, ref currentScoringValues); - currentScoringValues.MaxCombo = HighestCombo.Value; + if (result.Type.AffectsAccuracy()) + { + currentMaximumBaseScore -= Judgement.ToNumericResult(result.Judgement.MaxResult); + currentBaseScore -= Judgement.ToNumericResult(result.Type); + currentAccuracyJudgementCount--; + } + + if (result.Type.IsBonus()) + currentBonusPortion -= GetBonusScoreChange(result); + else + currentComboPortion -= GetComboScoreChange(result); + + RemoveScoreChange(result); Debug.Assert(hitEvents.Count > 0); lastHitObject = hitEvents[^1].LastHitObject; @@ -270,110 +291,35 @@ namespace osu.Game.Rulesets.Scoring updateScore(); } - private static void revertResult(HitResult result, ref ScoringValues scoringValues) + protected virtual double GetBonusScoreChange(JudgementResult result) => Judgement.ToNumericResult(result.Type); + + protected virtual double GetComboScoreChange(JudgementResult result) => Judgement.ToNumericResult(result.Type) * (1 + result.ComboAfterJudgement / 10d); + + protected virtual void ApplyScoreChange(JudgementResult result) { - if (!result.IsScorable()) - return; + } - if (result.IsBonus()) - scoringValues.BonusScore -= result.IsHit() ? Judgement.ToNumericResult(result) : 0; - else - scoringValues.BaseScore -= result.IsHit() ? Judgement.ToNumericResult(result) : 0; - - if (result.IsBasic()) - scoringValues.CountBasicHitObjects--; + protected virtual void RemoveScoreChange(JudgementResult result) + { } private void updateScore() { - Accuracy.Value = currentMaximumScoringValues.BaseScore > 0 ? (double)currentScoringValues.BaseScore / currentMaximumScoringValues.BaseScore : 1; - MinimumAccuracy.Value = maximumScoringValues.BaseScore > 0 ? (double)currentScoringValues.BaseScore / maximumScoringValues.BaseScore : 0; - MaximumAccuracy.Value = maximumScoringValues.BaseScore > 0 - ? (double)(currentScoringValues.BaseScore + (maximumScoringValues.BaseScore - currentMaximumScoringValues.BaseScore)) / maximumScoringValues.BaseScore - : 1; - TotalScore.Value = computeScore(Mode.Value, currentScoringValues, maximumScoringValues); + Accuracy.Value = currentMaximumBaseScore > 0 ? currentBaseScore / currentMaximumBaseScore : 1; + MinimumAccuracy.Value = maximumBaseScore > 0 ? currentBaseScore / maximumBaseScore : 0; + MaximumAccuracy.Value = maximumBaseScore > 0 ? (currentBaseScore + (maximumBaseScore - currentMaximumBaseScore)) / maximumBaseScore : 1; + + double comboProgress = maximumComboPortion > 0 ? currentComboPortion / maximumComboPortion : 1; + double accuracyProcess = maximumAccuracyJudgementCount > 0 ? (double)currentAccuracyJudgementCount / maximumAccuracyJudgementCount : 1; + + TotalScore.Value = (long)Math.Round(ComputeTotalScore(comboProgress, accuracyProcess, currentBonusPortion) * scoreMultiplier); } - /// - /// Computes the accuracy of a given . - /// - /// The to compute the total score of. - /// The score's accuracy. - [Pure] - public double ComputeAccuracy(ScoreInfo scoreInfo) + protected virtual double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion) { - if (!Ruleset.RulesetInfo.Equals(scoreInfo.Ruleset)) - throw new ArgumentException($"Unexpected score ruleset. Expected \"{Ruleset.RulesetInfo.ShortName}\" but was \"{scoreInfo.Ruleset.ShortName}\"."); - - // We only extract scoring values from the score's statistics. This is because accuracy is always relative to the point of pass or fail rather than relative to the whole beatmap. - extractScoringValues(scoreInfo.Statistics, out var current, out var maximum); - - return maximum.BaseScore > 0 ? (double)current.BaseScore / maximum.BaseScore : 1; - } - - /// - /// Computes the total score of a given . - /// - /// - /// Does not require to have been called before use. - /// - /// The to represent the score as. - /// The to compute the total score of. - /// The total score in the given . - [Pure] - public long ComputeScore(ScoringMode mode, ScoreInfo scoreInfo) - { - if (!Ruleset.RulesetInfo.Equals(scoreInfo.Ruleset)) - throw new ArgumentException($"Unexpected score ruleset. Expected \"{Ruleset.RulesetInfo.ShortName}\" but was \"{scoreInfo.Ruleset.ShortName}\"."); - - extractScoringValues(scoreInfo, out var current, out var maximum); - - return computeScore(mode, current, maximum); - } - - /// - /// Computes the total score from scoring values. - /// - /// The to represent the score as. - /// The current scoring values. - /// The maximum scoring values. - /// The total score computed from the given scoring values. - [Pure] - private long computeScore(ScoringMode mode, ScoringValues current, ScoringValues maximum) - { - double accuracyRatio = maximum.BaseScore > 0 ? (double)current.BaseScore / maximum.BaseScore : 1; - double comboRatio = maximum.MaxCombo > 0 ? (double)current.MaxCombo / maximum.MaxCombo : 1; - return ComputeScore(mode, accuracyRatio, comboRatio, current.BonusScore, maximum.CountBasicHitObjects); - } - - /// - /// Computes the total score from individual scoring components. - /// - /// The to represent the score as. - /// The accuracy percentage achieved by the player. - /// The portion of the max combo achieved by the player. - /// The total bonus score. - /// The total number of basic (non-tick and non-bonus) hitobjects in the beatmap. - /// The total score computed from the given scoring component ratios. - [Pure] - public long ComputeScore(ScoringMode mode, double accuracyRatio, double comboRatio, long bonusScore, int totalBasicHitObjects) - { - double accuracyScore = accuracyPortion * accuracyRatio; - double comboScore = comboPortion * comboRatio; - double rawScore = (max_score * (accuracyScore + comboScore) + bonusScore) * scoreMultiplier; - - switch (mode) - { - default: - case ScoringMode.Standardised: - return (long)Math.Round(rawScore); - - case ScoringMode.Classic: - // This gives a similar feeling to osu!stable scoring (ScoreV1) while keeping classic scoring as only a constant multiple of standardised scoring. - // The invariant is important to ensure that scores don't get re-ordered on leaderboards between the two scoring modes. - double scaledRawScore = rawScore / max_score; - return (long)Math.Round(Math.Pow(scaledRawScore * Math.Max(1, totalBasicHitObjects), 2) * ClassicScoreMultiplier); - } + return 700000 * comboProgress + + 300000 * Math.Pow(Accuracy.Value, 10) * accuracyProgress + + bonusPortion; } /// @@ -382,6 +328,9 @@ namespace osu.Game.Rulesets.Scoring /// Whether to store the current state of the for future use. protected override void Reset(bool storeResults) { + // Run one last time to store max values. + updateScore(); + base.Reset(storeResults); hitEvents.Clear(); @@ -389,16 +338,24 @@ namespace osu.Game.Rulesets.Scoring if (storeResults) { - maximumScoringValues = currentScoringValues; + maximumBaseScore = currentBaseScore; + + maximumComboPortion = currentComboPortion; + maximumAccuracyJudgementCount = currentAccuracyJudgementCount; maximumResultCounts.Clear(); maximumResultCounts.AddRange(scoreResultCounts); + + MaximumTotalScore = TotalScore.Value; } scoreResultCounts.Clear(); - currentScoringValues = default; - currentMaximumScoringValues = default; + currentBaseScore = 0; + currentMaximumBaseScore = 0; + currentAccuracyJudgementCount = 0; + currentComboPortion = 0; + currentBonusPortion = 0; TotalScore.Value = 0; Accuracy.Value = 1; @@ -428,7 +385,7 @@ namespace osu.Game.Rulesets.Scoring score.MaximumStatistics[result] = maximumResultCounts.GetValueOrDefault(result); // Populate total score after everything else. - score.TotalScore = ComputeScore(ScoringMode.Standardised, score); + score.TotalScore = TotalScore.Value; } /// @@ -452,126 +409,36 @@ namespace osu.Game.Rulesets.Scoring if (frame.Header == null) return; - extractScoringValues(frame.Header.Statistics, out var current, out var maximum); - currentScoringValues.BaseScore = current.BaseScore; - currentScoringValues.MaxCombo = frame.Header.MaxCombo; - currentMaximumScoringValues.BaseScore = maximum.BaseScore; - currentMaximumScoringValues.MaxCombo = maximum.MaxCombo; - Combo.Value = frame.Header.Combo; HighestCombo.Value = frame.Header.MaxCombo; + TotalScore.Value = frame.Header.TotalScore; scoreResultCounts.Clear(); scoreResultCounts.AddRange(frame.Header.Statistics); + SetScoreProcessorStatistics(frame.Header.ScoreProcessorStatistics); + updateScore(); OnResetFromReplayFrame?.Invoke(); } - #region ScoringValue extraction - - /// - /// Applies a best-effort extraction of hit statistics into . - /// - /// - /// This method is useful in a variety of situations, with a few drawbacks that need to be considered: - /// - /// The maximum will always be 0. - /// The current and maximum will always be the same value. - /// - /// Consumers are expected to more accurately fill in the above values through external means. - /// - /// Ensure to fill in the maximum for use in - /// . - /// - /// - /// The score to extract scoring values from. - /// The "current" scoring values, representing the hit statistics as they appear. - /// The "maximum" scoring values, representing the hit statistics as if the maximum hit result was attained each time. - [Pure] - private void extractScoringValues(ScoreInfo scoreInfo, out ScoringValues current, out ScoringValues maximum) + public ScoreProcessorStatistics GetScoreProcessorStatistics() => new ScoreProcessorStatistics { - extractScoringValues(scoreInfo.Statistics, out current, out maximum); - current.MaxCombo = scoreInfo.MaxCombo; + MaximumBaseScore = currentMaximumBaseScore, + BaseScore = currentBaseScore, + AccuracyJudgementCount = currentAccuracyJudgementCount, + ComboPortion = currentComboPortion, + BonusPortion = currentBonusPortion + }; - if (scoreInfo.MaximumStatistics.Count > 0) - extractScoringValues(scoreInfo.MaximumStatistics, out _, out maximum); - } - - /// - /// Applies a best-effort extraction of hit statistics into . - /// - /// - /// This method is useful in a variety of situations, with a few drawbacks that need to be considered: - /// - /// The current will always be 0. - /// The maximum will always be 0. - /// The current and maximum will always be the same value. - /// - /// Consumers are expected to more accurately fill in the above values (especially the current ) via external means (e.g. ). - /// - /// The hit statistics to extract scoring values from. - /// The "current" scoring values, representing the hit statistics as they appear. - /// The "maximum" scoring values, representing the hit statistics as if the maximum hit result was attained each time. - [Pure] - private void extractScoringValues(IReadOnlyDictionary statistics, out ScoringValues current, out ScoringValues maximum) + public void SetScoreProcessorStatistics(ScoreProcessorStatistics statistics) { - current = default; - maximum = default; - - foreach ((HitResult result, int count) in statistics) - { - if (!result.IsScorable()) - continue; - - if (result.IsBonus()) - current.BonusScore += count * Judgement.ToNumericResult(result); - - if (result.AffectsAccuracy()) - { - // The maximum result of this judgement if it wasn't a miss. - // E.g. For a GOOD judgement, the max result is either GREAT/PERFECT depending on which one the ruleset uses (osu!: GREAT, osu!mania: PERFECT). - HitResult maxResult; - - switch (result) - { - case HitResult.LargeTickHit: - case HitResult.LargeTickMiss: - maxResult = HitResult.LargeTickHit; - break; - - case HitResult.SmallTickHit: - case HitResult.SmallTickMiss: - maxResult = HitResult.SmallTickHit; - break; - - default: - maxResult = maxBasicResult ??= Ruleset.GetHitResults().MaxBy(kvp => Judgement.ToNumericResult(kvp.result)).result; - break; - } - - current.BaseScore += count * Judgement.ToNumericResult(result); - maximum.BaseScore += count * Judgement.ToNumericResult(maxResult); - } - - if (result.AffectsCombo()) - maximum.MaxCombo += count; - - if (result.IsBasic()) - { - current.CountBasicHitObjects += count; - maximum.CountBasicHitObjects += count; - } - } - } - - #endregion - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - hitEvents.Clear(); + currentMaximumBaseScore = statistics.MaximumBaseScore; + currentBaseScore = statistics.BaseScore; + currentAccuracyJudgementCount = statistics.AccuracyJudgementCount; + currentComboPortion = statistics.ComboPortion; + currentBonusPortion = statistics.BonusPortion; } #region Static helper methods @@ -630,30 +497,10 @@ namespace osu.Game.Rulesets.Scoring #endregion - /// - /// Stores the required scoring data that fulfils the minimum requirements for a to calculate score. - /// - private struct ScoringValues + protected override void Dispose(bool isDisposing) { - /// - /// The sum of all "basic" scoring values. See: and . - /// - public long BaseScore; - - /// - /// The sum of all "bonus" scoring values. See: and . - /// - public long BonusScore; - - /// - /// The highest achieved combo. - /// - public int MaxCombo; - - /// - /// The count of "basic" s. See: . - /// - public int CountBasicHitObjects; + base.Dispose(isDisposing); + hitEvents.Clear(); } } @@ -665,4 +512,46 @@ namespace osu.Game.Rulesets.Scoring [LocalisableDescription(typeof(GameplaySettingsStrings), nameof(GameplaySettingsStrings.ClassicScoreDisplay))] Classic } + + [Serializable] + [MessagePackObject] + public class ScoreProcessorStatistics + { + /// + /// The sum of all accuracy-affecting judgements at the current point in time. + /// + /// + /// Used to compute accuracy. + /// See: and . + /// + [Key(0)] + public double BaseScore { get; set; } + + /// + /// The maximum sum of accuracy-affecting judgements at the current point in time. + /// + /// + /// Used to compute accuracy. + /// + [Key(1)] + public double MaximumBaseScore { get; set; } + + /// + /// The count of accuracy-affecting judgements at the current point in time. + /// + [Key(2)] + public int AccuracyJudgementCount { get; set; } + + /// + /// The combo score at the current point in time. + /// + [Key(3)] + public double ComboPortion { get; set; } + + /// + /// The bonus score at the current point in time. + /// + [Key(4)] + public double BonusPortion { get; set; } + } } diff --git a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs index d4510a4519..fbb7a20a5d 100644 --- a/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs +++ b/osu.Game/Rulesets/UI/GameplaySampleTriggerSource.cs @@ -58,7 +58,7 @@ namespace osu.Game.Rulesets.UI PlaySamples(samples); } - protected void PlaySamples(ISampleInfo[] samples) => Schedule(() => + protected virtual void PlaySamples(ISampleInfo[] samples) => Schedule(() => { var hitSound = getNextSample(); hitSound.Samples = samples; diff --git a/osu.Game/Rulesets/UI/PlayfieldBorder.cs b/osu.Game/Rulesets/UI/PlayfieldBorder.cs index 211a87de84..18bd5b9b93 100644 --- a/osu.Game/Rulesets/UI/PlayfieldBorder.cs +++ b/osu.Game/Rulesets/UI/PlayfieldBorder.cs @@ -4,10 +4,13 @@ #nullable disable using System.Linq; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Platform; +using osu.Game.Extensions; using osuTK; using osuTK.Graphics; @@ -74,6 +77,12 @@ namespace osu.Game.Rulesets.UI }; } + [BackgroundDependencyLoader] + private void load(GameHost host) + { + this.ApplyGameWideClock(host); + } + protected override void LoadComplete() { base.LoadComplete(); diff --git a/osu.Game/Rulesets/UI/RulesetInputManager.cs b/osu.Game/Rulesets/UI/RulesetInputManager.cs index 2ae54a3afe..a24e22f22b 100644 --- a/osu.Game/Rulesets/UI/RulesetInputManager.cs +++ b/osu.Game/Rulesets/UI/RulesetInputManager.cs @@ -28,6 +28,8 @@ namespace osu.Game.Rulesets.UI public abstract partial class RulesetInputManager : PassThroughInputManager, ICanAttachHUDPieces, IHasReplayHandler, IHasRecordingHandler where T : struct { + protected override bool AllowRightClickFromLongTouch => false; + public readonly KeyBindingContainer KeyBindingContainer; [Resolved(CanBeNull = true)] diff --git a/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs b/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs index 54079c7895..ead893c0af 100644 --- a/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs +++ b/osu.Game/Rulesets/UI/Scrolling/Algorithms/OverlappingScrollAlgorithm.cs @@ -1,9 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - -using System; +using System.Diagnostics; using System.Linq; using osu.Framework.Lists; using osu.Game.Beatmaps.ControlPoints; @@ -40,29 +38,16 @@ namespace osu.Game.Rulesets.UI.Scrolling.Algorithms public double TimeAt(float position, double currentTime, double timeRange, float scrollLength) { - // Find the control point relating to the position. + Debug.Assert(controlPoints.Count > 0); + + // Iterate over control points and find the most relevant for the provided position. // Note: Due to velocity adjustments, overlapping control points will provide multiple valid time values for a single position // As such, this operation provides unexpected results by using the latter of the control points. + var relevantControlPoint = controlPoints.LastOrDefault(cp => PositionAt(cp.Time, currentTime, timeRange, scrollLength) <= position) ?? controlPoints.First(); - int i = 0; - float pos = 0; + float positionAtControlPoint = PositionAt(relevantControlPoint.Time, currentTime, timeRange, scrollLength); - for (; i < controlPoints.Count; i++) - { - float lastPos = pos; - pos = PositionAt(controlPoints[i].Time, currentTime, timeRange, scrollLength); - - if (pos > position) - { - i--; - pos = lastPos; - break; - } - } - - i = Math.Clamp(i, 0, controlPoints.Count - 1); - - return controlPoints[i].Time + (position - pos) * timeRange / controlPoints[i].Multiplier / scrollLength; + return relevantControlPoint.Time + (position - positionAtControlPoint) * timeRange / relevantControlPoint.Multiplier / scrollLength; } public void Reset() diff --git a/osu.Game/Rulesets/UI/Scrolling/ScrollingDirection.cs b/osu.Game/Rulesets/UI/Scrolling/ScrollingDirection.cs index 58bb80accd..81e1a6c916 100644 --- a/osu.Game/Rulesets/UI/Scrolling/ScrollingDirection.cs +++ b/osu.Game/Rulesets/UI/Scrolling/ScrollingDirection.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Rulesets.UI.Scrolling { public enum ScrollingDirection diff --git a/osu.Game/Scoring/IScoreInfo.cs b/osu.Game/Scoring/IScoreInfo.cs index 289679a724..ffc30384d2 100644 --- a/osu.Game/Scoring/IScoreInfo.cs +++ b/osu.Game/Scoring/IScoreInfo.cs @@ -15,6 +15,9 @@ namespace osu.Game.Scoring { IUser User { get; } + /// + /// The standardised total score. + /// long TotalScore { get; } int MaxCombo { get; } diff --git a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs index 9b145ad56e..c6461840aa 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs @@ -46,6 +46,9 @@ namespace osu.Game.Scoring.Legacy score.ScoreInfo = scoreInfo; int version = sr.ReadInt32(); + + scoreInfo.IsLegacyScore = version < LegacyScoreEncoder.FIRST_LAZER_VERSION; + string beatmapHash = sr.ReadString(); workingBeatmap = GetBeatmap(beatmapHash); diff --git a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs index a78ae24da2..87e1e79f87 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs @@ -28,9 +28,10 @@ namespace osu.Game.Scoring.Legacy /// /// /// 30000001: Appends to the end of scores. + /// 30000002: Score stored to replay calculated using the Score V2 algorithm. /// /// - public const int LATEST_VERSION = 30000001; + public const int LATEST_VERSION = 30000002; /// /// The first stable-compatible YYYYMMDD format version given to lazer usage of replays. diff --git a/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs b/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs index e42f6caf26..52dec20b32 100644 --- a/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs +++ b/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs @@ -3,13 +3,78 @@ #nullable disable +using System; using System.Collections.Generic; +using System.Linq; using osu.Game.Rulesets.Scoring; namespace osu.Game.Scoring.Legacy { public static class ScoreInfoExtensions { + public static long GetDisplayScore(this ScoreProcessor scoreProcessor, ScoringMode mode) + => getDisplayScore(scoreProcessor.Ruleset.RulesetInfo.OnlineID, scoreProcessor.TotalScore.Value, mode, scoreProcessor.MaximumStatistics); + + public static long GetDisplayScore(this ScoreInfo scoreInfo, ScoringMode mode) + { + // Temporary to not scale stable scores that are already in the XX-millions with the classic scoring mode. + if (scoreInfo.IsLegacyScore) + return scoreInfo.TotalScore; + + return getDisplayScore(scoreInfo.Ruleset.OnlineID, scoreInfo.TotalScore, mode, scoreInfo.MaximumStatistics); + } + + private static long getDisplayScore(int rulesetId, long score, ScoringMode mode, IReadOnlyDictionary maximumStatistics) + { + if (mode == ScoringMode.Standardised) + return score; + + int maxBasicJudgements = maximumStatistics + .Where(k => k.Key.IsBasic()) + .Select(k => k.Value) + .DefaultIfEmpty(0) + .Sum(); + + // This gives a similar feeling to osu!stable scoring (ScoreV1) while keeping classic scoring as only a constant multiple of standardised scoring. + // The invariant is important to ensure that scores don't get re-ordered on leaderboards between the two scoring modes. + double scaledRawScore = score / ScoreProcessor.MAX_SCORE; + + return (long)Math.Round(Math.Pow(scaledRawScore * Math.Max(1, maxBasicJudgements), 2) * getStandardisedToClassicMultiplier(rulesetId)); + } + + /// + /// Returns a ballpark multiplier which gives a similar "feel" for how large scores should get when displayed in "classic" mode. + /// This is different per ruleset to match the different algorithms used in the scoring implementation. + /// + private static double getStandardisedToClassicMultiplier(int rulesetId) + { + double multiplier; + + switch (rulesetId) + { + // For non-legacy rulesets, just go with the same as the osu! ruleset. + // This is arbitrary, but at least allows the setting to do something to the score. + default: + case 0: + multiplier = 36; + break; + + case 1: + multiplier = 22; + break; + + case 2: + multiplier = 28; + break; + + case 3: + multiplier = 16; + break; + } + + return multiplier; + } + public static int? GetCountGeki(this ScoreInfo scoreInfo) { switch (scoreInfo.Ruleset.OnlineID) diff --git a/osu.Game/Scoring/ScoreImporter.cs b/osu.Game/Scoring/ScoreImporter.cs index f69c1b9385..16658a598a 100644 --- a/osu.Game/Scoring/ScoreImporter.cs +++ b/osu.Game/Scoring/ScoreImporter.cs @@ -83,6 +83,11 @@ namespace osu.Game.Scoring if (string.IsNullOrEmpty(model.MaximumStatisticsJson)) model.MaximumStatisticsJson = JsonConvert.SerializeObject(model.MaximumStatistics); + + // for pre-ScoreV2 lazer scores, apply a best-effort conversion of total score to ScoreV2. + // this requires: max combo, statistics, max statistics (where available), and mods to already be populated on the score. + if (StandardisedScoreMigrationTools.ShouldMigrateToNewStandardised(model)) + model.TotalScore = StandardisedScoreMigrationTools.GetNewStandardised(model); } /// @@ -146,16 +151,48 @@ namespace osu.Game.Scoring #pragma warning restore CS0618 } + // Very naive local caching to improve performance of large score imports (where the username is usually the same for most or all scores). + private readonly Dictionary usernameLookupCache = new Dictionary(); + protected override void PostImport(ScoreInfo model, Realm realm, ImportParameters parameters) { base.PostImport(model, realm, parameters); - var userRequest = new GetUserRequest(model.RealmUser.Username); + populateUserDetails(model); + } + + /// + /// Legacy replays only store a username. + /// This will populate a user ID during import. + /// + private void populateUserDetails(ScoreInfo model) + { + string username = model.RealmUser.Username; + + if (usernameLookupCache.TryGetValue(username, out var existing)) + { + model.User = existing; + return; + } + + var userRequest = new GetUserRequest(username); api.Perform(userRequest); if (userRequest.Response is APIUser user) + { + usernameLookupCache.TryAdd(username, new APIUser + { + // Because this is a permanent cache, let's only store the pieces we're interested in, + // rather than the full API response. If we start to store more than these three fields + // in realm, this should be undone. + Id = user.Id, + Username = user.Username, + CountryCode = user.CountryCode, + }); + model.User = user; + } } } } diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index e084c45de0..d56338c6a4 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -181,8 +181,7 @@ namespace osu.Game.Scoring /// /// Whether this represents a legacy (osu!stable) score. /// - [Ignored] - public bool IsLegacyScore => Mods.OfType().Any(); + public bool IsLegacyScore { get; set; } private Dictionary? statistics; diff --git a/osu.Game/Scoring/ScoreInfoExtensions.cs b/osu.Game/Scoring/ScoreInfoExtensions.cs index 7979ca8aaa..85598076d6 100644 --- a/osu.Game/Scoring/ScoreInfoExtensions.cs +++ b/osu.Game/Scoring/ScoreInfoExtensions.cs @@ -1,9 +1,10 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System.Collections.Generic; +using System.Linq; using osu.Game.Beatmaps; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Scoring { @@ -13,5 +14,23 @@ namespace osu.Game.Scoring /// A user-presentable display title representing this score. /// public static string GetDisplayTitle(this IScoreInfo scoreInfo) => $"{scoreInfo.User.Username} playing {scoreInfo.Beatmap.GetDisplayTitle()}"; + + /// + /// Orders an array of s by total score. + /// + /// The array of s to reorder. + /// The given ordered by decreasing total score. + public static IEnumerable OrderByTotalScore(this IEnumerable scores) + => scores.OrderByDescending(s => s.TotalScore) + .ThenBy(s => s.OnlineID) + // Local scores may not have an online ID. Fall back to date in these cases. + .ThenBy(s => s.Date); + + /// + /// Retrieves the maximum achievable combo for the provided score. + /// + /// The to compute the maximum achievable combo for. + /// The maximum achievable combo. + public static int GetMaximumAchievableCombo(this ScoreInfo score) => score.MaximumStatistics.Where(kvp => kvp.Key.AffectsCombo()).Sum(kvp => kvp.Value); } } diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index 3e6d09b74a..55bcb9f79d 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -20,6 +20,7 @@ using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using osu.Game.Rulesets.Scoring; using osu.Game.Online.API; +using osu.Game.Scoring.Legacy; namespace osu.Game.Scoring { @@ -68,17 +69,6 @@ namespace osu.Game.Scoring return Realm.Run(r => r.All().FirstOrDefault(query)?.Detach()); } - /// - /// Orders an array of s by total score. - /// - /// The array of s to reorder. - /// The given ordered by decreasing total score. - public IEnumerable OrderByTotalScore(IEnumerable scores) - => scores.OrderByDescending(s => GetTotalScore(s)) - .ThenBy(s => s.OnlineID) - // Local scores may not have an online ID. Fall back to date in these cases. - .ThenBy(s => s.Date); - /// /// Retrieves a bindable that represents the total score of a . /// @@ -87,7 +77,7 @@ namespace osu.Game.Scoring /// /// The to retrieve the bindable for. /// The bindable containing the total score. - public Bindable GetBindableTotalScore([NotNull] ScoreInfo score) => new TotalScoreBindable(score, this, configManager); + public Bindable GetBindableTotalScore([NotNull] ScoreInfo score) => new TotalScoreBindable(score, configManager); /// /// Retrieves a bindable that represents the formatted total score string of a . @@ -99,32 +89,6 @@ namespace osu.Game.Scoring /// The bindable containing the formatted total score string. public Bindable GetBindableTotalScoreString([NotNull] ScoreInfo score) => new TotalScoreStringBindable(GetBindableTotalScore(score)); - /// - /// Retrieves the total score of a in the given . - /// - /// The to calculate the total score of. - /// The to return the total score as. - /// The total score. - public long GetTotalScore([NotNull] ScoreInfo score, ScoringMode mode = ScoringMode.Standardised) - { - // TODO: This is required for playlist aggregate scores. They should likely not be getting here in the first place. - if (string.IsNullOrEmpty(score.BeatmapInfo.MD5Hash)) - return score.TotalScore; - - var ruleset = score.Ruleset.CreateInstance(); - var scoreProcessor = ruleset.CreateScoreProcessor(); - scoreProcessor.Mods.Value = score.Mods; - - return scoreProcessor.ComputeScore(mode, score); - } - - /// - /// Retrieves the maximum achievable combo for the provided score. - /// - /// The to compute the maximum achievable combo for. - /// The maximum achievable combo. - public int GetMaximumAchievableCombo([NotNull] ScoreInfo score) => score.MaximumStatistics.Where(kvp => kvp.Key.AffectsCombo()).Sum(kvp => kvp.Value); - /// /// Provides the total score of a . Responds to changes in the currently-selected . /// @@ -136,12 +100,11 @@ namespace osu.Game.Scoring /// Creates a new . /// /// The to provide the total score of. - /// The . /// The config. - public TotalScoreBindable(ScoreInfo score, ScoreManager scoreManager, OsuConfigManager configManager) + public TotalScoreBindable(ScoreInfo score, OsuConfigManager configManager) { configManager?.BindWith(OsuSetting.ScoreDisplayMode, scoringMode); - scoringMode.BindValueChanged(mode => Value = scoreManager.GetTotalScore(score, mode.NewValue), true); + scoringMode.BindValueChanged(mode => Value = score.GetDisplayScore(mode.NewValue), true); } } diff --git a/osu.Game/Scoring/ScorePerformanceCache.cs b/osu.Game/Scoring/ScorePerformanceCache.cs index 17a0c0ea6a..bdbcfe4efe 100644 --- a/osu.Game/Scoring/ScorePerformanceCache.cs +++ b/osu.Game/Scoring/ScorePerformanceCache.cs @@ -1,12 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Threading; using System.Threading.Tasks; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Game.Beatmaps; using osu.Game.Database; @@ -21,7 +18,7 @@ namespace osu.Game.Scoring public partial class ScorePerformanceCache : MemoryCachingComponent { [Resolved] - private BeatmapDifficultyCache difficultyCache { get; set; } + private BeatmapDifficultyCache difficultyCache { get; set; } = null!; protected override bool CacheNullValues => false; @@ -30,10 +27,10 @@ namespace osu.Game.Scoring /// /// The score to do the calculation on. /// An optional to cancel the operation. - public Task CalculatePerformanceAsync([NotNull] ScoreInfo score, CancellationToken token = default) => + public Task CalculatePerformanceAsync(ScoreInfo score, CancellationToken token = default) => GetAsync(new PerformanceCacheLookup(score), token); - protected override async Task ComputeValueAsync(PerformanceCacheLookup lookup, CancellationToken token = default) + protected override async Task ComputeValueAsync(PerformanceCacheLookup lookup, CancellationToken token = default) { var score = lookup.ScoreInfo; diff --git a/osu.Game/Screens/Edit/BindableBeatDivisor.cs b/osu.Game/Screens/Edit/BindableBeatDivisor.cs index aa8e202e22..ffa4f01e75 100644 --- a/osu.Game/Screens/Edit/BindableBeatDivisor.cs +++ b/osu.Game/Screens/Edit/BindableBeatDivisor.cs @@ -59,16 +59,18 @@ namespace osu.Game.Screens.Edit Value = 1; } - public void Next() + public void SelectNext() { var presets = ValidDivisors.Value.Presets; - Value = presets.Cast().SkipWhile(preset => preset != Value).ElementAtOrDefault(1) ?? presets[0]; + if (presets.Cast().SkipWhile(preset => preset != Value).ElementAtOrDefault(1) is int newValue) + Value = newValue; } - public void Previous() + public void SelectPrevious() { var presets = ValidDivisors.Value.Presets; - Value = presets.Cast().TakeWhile(preset => preset != Value).LastOrDefault() ?? presets[^1]; + if (presets.Cast().TakeWhile(preset => preset != Value).LastOrDefault() is int newValue) + Value = newValue; } protected override int DefaultPrecision => 1; @@ -154,12 +156,15 @@ namespace osu.Game.Screens.Edit /// /// The 0-based beat index. /// The beat divisor. + /// The list of valid divisors which can be chosen from. Assumes ordered from low to high. Defaults to if omitted. /// The applicable divisor. - public static int GetDivisorForBeatIndex(int index, int beatDivisor) + public static int GetDivisorForBeatIndex(int index, int beatDivisor, int[] validDivisors = null) { + validDivisors ??= PREDEFINED_DIVISORS; + int beat = index % beatDivisor; - foreach (int divisor in PREDEFINED_DIVISORS) + foreach (int divisor in validDivisors) { if ((beat * divisor) % beatDivisor == 0) return divisor; diff --git a/osu.Game/Screens/Edit/Components/PlaybackControl.cs b/osu.Game/Screens/Edit/Components/PlaybackControl.cs index 72c299f443..431336aa60 100644 --- a/osu.Game/Screens/Edit/Components/PlaybackControl.cs +++ b/osu.Game/Screens/Edit/Components/PlaybackControl.cs @@ -76,6 +76,9 @@ namespace osu.Game.Screens.Edit.Components protected override bool OnKeyDown(KeyDownEvent e) { + if (e.Repeat) + return false; + switch (e.Key) { case Key.Space: diff --git a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs index 9f422d5aa9..f7159f8670 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorControl.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Diagnostics; using System.Linq; @@ -16,12 +14,14 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Input.Bindings; using osu.Game.Overlays; using osuTK; using osuTK.Graphics; @@ -29,8 +29,10 @@ using osuTK.Input; namespace osu.Game.Screens.Edit.Compose.Components { - public partial class BeatDivisorControl : CompositeDrawable + public partial class BeatDivisorControl : CompositeDrawable, IKeyBindingHandler { + private int? lastCustomDivisor; + private readonly BindableBeatDivisor beatDivisor = new BindableBeatDivisor(); public BeatDivisorControl(BindableBeatDivisor beatDivisor) @@ -101,13 +103,13 @@ namespace osu.Game.Screens.Edit.Compose.Components new ChevronButton { Icon = FontAwesome.Solid.ChevronLeft, - Action = beatDivisor.Previous + Action = beatDivisor.SelectPrevious }, new DivisorDisplay { BeatDivisor = { BindTarget = beatDivisor } }, new ChevronButton { Icon = FontAwesome.Solid.ChevronRight, - Action = beatDivisor.Next + Action = beatDivisor.SelectNext } }, }, @@ -184,29 +186,46 @@ namespace osu.Game.Screens.Edit.Compose.Components }; } + protected override void LoadComplete() + { + base.LoadComplete(); + + beatDivisor.ValidDivisors.BindValueChanged(valid => + { + if (valid.NewValue.Type == BeatDivisorType.Custom) + lastCustomDivisor = valid.NewValue.Presets.Last(); + }, true); + } + private void cycleDivisorType(int direction) { - Debug.Assert(Math.Abs(direction) == 1); - int nextDivisorType = (int)beatDivisor.ValidDivisors.Value.Type + direction; - if (nextDivisorType > (int)BeatDivisorType.Triplets) - nextDivisorType = (int)BeatDivisorType.Common; - else if (nextDivisorType < (int)BeatDivisorType.Common) - nextDivisorType = (int)BeatDivisorType.Triplets; + int totalTypes = Enum.GetValues().Length; + BeatDivisorType currentType = beatDivisor.ValidDivisors.Value.Type; - switch ((BeatDivisorType)nextDivisorType) + Debug.Assert(Math.Abs(direction) == 1); + + cycleOnce(); + + if (lastCustomDivisor == null && currentType == BeatDivisorType.Custom) + cycleOnce(); + + switch (currentType) { case BeatDivisorType.Common: - beatDivisor.ValidDivisors.Value = BeatDivisorPresetCollection.COMMON; + beatDivisor.SetArbitraryDivisor(4); break; case BeatDivisorType.Triplets: - beatDivisor.ValidDivisors.Value = BeatDivisorPresetCollection.TRIPLETS; + beatDivisor.SetArbitraryDivisor(6); break; case BeatDivisorType.Custom: - beatDivisor.ValidDivisors.Value = BeatDivisorPresetCollection.Custom(beatDivisor.ValidDivisors.Value.Presets.Max()); + Debug.Assert(lastCustomDivisor != null); + beatDivisor.SetArbitraryDivisor(lastCustomDivisor.Value); break; } + + void cycleOnce() => currentType = (BeatDivisorType)(((int)currentType + totalTypes + direction) % totalTypes); } protected override bool OnKeyDown(KeyDownEvent e) @@ -220,6 +239,26 @@ namespace osu.Game.Screens.Edit.Compose.Components return base.OnKeyDown(e); } + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case GlobalAction.EditorCycleNextBeatSnapDivisor: + beatDivisor.SelectNext(); + return true; + + case GlobalAction.EditorCyclePreviousBeatSnapDivisor: + beatDivisor.SelectPrevious(); + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + internal partial class DivisorDisplay : OsuAnimatedButton, IHasPopover { public BindableBeatDivisor BeatDivisor { get; } = new BindableBeatDivisor(); @@ -304,12 +343,12 @@ namespace osu.Game.Screens.Edit.Compose.Components { base.LoadComplete(); BeatDivisor.BindValueChanged(_ => updateState(), true); - divisorTextBox.OnCommit += (_, _) => setPresets(); + divisorTextBox.OnCommit += (_, _) => setPresetsFromTextBoxEntry(); Schedule(() => GetContainingInputManager().ChangeFocus(divisorTextBox)); } - private void setPresets() + private void setPresetsFromTextBoxEntry() { if (!int.TryParse(divisorTextBox.Text, out int divisor) || divisor < 1 || divisor > 64) { @@ -372,10 +411,10 @@ namespace osu.Game.Screens.Edit.Compose.Components private partial class TickSliderBar : SliderBar { - private Marker marker; + private Marker marker = null!; [Resolved] - private OsuColour colours { get; set; } + private OsuColour colours { get; set; } = null!; private readonly BindableBeatDivisor beatDivisor; @@ -383,7 +422,8 @@ namespace osu.Game.Screens.Edit.Compose.Components { CurrentNumber.BindTo(this.beatDivisor = beatDivisor); - Padding = new MarginPadding { Horizontal = 5 }; + RangePadding = 5; + Padding = new MarginPadding { Horizontal = RangePadding }; } protected override void LoadComplete() @@ -398,15 +438,20 @@ namespace osu.Game.Screens.Edit.Compose.Components ClearInternal(); CurrentNumber.ValueChanged -= moveMarker; - foreach (int divisor in beatDivisor.ValidDivisors.Value.Presets) + int largestDivisor = beatDivisor.ValidDivisors.Value.Presets.Last(); + + for (int tickIndex = 0; tickIndex <= largestDivisor; tickIndex++) { - AddInternal(new Tick(divisor) + int divisor = BindableBeatDivisor.GetDivisorForBeatIndex(tickIndex, largestDivisor, (int[])beatDivisor.ValidDivisors.Value.Presets); + bool isSolidTick = divisor * (largestDivisor - tickIndex) == largestDivisor; + + AddInternal(new Tick(divisor, isSolidTick) { Anchor = Anchor.CentreLeft, Origin = Anchor.Centre, RelativePositionAxes = Axes.Both, Colour = BindableBeatDivisor.GetColourFor(divisor, colours), - X = getMappedPosition(divisor), + X = tickIndex / (float)largestDivisor, }); } @@ -418,6 +463,11 @@ namespace osu.Game.Screens.Edit.Compose.Components private void moveMarker(ValueChangedEvent divisor) { marker.MoveToX(getMappedPosition(divisor.NewValue), 100, Easing.OutQuint); + + foreach (Tick tick in InternalChildren.OfType().Where(t => !t.AlwaysDisplayed)) + { + tick.FadeTo(divisor.NewValue % tick.Divisor == 0 ? 0.2f : 0f, 100, Easing.OutQuint); + } } protected override void UpdateValue(float value) @@ -431,12 +481,12 @@ namespace osu.Game.Screens.Edit.Compose.Components switch (e.Key) { case Key.Right: - beatDivisor.Next(); + beatDivisor.SelectNext(); OnUserChange(Current.Value); return true; case Key.Left: - beatDivisor.Previous(); + beatDivisor.SelectPrevious(); OnUserChange(Current.Value); return true; @@ -483,13 +533,22 @@ namespace osu.Game.Screens.Edit.Compose.Components OnUserChange(Current.Value); } - private float getMappedPosition(float divisor) => MathF.Pow((divisor - 1) / (beatDivisor.ValidDivisors.Value.Presets.Last() - 1), 0.90f); + private float getMappedPosition(float divisor) => 1 - 1 / divisor; private partial class Tick : Circle { - public Tick(int divisor) + public readonly bool AlwaysDisplayed; + + public readonly int Divisor; + + public Tick(int divisor, bool alwaysDisplayed) { + AlwaysDisplayed = alwaysDisplayed; + Divisor = divisor; + Size = new Vector2(6f, 12) * BindableBeatDivisor.GetSize(divisor); + Alpha = alwaysDisplayed ? 1 : 0; + InternalChild = new Box { RelativeSizeAxes = Axes.Both }; } } @@ -497,7 +556,7 @@ namespace osu.Game.Screens.Edit.Compose.Components private partial class Marker : CompositeDrawable { [Resolved] - private OverlayColourProvider colourProvider { get; set; } + private OverlayColourProvider colourProvider { get; set; } = null!; [BackgroundDependencyLoader] private void load() diff --git a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorType.cs b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorType.cs index ebdb030e76..4a25144881 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BeatDivisorType.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BeatDivisorType.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Screens.Edit.Compose.Components { public enum BeatDivisorType diff --git a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs index cb7c083d87..56a6b18433 100644 --- a/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/BlueprintContainer.cs @@ -16,6 +16,7 @@ using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; using osu.Game.Rulesets.Edit; using osuTK; using osuTK.Input; @@ -26,7 +27,7 @@ namespace osu.Game.Screens.Edit.Compose.Components /// A container which provides a "blueprint" display of items. /// Includes selection and manipulation support via a . /// - public abstract partial class BlueprintContainer : CompositeDrawable, IKeyBindingHandler + public abstract partial class BlueprintContainer : CompositeDrawable, IKeyBindingHandler, IKeyBindingHandler where T : class { protected DragBox DragBox { get; private set; } @@ -91,7 +92,6 @@ namespace osu.Game.Screens.Edit.Compose.Components }; SelectionHandler = CreateSelectionHandler(); - SelectionHandler.DeselectAll = DeselectAll; SelectionHandler.SelectedItems.BindTo(SelectedItems); AddRangeInternal(new[] @@ -279,6 +279,30 @@ namespace osu.Game.Screens.Edit.Compose.Components { } + public bool OnPressed(KeyBindingPressEvent e) + { + if (e.Repeat) + return false; + + switch (e.Action) + { + case GlobalAction.Back: + if (SelectedItems.Count > 0) + { + DeselectAll(); + return true; + } + + break; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + #region Blueprint Addition/Removal protected virtual void AddBlueprintFor(T item) diff --git a/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs index d6e4e1f030..63886e38eb 100644 --- a/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs @@ -18,6 +18,9 @@ namespace osu.Game.Screens.Edit.Compose.Components { public abstract partial class CircularDistanceSnapGrid : DistanceSnapGrid { + [Resolved] + private EditorClock editorClock { get; set; } + protected CircularDistanceSnapGrid(HitObject referenceObject, Vector2 startPosition, double startTime, double? endTime = null) : base(referenceObject, startPosition, startTime, endTime) { @@ -98,9 +101,12 @@ namespace osu.Game.Screens.Edit.Compose.Components if (travelLength < DistanceBetweenTicks) travelLength = DistanceBetweenTicks; - // When interacting with the resolved snap provider, the distance spacing multiplier should first be removed - // to allow for snapping at a non-multiplied ratio. - float snappedDistance = SnapProvider.FindSnappedDistance(ReferenceObject, travelLength / distanceSpacingMultiplier); + float snappedDistance = LimitedDistanceSnap.Value + ? SnapProvider.DurationToDistance(ReferenceObject, editorClock.CurrentTime - ReferenceObject.GetEndTime()) + // When interacting with the resolved snap provider, the distance spacing multiplier should first be removed + // to allow for snapping at a non-multiplied ratio. + : SnapProvider.FindSnappedDistance(ReferenceObject, travelLength / distanceSpacingMultiplier); + double snappedTime = StartTime + SnapProvider.DistanceToDuration(ReferenceObject, snappedDistance); if (snappedTime > LatestEndTime) diff --git a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs index 453e4b9130..c8cfac454a 100644 --- a/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/ComposeBlueprintContainer.cs @@ -14,6 +14,8 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Game.Audio; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; @@ -56,7 +58,8 @@ namespace osu.Game.Screens.Edit.Compose.Components [BackgroundDependencyLoader] private void load() { - TernaryStates = CreateTernaryButtons().ToArray(); + MainTernaryStates = CreateTernaryButtons().ToArray(); + SampleBankTernaryStates = createSampleBankTernaryButtons().ToArray(); AddInternal(new DrawableRulesetDependenciesProvidingContainer(Composer.Ruleset) { @@ -78,9 +81,10 @@ namespace osu.Game.Screens.Edit.Compose.Components // we own SelectionHandler so don't need to worry about making bindable copies (for simplicity) foreach (var kvp in SelectionHandler.SelectionSampleStates) - { kvp.Value.BindValueChanged(_ => updatePlacementSamples()); - } + + foreach (var kvp in SelectionHandler.SelectionBankStates) + kvp.Value.BindValueChanged(_ => updatePlacementSamples()); } protected override void TransferBlueprintFor(HitObject hitObject, DrawableHitObject drawableObject) @@ -166,6 +170,9 @@ namespace osu.Game.Screens.Edit.Compose.Components foreach (var kvp in SelectionHandler.SelectionSampleStates) sampleChanged(kvp.Key, kvp.Value.Value); + + foreach (var kvp in SelectionHandler.SelectionBankStates) + bankChanged(kvp.Key, kvp.Value.Value); } private void sampleChanged(string sampleName, TernaryState state) @@ -185,17 +192,29 @@ namespace osu.Game.Screens.Edit.Compose.Components case TernaryState.True: if (existingSample == null) - samples.Add(new HitSampleInfo(sampleName)); + samples.Add(CurrentPlacement.HitObject.CreateHitSampleInfo(sampleName)); break; } } + private void bankChanged(string bankName, TernaryState state) + { + if (CurrentPlacement == null) return; + + if (bankName == EditorSelectionHandler.HIT_BANK_AUTO) + CurrentPlacement.AutomaticBankAssignment = state == TernaryState.True; + else if (state == TernaryState.True) + CurrentPlacement.HitObject.Samples = CurrentPlacement.HitObject.Samples.Select(s => s.With(newBank: bankName)).ToList(); + } + public readonly Bindable NewCombo = new Bindable { Description = "New Combo" }; /// /// A collection of states which will be displayed to the user in the toolbox. /// - public TernaryButton[] TernaryStates { get; private set; } + public TernaryButton[] MainTernaryStates { get; private set; } + + public TernaryButton[] SampleBankTernaryStates { get; private set; } /// /// Create all ternary states required to be displayed to the user. @@ -209,6 +228,39 @@ namespace osu.Game.Screens.Edit.Compose.Components yield return new TernaryButton(kvp.Value, kvp.Key.Replace("hit", string.Empty).Titleize(), () => getIconForSample(kvp.Key)); } + private IEnumerable createSampleBankTernaryButtons() + { + foreach (var kvp in SelectionHandler.SelectionBankStates) + yield return new TernaryButton(kvp.Value, kvp.Key.Titleize(), () => getIconForBank(kvp.Key)); + } + + private Drawable getIconForBank(string sampleName) + { + return new Container + { + Size = new Vector2(30, 20), + Children = new Drawable[] + { + new SpriteIcon + { + Size = new Vector2(8), + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = FontAwesome.Solid.VolumeOff + }, + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + X = 10, + Y = -1, + Font = OsuFont.Default.With(weight: FontWeight.Bold, size: 20), + Text = $"{char.ToUpperInvariant(sampleName.First())}" + } + } + }; + } + private Drawable getIconForSample(string sampleName) { switch (sampleName) @@ -317,12 +369,16 @@ namespace osu.Game.Screens.Edit.Compose.Components } } + private void commitIfPlacementActive() + { + CurrentPlacement?.EndPlacement(CurrentPlacement.PlacementActive == PlacementBlueprint.PlacementState.Active); + removePlacement(); + } + private void removePlacement() { - if (CurrentPlacement == null) return; - - CurrentPlacement.EndPlacement(false); - CurrentPlacement.Expire(); + CurrentPlacement?.EndPlacement(false); + CurrentPlacement?.Expire(); CurrentPlacement = null; } @@ -342,7 +398,8 @@ namespace osu.Game.Screens.Edit.Compose.Components currentTool = value; - refreshTool(); + // As per stable editor, when changing tools, we should forcefully commit any pending placement. + commitIfPlacementActive(); } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs index 6092ebc08f..8aa2fa9f45 100644 --- a/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs @@ -10,6 +10,7 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Layout; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; @@ -60,6 +61,18 @@ namespace osu.Game.Screens.Edit.Compose.Components [Resolved] private BindableBeatDivisor beatDivisor { get; set; } + /// + /// When enabled, distance snap should only snap to the current time (as per the editor clock). + /// This is to emulate stable behaviour. + /// + protected Bindable LimitedDistanceSnap { get; private set; } + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + LimitedDistanceSnap = config.GetBindable(OsuSetting.EditorLimitedDistanceSnap); + } + private readonly LayoutValue gridCache = new LayoutValue(Invalidation.RequiredParentSizeToFit); protected readonly HitObject ReferenceObject; diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs index 357cc940f2..d618541685 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs @@ -11,6 +11,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.UserInterface; using osu.Game.Audio; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; @@ -20,6 +21,12 @@ namespace osu.Game.Screens.Edit.Compose.Components { public partial class EditorSelectionHandler : SelectionHandler { + /// + /// A special bank name that is only used in the editor UI. + /// When selected and in placement mode, the bank of the last hit object will always be used. + /// + public const string HIT_BANK_AUTO = "auto"; + [Resolved] protected EditorBeatmap EditorBeatmap { get; private set; } @@ -48,11 +55,83 @@ namespace osu.Game.Screens.Edit.Compose.Components /// public readonly Dictionary> SelectionSampleStates = new Dictionary>(); + /// + /// The state of each sample bank type for all selected hitobjects. + /// + public readonly Dictionary> SelectionBankStates = new Dictionary>(); + /// /// Set up ternary state bindables and bind them to selection/hitobject changes (in both directions) /// private void createStateBindables() { + foreach (string bankName in HitSampleInfo.AllBanks.Prepend(HIT_BANK_AUTO)) + { + var bindable = new Bindable + { + Description = bankName.Titleize() + }; + + bindable.ValueChanged += state => + { + switch (state.NewValue) + { + case TernaryState.False: + if (SelectedItems.Count == 0) + { + // Ensure that if this is the last selected bank, it should remain selected. + if (SelectionBankStates.Values.All(b => b.Value == TernaryState.False)) + bindable.Value = TernaryState.True; + } + else + { + // Auto should never apply when there is a selection made. + // This is also required to stop a bindable feedback loop when a HitObject has zero samples (and LINQ `All` below becomes true). + if (bankName == HIT_BANK_AUTO) + break; + + // Never remove a sample bank. + // These are basically radio buttons, not toggles. + if (SelectedItems.All(h => h.Samples.All(s => s.Bank == bankName))) + bindable.Value = TernaryState.True; + } + + break; + + case TernaryState.True: + if (SelectedItems.Count == 0) + { + // Ensure the user can't stack multiple bank selections when there's no hitobject selection. + // Note that in normal scenarios this is sorted out by the feedback from applying the bank to the selected objects. + foreach (var other in SelectionBankStates.Values) + { + if (other != bindable) + other.Value = TernaryState.False; + } + } + else + { + // Auto should just not apply if there's a selection already made. + // Maybe we could make it a disabled button in the future, but right now the editor buttons don't support disabled state. + if (bankName == HIT_BANK_AUTO) + { + bindable.Value = TernaryState.False; + break; + } + + AddSampleBank(bankName); + } + + break; + } + }; + + SelectionBankStates[bankName] = bindable; + } + + // start with normal selected. + SelectionBankStates[SampleControlPoint.DEFAULT_BANK].Value = TernaryState.True; + foreach (string sampleName in HitSampleInfo.AllAdditions) { var bindable = new Bindable @@ -104,12 +183,33 @@ namespace osu.Game.Screens.Edit.Compose.Components { bindable.Value = GetStateFromSelection(SelectedItems, h => h.Samples.Any(s => s.Name == sampleName)); } + + foreach ((string bankName, var bindable) in SelectionBankStates) + { + bindable.Value = GetStateFromSelection(SelectedItems, h => h.Samples.All(s => s.Bank == bankName)); + } } #endregion #region Ternary state changes + /// + /// Adds a sample bank to all selected s. + /// + /// The name of the sample bank. + public void AddSampleBank(string bankName) + { + EditorBeatmap.PerformOnSelection(h => + { + if (h.Samples.All(s => s.Bank == bankName)) + return; + + h.Samples = h.Samples.Select(s => s.With(newBank: bankName)).ToList(); + EditorBeatmap.Update(h); + }); + } + /// /// Adds a hit sample to all selected s. /// @@ -122,7 +222,7 @@ namespace osu.Game.Screens.Edit.Compose.Components if (h.Samples.Any(s => s.Name == sampleName)) return; - h.Samples.Add(new HitSampleInfo(sampleName)); + h.Samples.Add(h.CreateHitSampleInfo(sampleName)); EditorBeatmap.Update(h); }); } @@ -174,11 +274,17 @@ namespace osu.Game.Screens.Edit.Compose.Components yield return new TernaryStateToggleMenuItem("New combo") { State = { BindTarget = SelectionNewComboState } }; } - yield return new OsuMenuItem("Sound") + yield return new OsuMenuItem("Sample") { Items = SelectionSampleStates.Select(kvp => new TernaryStateToggleMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray() }; + + yield return new OsuMenuItem("Bank") + { + Items = SelectionBankStates.Select(kvp => + new TernaryStateToggleMenuItem(kvp.Value.Description) { State = { BindTarget = kvp.Value } }).ToArray() + }; } #endregion diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs index 17790547ed..1c5faed0e5 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs @@ -22,6 +22,8 @@ namespace osu.Game.Screens.Edit.Compose.Components { public const float BORDER_RADIUS = 3; + private const float button_padding = 5; + public Func OnRotation; public Func OnScale; public Func OnFlip; @@ -182,6 +184,13 @@ namespace osu.Game.Screens.Edit.Compose.Components return base.OnKeyDown(e); } + protected override void Update() + { + base.Update(); + + ensureButtonsOnScreen(); + } + private void recreate() { if (LoadState < LoadState.Loading) @@ -234,11 +243,10 @@ namespace osu.Game.Screens.Edit.Compose.Components }, buttons = new FillFlowContainer { - Y = 20, - AutoSizeAxes = Axes.Both, + AutoSizeAxes = Axes.X, + Height = 30, Direction = FillDirection.Horizontal, - Anchor = Anchor.BottomCentre, - Origin = Anchor.Centre + Margin = new MarginPadding(button_padding), } }; @@ -352,5 +360,39 @@ namespace osu.Game.Screens.Edit.Compose.Components if (activeOperations++ == 0) OperationStarted?.Invoke(); } + + private void ensureButtonsOnScreen() + { + buttons.Position = Vector2.Zero; + + var thisQuad = ScreenSpaceDrawQuad; + + // Shrink the parent quad to give a bit of padding so the buttons don't stick *right* on the border. + // AABBFloat assumes no rotation. one would hope the whole editor is not being rotated. + var parentQuad = Parent.ScreenSpaceDrawQuad.AABBFloat.Shrink(ToLocalSpace(thisQuad.TopLeft + new Vector2(button_padding * 2))); + + float topExcess = thisQuad.TopLeft.Y - parentQuad.TopLeft.Y; + float bottomExcess = parentQuad.BottomLeft.Y - thisQuad.BottomLeft.Y; + float leftExcess = thisQuad.TopLeft.X - parentQuad.TopLeft.X; + float rightExcess = parentQuad.TopRight.X - thisQuad.TopRight.X; + + if (topExcess + bottomExcess < buttons.Height + button_padding) + { + buttons.Anchor = Anchor.BottomCentre; + buttons.Origin = Anchor.BottomCentre; + } + else if (topExcess > bottomExcess) + { + buttons.Anchor = Anchor.TopCentre; + buttons.Origin = Anchor.BottomCentre; + } + else + { + buttons.Anchor = Anchor.BottomCentre; + buttons.Origin = Anchor.TopCentre; + } + + buttons.X += ToLocalSpace(thisQuad.TopLeft - new Vector2(Math.Min(0, leftExcess)) + new Vector2(Math.Min(0, rightExcess))).X; + } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs index 9e4fb26688..5cedf1ca42 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionHandler.cs @@ -197,9 +197,9 @@ namespace osu.Game.Screens.Edit.Compose.Components #region Selection Handling /// - /// Bind an action to deselect all selected blueprints. + /// Deselect all selected items. /// - internal Action DeselectAll { private get; set; } + protected void DeselectAll() => SelectedItems.Clear(); /// /// Handle a blueprint becoming selected. @@ -303,7 +303,7 @@ namespace osu.Game.Screens.Edit.Compose.Components if (blueprint.IsSelected) return false; - DeselectAll?.Invoke(); + DeselectAll(); blueprint.Select(); return true; } @@ -311,6 +311,7 @@ namespace osu.Game.Screens.Edit.Compose.Components protected void DeleteSelected() { DeleteItems(SelectedItems.ToArray()); + DeselectAll(); } #endregion diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs index f93fb0679f..b60e04afc1 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineBlueprintContainer.cs @@ -83,8 +83,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { placementBlueprint = CreateBlueprintFor(obj.NewValue).AsNonNull(); - placementBlueprint.Colour = Color4.MediumPurple; + placementBlueprint.Colour = OsuColour.Gray(0.9f); + // TODO: this is out of order, causing incorrect stacking height. SelectionBlueprints.Add(placementBlueprint); } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs index ea063e9216..55f122669d 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs @@ -50,6 +50,8 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private readonly Container colouredComponents; private readonly OsuSpriteText comboIndexText; + private readonly SamplePointPiece samplePointPiece; + private readonly DifficultyPointPiece? difficultyPointPiece; [Resolved] private ISkinSource skin { get; set; } = null!; @@ -101,7 +103,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline }, } }, - new SamplePointPiece(Item) + samplePointPiece = new SamplePointPiece(Item) { Anchor = Anchor.BottomLeft, Origin = Anchor.TopCentre @@ -118,7 +120,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (item is IHasSliderVelocity) { - AddInternal(new DifficultyPointPiece(Item) + AddInternal(difficultyPointPiece = new DifficultyPointPiece(Item) { Anchor = Anchor.TopLeft, Origin = Anchor.BottomCentre @@ -244,6 +246,25 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline public override Vector2 ScreenSpaceSelectionPoint => ScreenSpaceDrawQuad.TopLeft; + protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) + { + // Since children are exceeding the component size, we need to use a custom quad to compute whether it should be masked away. + + // If the component isn't considered masked away by itself, there's no need to apply custom logic. + if (!base.ComputeIsMaskedAway(maskingBounds)) + return false; + + // If the component is considered masked away, we'll use children to create an extended quad that encapsulates all parts of this blueprint + // to ensure it doesn't pop in and out of existence abruptly when scrolling the timeline. + var rect = RectangleF.Union(ScreenSpaceDrawQuad.AABBFloat, circle.ScreenSpaceDrawQuad.AABBFloat); + rect = RectangleF.Union(rect, samplePointPiece.ScreenSpaceDrawQuad.AABBFloat); + + if (difficultyPointPiece != null) + rect = RectangleF.Union(rect, difficultyPointPiece.ScreenSpaceDrawQuad.AABBFloat); + + return !Precision.AlmostIntersects(maskingBounds, rect); + } + private partial class Tick : Circle { public Tick() diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index b8fa7f6579..41c11ce5c1 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -28,6 +28,7 @@ using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Configuration; +using osu.Game.Database; using osu.Game.Graphics.Cursor; using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; @@ -92,6 +93,9 @@ namespace osu.Game.Screens.Edit [Resolved(canBeNull: true)] private INotificationOverlay notifications { get; set; } + [Resolved] + private RealmAccess realm { get; set; } + public readonly Bindable Mode = new Bindable(); public IBindable SamplePlaybackDisabled => samplePlaybackDisabled; @@ -181,6 +185,7 @@ namespace osu.Game.Screens.Edit private Bindable editorBackgroundDim; private Bindable editorHitMarkers; private Bindable editorAutoSeekOnPlacement; + private Bindable editorLimitedDistanceSnap; public Editor(EditorLoader loader = null) { @@ -272,6 +277,7 @@ namespace osu.Game.Screens.Edit editorBackgroundDim = config.GetBindable(OsuSetting.EditorDim); editorHitMarkers = config.GetBindable(OsuSetting.EditorShowHitMarkers); editorAutoSeekOnPlacement = config.GetBindable(OsuSetting.EditorAutoSeekOnPlacement); + editorLimitedDistanceSnap = config.GetBindable(OsuSetting.EditorLimitedDistanceSnap); AddInternal(new OsuContextMenuContainer { @@ -333,6 +339,10 @@ namespace osu.Game.Screens.Edit new ToggleMenuItem(EditorStrings.AutoSeekOnPlacement) { State = { BindTarget = editorAutoSeekOnPlacement }, + }, + new ToggleMenuItem(EditorStrings.LimitedDistanceSnap) + { + State = { BindTarget = editorLimitedDistanceSnap }, } } }, @@ -529,6 +539,9 @@ namespace osu.Game.Screens.Edit // Track traversal keys. // Matching osu-stable implementations. case Key.Z: + if (e.Repeat) + return false; + // Seek to first object time, or track start if already there. double? firstObjectTime = editorBeatmap.HitObjects.FirstOrDefault()?.StartTime; @@ -539,12 +552,18 @@ namespace osu.Game.Screens.Edit return true; case Key.X: + if (e.Repeat) + return false; + // Restart playback from beginning of track. clock.Seek(0); clock.Start(); return true; case Key.C: + if (e.Repeat) + return false; + // Pause or resume. if (clock.IsRunning) clock.Stop(); @@ -553,6 +572,9 @@ namespace osu.Game.Screens.Edit return true; case Key.V: + if (e.Repeat) + return false; + // Seek to last object time, or track end if already there. // Note that in osu-stable subsequent presses when at track end won't return to last object. // This has intentionally been changed to make it more useful. @@ -700,6 +722,13 @@ namespace osu.Game.Screens.Edit } } + realm.Write(r => + { + var beatmap = r.Find(editorBeatmap.BeatmapInfo.ID); + if (beatmap != null) + beatmap.EditorTimestamp = clock.CurrentTime; + }); + ApplyToBackground(b => { b.DimWhenUserSettingsIgnored.Value = 0; @@ -833,7 +862,11 @@ namespace osu.Game.Screens.Edit { double targetTime = 0; - if (Beatmap.Value.Beatmap.HitObjects.Count > 0) + if (editorBeatmap.BeatmapInfo.EditorTimestamp != null) + { + targetTime = editorBeatmap.BeatmapInfo.EditorTimestamp.Value; + } + else if (Beatmap.Value.Beatmap.HitObjects.Count > 0) { // seek to one beat length before the first hitobject targetTime = Beatmap.Value.Beatmap.HitObjects[0].StartTime; diff --git a/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs b/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs index 84cfac8f65..b8cbff047e 100644 --- a/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs +++ b/osu.Game/Screens/Edit/EditorScreenWithTimeline.cs @@ -82,7 +82,7 @@ namespace osu.Game.Screens.Edit AutoSizeAxes = Axes.Y, Padding = new MarginPadding { Right = 5 }, }, - new BeatDivisorControl(beatDivisor) { RelativeSizeAxes = Axes.Both } + new BeatDivisorControl(this.beatDivisor) { RelativeSizeAxes = Axes.Both } }, }, RowDimensions = new[] diff --git a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs index b4647c2b64..565379f391 100644 --- a/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs +++ b/osu.Game/Screens/Edit/LegacyEditorBeatmapPatcher.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.IO; using System.Linq; using System.Text; @@ -15,7 +16,9 @@ using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Formats; +using osu.Game.Extensions; using osu.Game.IO; +using osu.Game.Rulesets.Objects.Types; using osu.Game.Skinning; using Decoder = osu.Game.Beatmaps.Formats.Decoder; @@ -42,6 +45,7 @@ namespace osu.Game.Screens.Edit editorBeatmap.BeginChange(); processHitObjects(result, () => newBeatmap ??= readBeatmap(newState)); processTimingPoints(() => newBeatmap ??= readBeatmap(newState)); + processHitObjectLocalData(() => newBeatmap ??= readBeatmap(newState)); editorBeatmap.EndChange(); } @@ -87,6 +91,41 @@ namespace osu.Game.Screens.Edit } } + private void processHitObjectLocalData(Func getNewBeatmap) + { + // This method handles data that are stored in control points in the legacy format, + // but were moved to the hitobjects themselves in lazer. + // Specifically, the data being referred to here consists of: slider velocity and sample information. + + // For simplicity, this implementation relies on the editor beatmap already having the same hitobjects in sequence as the new beatmap. + // To guarantee that, `processHitObjects()` must be ran prior to this method for correct operation. + // This is done to avoid the necessity of reimplementing/reusing parts of LegacyBeatmapDecoder that already treat this data correctly. + + var oldObjects = editorBeatmap.HitObjects; + var newObjects = getNewBeatmap().HitObjects; + + Debug.Assert(oldObjects.Count == newObjects.Count); + + foreach (var (oldObject, newObject) in oldObjects.Zip(newObjects)) + { + // if `oldObject` and `newObject` are the same, it means that `oldObject` was inserted into `editorBeatmap` by `processHitObjects()`. + // in that case, there is nothing to do (and some of the subsequent changes may even prove destructive). + if (ReferenceEquals(oldObject, newObject)) + continue; + + if (oldObject is IHasSliderVelocity oldWithVelocity && newObject is IHasSliderVelocity newWithVelocity) + oldWithVelocity.SliderVelocity = newWithVelocity.SliderVelocity; + + oldObject.Samples = newObject.Samples; + + if (oldObject is IHasRepeats oldWithRepeats && newObject is IHasRepeats newWithRepeats) + { + oldWithRepeats.NodeSamples.Clear(); + oldWithRepeats.NodeSamples.AddRange(newWithRepeats.NodeSamples); + } + } + } + private void findChangedIndices(DiffResult result, LegacyDecoder.Section section, out List removedIndices, out List addedIndices) { removedIndices = new List(); @@ -165,7 +204,7 @@ namespace osu.Game.Screens.Edit protected override IBeatmap GetBeatmap() => beatmap; - protected override Texture GetBackground() => throw new NotImplementedException(); + public override Texture GetBackground() => throw new NotImplementedException(); protected override Track GetBeatmapTrack() => throw new NotImplementedException(); diff --git a/osu.Game/Screens/Edit/Setup/SetupScreenHeader.cs b/osu.Game/Screens/Edit/Setup/SetupScreenHeader.cs index 1d66830adf..788beba9d9 100644 --- a/osu.Game/Screens/Edit/Setup/SetupScreenHeader.cs +++ b/osu.Game/Screens/Edit/Setup/SetupScreenHeader.cs @@ -65,7 +65,7 @@ namespace osu.Game.Screens.Edit.Setup { base.LoadComplete(); - sections.SelectedSection.BindValueChanged(section => tabControl.Current.Value = section.NewValue); + sections.SelectedSection.BindValueChanged(section => tabControl.Current.Value = section.NewValue!); tabControl.Current.BindValueChanged(section => { if (section.NewValue != sections.SelectedSection.Value) diff --git a/osu.Game/Screens/OnlinePlay/Components/PlaylistItemBackground.cs b/osu.Game/Screens/OnlinePlay/Components/PlaylistItemBackground.cs index 997ba6b639..6b06eaee1e 100644 --- a/osu.Game/Screens/OnlinePlay/Components/PlaylistItemBackground.cs +++ b/osu.Game/Screens/OnlinePlay/Components/PlaylistItemBackground.cs @@ -27,7 +27,7 @@ namespace osu.Game.Screens.OnlinePlay.Components if (Beatmap?.BeatmapSet is IBeatmapSetOnlineInfo online) texture = textures.Get(online.Covers.Cover); - Sprite.Texture = texture ?? beatmaps.DefaultBeatmap.Background; + Sprite.Texture = texture ?? beatmaps.DefaultBeatmap.GetBackground(); } public override bool Equals(Background? other) diff --git a/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs b/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs index 93c8faf0b0..2ee3bb30dd 100644 --- a/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs @@ -85,15 +85,15 @@ namespace osu.Game.Screens.OnlinePlay.Components StarDifficulty minDifficulty; StarDifficulty maxDifficulty; - if (DifficultyRange.Value != null) + if (DifficultyRange.Value != null && Playlist.Count == 0) { + // When Playlist is empty (in lounge) we take retrieved range minDifficulty = new StarDifficulty(DifficultyRange.Value.Min, 0); maxDifficulty = new StarDifficulty(DifficultyRange.Value.Max, 0); } else { - // In multiplayer rooms, the beatmaps of playlist items will not be populated to a point this can be correct. - // Either populating them via BeatmapLookupCache or polling the API for the room's DifficultyRange will be required. + // When Playlist is not empty (in room) we compute actual range var orderedDifficulties = Playlist.Select(p => p.Beatmap).OrderBy(b => b.StarRating).ToArray(); minDifficulty = new StarDifficulty(orderedDifficulties.Length > 0 ? orderedDifficulties[0].StarRating : 0, 0); diff --git a/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs b/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs index 6313d907a5..4d5d724089 100644 --- a/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/FreeModSelectOverlay.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using osu.Game.Overlays; using System.Collections.Generic; @@ -34,11 +32,12 @@ namespace osu.Game.Screens.OnlinePlay protected override ModColumn CreateModColumn(ModType modType) => new ModColumn(modType, true); - protected override IEnumerable CreateFooterButtons() => base.CreateFooterButtons().Prepend( - new SelectAllModsButton(this) - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - }); + protected override IEnumerable CreateFooterButtons() + => base.CreateFooterButtons() + .Prepend(SelectAllModsButton = new SelectAllModsButton(this) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + }); } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs index 8c85a8235c..ef06d21655 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs @@ -103,118 +103,129 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components CornerRadius = CORNER_RADIUS, Children = new Drawable[] { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.Background5, + Width = 0.2f, + }, + new Box + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.Both, + Colour = ColourInfo.GradientHorizontal(colours.Background5, colours.Background5.Opacity(0.3f)), + Width = 0.8f, + }, new GridContainer { RelativeSizeAxes = Axes.Both, ColumnDimensions = new[] { - new Dimension(GridSizeMode.Relative, 0.2f) + new Dimension(), + new Dimension(GridSizeMode.AutoSize), }, Content = new[] { new Drawable[] { - new Box + new Container { + Name = @"Left details", RelativeSizeAxes = Axes.Both, - Colour = colours.Background5, - }, - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = ColourInfo.GradientHorizontal(colours.Background5, colours.Background5.Opacity(0.3f)) - }, - } - } - }, - new Container - { - Name = @"Left details", - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding - { - Left = 20, - Vertical = 5 - }, - Children = new Drawable[] - { - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Children = new Drawable[] - { - new FillFlowContainer + Padding = new MarginPadding { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5), - Children = new Drawable[] - { - new RoomStatusPill - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft - }, - specialCategoryPill = new RoomSpecialCategoryPill - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft - }, - endDateInfo = new EndDateInfo - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - }, - } + Left = 20, + Right = DrawableRoomParticipantsList.SHEAR_WIDTH, + Vertical = 5 }, - new FillFlowContainer + Children = new Drawable[] { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Top = 3 }, - Direction = FillDirection.Vertical, - Children = new Drawable[] + new FillFlowContainer { - new RoomNameText(), - new RoomStatusText() + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new RoomStatusPill + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + }, + specialCategoryPill = new RoomSpecialCategoryPill + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + }, + endDateInfo = new EndDateInfo + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + } + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Top = 3 }, + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new TruncatingSpriteText + { + RelativeSizeAxes = Axes.X, + Font = OsuFont.GetFont(size: 28), + Current = { BindTarget = Room.Name } + }, + new RoomStatusText() + } + } + }, + }, + new FillFlowContainer + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(5), + ChildrenEnumerable = CreateBottomDetails() + } + } + }, + new FillFlowContainer + { + Name = "Right content", + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + AutoSizeAxes = Axes.X, + RelativeSizeAxes = Axes.Y, + Spacing = new Vector2(5), + Padding = new MarginPadding + { + Right = 10, + Vertical = 20, + }, + Children = new Drawable[] + { + ButtonsContainer, + drawableRoomParticipantsList = new DrawableRoomParticipantsList + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + NumberOfCircles = NumberOfAvatars } } }, - }, - new FillFlowContainer - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(5), - ChildrenEnumerable = CreateBottomDetails() - } - } - }, - new FillFlowContainer - { - Name = "Right content", - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, - Spacing = new Vector2(5), - Padding = new MarginPadding - { - Right = 10, - Vertical = 20, - }, - Children = new Drawable[] - { - ButtonsContainer, - drawableRoomParticipantsList = new DrawableRoomParticipantsList - { - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - NumberOfCircles = NumberOfAvatars } } }, @@ -311,23 +322,6 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components return pills; } - private partial class RoomNameText : OsuSpriteText - { - [Resolved(typeof(Room), nameof(Online.Rooms.Room.Name))] - private Bindable name { get; set; } - - public RoomNameText() - { - Font = OsuFont.GetFont(size: 28); - } - - [BackgroundDependencyLoader] - private void load() - { - Current = name; - } - } - private partial class RoomStatusText : OnlinePlayComposite { [Resolved] @@ -343,7 +337,6 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - Width = 0.5f; } [BackgroundDependencyLoader] diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs index c31633eefc..06f9f35479 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoomParticipantsList.cs @@ -24,8 +24,14 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components { public partial class DrawableRoomParticipantsList : OnlinePlayComposite { + public const float SHEAR_WIDTH = 12f; + private const float avatar_size = 36; + private const float height = 60f; + + private static readonly Vector2 shear = new Vector2(SHEAR_WIDTH / height, 0); + private FillFlowContainer avatarFlow; private CircularAvatar hostAvatar; @@ -36,7 +42,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components public DrawableRoomParticipantsList() { AutoSizeAxes = Axes.X; - Height = 60; + Height = height; } [BackgroundDependencyLoader] @@ -49,7 +55,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components RelativeSizeAxes = Axes.Both, Masking = true, CornerRadius = 10, - Shear = new Vector2(0.2f, 0), + Shear = shear, Child = new Box { RelativeSizeAxes = Axes.Both, @@ -98,7 +104,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components RelativeSizeAxes = Axes.Both, Masking = true, CornerRadius = 10, - Shear = new Vector2(0.2f, 0), + Shear = shear, Child = new Box { RelativeSizeAxes = Axes.Both, diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/MatchTypePill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/MatchTypePill.cs index f96d547747..35e0482f2b 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/MatchTypePill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/MatchTypePill.cs @@ -3,39 +3,14 @@ #nullable disable -using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; -using osu.Framework.Graphics; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public partial class MatchTypePill : OnlinePlayComposite + public partial class MatchTypePill : OnlinePlayPill { - private OsuTextFlowContainer textFlow; - - public MatchTypePill() - { - AutoSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load() - { - InternalChild = new PillContainer - { - Child = textFlow = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12)) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - } - }; - } - protected override void LoadComplete() { base.LoadComplete(); @@ -45,8 +20,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components private void onMatchTypeChanged(ValueChangedEvent type) { - textFlow.Clear(); - textFlow.AddText(type.NewValue.GetLocalisableDescription()); + TextFlow.Text = type.NewValue.GetLocalisableDescription(); } } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/OnlinePlayPill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/OnlinePlayPill.cs new file mode 100644 index 0000000000..3e6d7a2e54 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/OnlinePlayPill.cs @@ -0,0 +1,37 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; + +namespace osu.Game.Screens.OnlinePlay.Lounge.Components +{ + public abstract partial class OnlinePlayPill : OnlinePlayComposite + { + protected PillContainer Pill { get; private set; } = null!; + protected OsuTextFlowContainer TextFlow { get; private set; } = null!; + protected virtual FontUsage Font => OsuFont.GetFont(size: 12); + + protected OnlinePlayPill() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = Pill = new PillContainer + { + Child = TextFlow = new OsuTextFlowContainer(s => s.Font = Font) + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + } + }; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/PlaylistCountPill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/PlaylistCountPill.cs index 81ba48d135..d1365c02f3 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/PlaylistCountPill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/PlaylistCountPill.cs @@ -5,40 +5,16 @@ using System.Linq; using Humanizer; -using osu.Framework.Allocation; using osu.Framework.Extensions.LocalisationExtensions; -using osu.Framework.Graphics; using osu.Game.Graphics; -using osu.Game.Graphics.Containers; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { /// /// A pill that displays the playlist item count. /// - public partial class PlaylistCountPill : OnlinePlayComposite + public partial class PlaylistCountPill : OnlinePlayPill { - private OsuTextFlowContainer count; - - public PlaylistCountPill() - { - AutoSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load() - { - InternalChild = new PillContainer - { - Child = count = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12)) - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - } - }; - } - protected override void LoadComplete() { base.LoadComplete(); @@ -55,10 +31,10 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components ? Playlist.Count(i => !i.Expired) : PlaylistItemStats.Value.CountActive; - count.Clear(); - count.AddText(activeItems.ToLocalisableString(), s => s.Font = s.Font.With(weight: FontWeight.Bold)); - count.AddText(" "); - count.AddText("Beatmap".ToQuantity(activeItems, ShowQuantityAs.None)); + TextFlow.Clear(); + TextFlow.AddText(activeItems.ToLocalisableString(), s => s.Font = s.Font.With(weight: FontWeight.Bold)); + TextFlow.AddText(" "); + TextFlow.AddText("Beatmap".ToQuantity(activeItems, ShowQuantityAs.None)); } } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/QueueModePill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/QueueModePill.cs index 0175418a96..208c11c155 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/QueueModePill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/QueueModePill.cs @@ -3,39 +3,14 @@ #nullable disable -using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; -using osu.Framework.Graphics; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; using osu.Game.Online.Multiplayer; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public partial class QueueModePill : OnlinePlayComposite + public partial class QueueModePill : OnlinePlayPill { - private OsuTextFlowContainer textFlow; - - public QueueModePill() - { - AutoSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load() - { - InternalChild = new PillContainer - { - Child = textFlow = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12)) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - AutoSizeAxes = Axes.Both, - } - }; - } - protected override void LoadComplete() { base.LoadComplete(); @@ -45,8 +20,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components private void onQueueModeChanged(ValueChangedEvent mode) { - textFlow.Clear(); - textFlow.AddText(mode.NewValue.GetLocalisableDescription()); + TextFlow.Text = mode.NewValue.GetLocalisableDescription(); } } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomSpecialCategoryPill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomSpecialCategoryPill.cs index 5d67a18d1f..10f6e59260 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomSpecialCategoryPill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomSpecialCategoryPill.cs @@ -5,58 +5,30 @@ using osu.Framework.Allocation; using osu.Framework.Extensions; -using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { - public partial class RoomSpecialCategoryPill : OnlinePlayComposite + public partial class RoomSpecialCategoryPill : OnlinePlayPill { - private SpriteText text; - private PillContainer pill; - [Resolved] private OsuColour colours { get; set; } - public RoomSpecialCategoryPill() - { - AutoSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load() - { - InternalChild = pill = new PillContainer - { - Background = - { - Colour = colours.Pink, - Alpha = 1 - }, - Child = text = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 12), - Colour = Color4.Black - } - }; - } + protected override FontUsage Font => base.Font.With(weight: FontWeight.SemiBold); protected override void LoadComplete() { base.LoadComplete(); + Pill.Background.Alpha = 1; + TextFlow.Colour = Color4.Black; + Category.BindValueChanged(c => { - text.Text = c.NewValue.GetLocalisableDescription(); - - var backgroundColour = colours.ForRoomCategory(Category.Value); - if (backgroundColour != null) - pill.Background.Colour = backgroundColour.Value; + TextFlow.Text = c.NewValue.GetLocalisableDescription(); + Pill.Background.Colour = colours.ForRoomCategory(c.NewValue) ?? colours.Pink; }, true); } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs index 201314851e..ca9917ad00 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomStatusPill.cs @@ -8,43 +8,20 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Sprites; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; using osu.Game.Online.Rooms; using osu.Game.Online.Rooms.RoomStatuses; -using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Lounge.Components { /// /// A pill that displays the room's current status. /// - public partial class RoomStatusPill : OnlinePlayComposite + public partial class RoomStatusPill : OnlinePlayPill { [Resolved] private OsuColour colours { get; set; } - private PillContainer pill; - private SpriteText statusText; - - public RoomStatusPill() - { - AutoSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load() - { - InternalChild = pill = new PillContainer - { - Child = statusText = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Font = OsuFont.GetFont(weight: FontWeight.SemiBold, size: 12), - Colour = Color4.Black - } - }; - } + protected override FontUsage Font => base.Font.With(weight: FontWeight.SemiBold); protected override void LoadComplete() { @@ -54,15 +31,17 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components Status.BindValueChanged(_ => updateDisplay(), true); FinishTransforms(true); + + TextFlow.Colour = Colour4.Black; + Pill.Background.Alpha = 1; } private void updateDisplay() { RoomStatus status = getDisplayStatus(); - pill.Background.Alpha = 1; - pill.Background.FadeColour(status.GetAppropriateColour(colours), 100); - statusText.Text = status.Message; + Pill.Background.FadeColour(status.GetAppropriateColour(colours), 100); + TextFlow.Text = status.Message; } private RoomStatus getDisplayStatus() diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs index 4d4fe4ea56..05232fe0e2 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs @@ -54,14 +54,12 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components protected override void PopIn() { - base.PopIn(); Settings.MoveToY(0, TRANSITION_DURATION, Easing.OutQuint); Settings.FadeIn(TRANSITION_DURATION / 2); } protected override void PopOut() { - base.PopOut(); Settings.MoveToY(-1, TRANSITION_DURATION, Easing.InSine); Settings.Delay(TRANSITION_DURATION / 2).FadeOut(TRANSITION_DURATION / 2); } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistDisplayMode.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistDisplayMode.cs index 1672f98637..cc3dca6a34 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistDisplayMode.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistDisplayMode.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist { /// diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MasterClockState.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MasterClockState.cs index 92dbde9f08..8982d1669d 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MasterClockState.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MasterClockState.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate { public enum MasterClockState diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs index 3d80248306..37b50b4863 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs @@ -132,7 +132,12 @@ namespace osu.Game.Screens.OnlinePlay this.ScaleTo(1, 250, Easing.OutSine); Debug.Assert(screenStack.CurrentScreen != null); - screenStack.CurrentScreen.OnResuming(e); + + // if a subscreen was pushed to the nested stack while the stack was not present, this path will proxy `OnResuming()` + // to the subscreen before `OnEntering()` can even be called for the subscreen, breaking ordering expectations. + // to work around this, do not proxy resume to screens that haven't loaded yet. + if ((screenStack.CurrentScreen as Drawable)?.IsLoaded == true) + screenStack.CurrentScreen.OnResuming(e); base.OnResuming(e); } @@ -143,7 +148,12 @@ namespace osu.Game.Screens.OnlinePlay this.FadeOut(250); Debug.Assert(screenStack.CurrentScreen != null); - screenStack.CurrentScreen.OnSuspending(e); + + // if a subscreen was pushed to the nested stack while the stack was not present, this path will proxy `OnSuspending()` + // to the subscreen before `OnEntering()` can even be called for the subscreen, breaking ordering expectations. + // to work around this, do not proxy suspend to screens that haven't loaded yet. + if ((screenStack.CurrentScreen as Drawable)?.IsLoaded == true) + screenStack.CurrentScreen.OnSuspending(e); } public override bool OnExiting(ScreenExitEvent e) diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs index 0c25a32259..b0e4585986 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs @@ -6,14 +6,12 @@ using System; using System.Diagnostics; using System.Linq; -using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Screens; using osu.Game.Extensions; using osu.Game.Online.Rooms; using osu.Game.Rulesets; -using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; @@ -63,13 +61,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists return new PlaylistsResultsScreen(score, Room.RoomID.Value.Value, PlaylistItem, true); } - protected override async Task PrepareScoreForResultsAsync(Score score) - { - await base.PrepareScoreForResultsAsync(score).ConfigureAwait(false); - - Score.ScoreInfo.TotalScore = ScoreProcessor.ComputeScore(ScoringMode.Standardised, Score.ScoreInfo); - } - protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs index d40d43cd54..aa72394ac9 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs @@ -182,7 +182,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists /// An optional pivot around which the scores were retrieved. private void performSuccessCallback([NotNull] Action> callback, [NotNull] List scores, [CanBeNull] MultiplayerScores pivot = null) => Schedule(() => { - var scoreInfos = scoreManager.OrderByTotalScore(scores.Select(s => s.CreateScoreInfo(scoreManager, rulesets, playlistItem, Beatmap.Value.BeatmapInfo))).ToArray(); + var scoreInfos = scores.Select(s => s.CreateScoreInfo(scoreManager, rulesets, playlistItem, Beatmap.Value.BeatmapInfo)).OrderByTotalScore().ToArray(); // Select a score if we don't already have one selected. // Note: This is done before the callback so that the panel list centres on the selected score before panels are added (eliminating initial scroll). diff --git a/osu.Game/Screens/OsuScreen.cs b/osu.Game/Screens/OsuScreen.cs index bc4cc2b00f..9c098794a6 100644 --- a/osu.Game/Screens/OsuScreen.cs +++ b/osu.Game/Screens/OsuScreen.cs @@ -233,7 +233,13 @@ namespace osu.Game.Screens /// protected virtual void LogoArriving(OsuLogo logo, bool resuming) { - ApplyLogoArrivingDefaults(logo); + logo.Action = null; + logo.FadeOut(300, Easing.OutQuint); + logo.Anchor = Anchor.TopLeft; + logo.Origin = Anchor.Centre; + logo.RelativePositionAxes = Axes.Both; + logo.Triangles = true; + logo.Ripple = true; } private void applyArrivingDefaults(bool isResuming) @@ -244,22 +250,6 @@ namespace osu.Game.Screens }, true); } - /// - /// Applies default animations to an arriving logo. - /// Todo: This should not exist. - /// - /// The logo to apply animations to. - public static void ApplyLogoArrivingDefaults(OsuLogo logo) - { - logo.Action = null; - logo.FadeOut(300, Easing.OutQuint); - logo.Anchor = Anchor.TopLeft; - logo.Origin = Anchor.Centre; - logo.RelativePositionAxes = Axes.Both; - logo.Triangles = true; - logo.Ripple = true; - } - private void onExitingLogo() { logo?.AppendAnimatingAction(() => LogoExiting(logo), false); diff --git a/osu.Game/Screens/Play/ArgonKeyCounter.cs b/osu.Game/Screens/Play/ArgonKeyCounter.cs index 6818b30823..2d725898d8 100644 --- a/osu.Game/Screens/Play/ArgonKeyCounter.cs +++ b/osu.Game/Screens/Play/ArgonKeyCounter.cs @@ -14,6 +14,7 @@ namespace osu.Game.Screens.Play public partial class ArgonKeyCounter : KeyCounter { private Circle inputIndicator = null!; + private OsuSpriteText keyNameText = null!; private OsuSpriteText countText = null!; // These values were taken from Figma @@ -24,13 +25,16 @@ namespace osu.Game.Screens.Play // Make things look bigger without using Scale private const float scale_factor = 1.5f; + [Resolved] + private OsuColour colours { get; set; } = null!; + public ArgonKeyCounter(InputTrigger trigger) : base(trigger) { } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { Children = new Drawable[] { @@ -42,7 +46,7 @@ namespace osu.Game.Screens.Play Height = line_height * scale_factor, Alpha = 0.5f }, - new OsuSpriteText + keyNameText = new OsuSpriteText { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, @@ -69,8 +73,33 @@ namespace osu.Game.Screens.Play { base.LoadComplete(); - IsActive.BindValueChanged(e => inputIndicator.Alpha = e.NewValue ? 1 : 0.5f, true); CountPresses.BindValueChanged(e => countText.Text = e.NewValue.ToString(@"#,0"), true); } + + protected override void Activate(bool forwardPlayback = true) + { + base.Activate(forwardPlayback); + + keyNameText + .FadeColour(Colour4.White, 10, Easing.OutQuint); + + inputIndicator + .FadeIn(10, Easing.OutQuint) + .MoveToY(0) + .Then() + .MoveToY(4, 60, Easing.OutQuint); + } + + protected override void Deactivate(bool forwardPlayback = true) + { + base.Deactivate(forwardPlayback); + + keyNameText + .FadeColour(colours.Blue0, 200, Easing.OutQuart); + + inputIndicator + .MoveToY(0, 250, Easing.OutQuart) + .FadeTo(0.5f, 250, Easing.OutQuart); + } } } diff --git a/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs b/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs index a152f4be19..66aa3d9cc0 100644 --- a/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs +++ b/osu.Game/Screens/Play/BeatmapMetadataDisplay.cs @@ -109,7 +109,7 @@ namespace osu.Game.Screens.Play new Sprite { RelativeSizeAxes = Axes.Both, - Texture = beatmap.Background, + Texture = beatmap.GetBackground(), Origin = Anchor.Centre, Anchor = Anchor.Centre, FillMode = FillMode.Fill, diff --git a/osu.Game/Screens/Play/FailOverlay.cs b/osu.Game/Screens/Play/FailOverlay.cs index 4fbc937b59..abfc401998 100644 --- a/osu.Game/Screens/Play/FailOverlay.cs +++ b/osu.Game/Screens/Play/FailOverlay.cs @@ -15,6 +15,8 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; +using osu.Game.Localisation; namespace osu.Game.Screens.Play { @@ -22,14 +24,13 @@ namespace osu.Game.Screens.Play { public Func> SaveReplay; - public override string Header => "failed"; - public override string Description => "you're dead, try again?"; + public override LocalisableString Header => GameplayMenuOverlayStrings.FailedHeader; [BackgroundDependencyLoader] private void load(OsuColour colours) { - AddButton("Retry", colours.YellowDark, () => OnRetry?.Invoke()); - AddButton("Quit", new Color4(170, 27, 39, 255), () => OnQuit?.Invoke()); + AddButton(GameplayMenuOverlayStrings.Retry, colours.YellowDark, () => OnRetry?.Invoke()); + AddButton(GameplayMenuOverlayStrings.Quit, new Color4(170, 27, 39, 255), () => OnQuit?.Invoke()); // from #10339 maybe this is a better visual effect Add(new Container { diff --git a/osu.Game/Screens/Play/GameplayMenuOverlay.cs b/osu.Game/Screens/Play/GameplayMenuOverlay.cs index 81146a4ea6..0680842891 100644 --- a/osu.Game/Screens/Play/GameplayMenuOverlay.cs +++ b/osu.Game/Screens/Play/GameplayMenuOverlay.cs @@ -1,12 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Collections.Generic; using System.Linq; -using Humanizer; using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; @@ -15,6 +12,8 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -22,6 +21,7 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osuTK; using osuTK.Graphics; +using osu.Game.Localisation; namespace osu.Game.Screens.Play { @@ -38,8 +38,8 @@ namespace osu.Game.Screens.Play public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; - public Action OnRetry; - public Action OnQuit; + public Action? OnRetry; + public Action? OnQuit; /// /// Action that is invoked when is triggered. @@ -51,14 +51,15 @@ namespace osu.Game.Screens.Play /// protected virtual Action SelectAction => () => InternalButtons.Selected?.TriggerClick(); - public abstract string Header { get; } + public abstract LocalisableString Header { get; } - public abstract string Description { get; } - - protected SelectionCycleFillFlowContainer InternalButtons; + protected SelectionCycleFillFlowContainer InternalButtons = null!; public IReadOnlyList Buttons => InternalButtons; - private FillFlowContainer retryCounterContainer; + private TextFlowContainer playInfoText = null!; + + [Resolved] + private GlobalActionContainer globalAction { get; set; } = null!; protected GameplayMenuOverlay() { @@ -86,36 +87,13 @@ namespace osu.Game.Screens.Play Anchor = Anchor.Centre, Children = new Drawable[] { - new FillFlowContainer + new OsuSpriteText { + Text = Header, + Font = OsuFont.GetFont(size: 48), Origin = Anchor.TopCentre, Anchor = Anchor.TopCentre, - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 20), - Children = new Drawable[] - { - new OsuSpriteText - { - Text = Header, - Font = OsuFont.GetFont(size: 30), - Spacing = new Vector2(5, 0), - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Colour = colours.Yellow, - Shadow = true, - ShadowColour = new Color4(0, 0, 0, 0.25f) - }, - new OsuSpriteText - { - Text = Description, - Origin = Anchor.TopCentre, - Anchor = Anchor.TopCentre, - Shadow = true, - ShadowColour = new Color4(0, 0, 0, 0.25f) - } - } + Colour = colours.Yellow, }, InternalButtons = new SelectionCycleFillFlowContainer { @@ -132,10 +110,11 @@ namespace osu.Game.Screens.Play Radius = 50 }, }, - retryCounterContainer = new FillFlowContainer + playInfoText = new OsuTextFlowContainer(cp => cp.Font = OsuFont.GetFont(size: 18)) { Origin = Anchor.TopCentre, Anchor = Anchor.TopCentre, + TextAnchor = Anchor.TopCentre, AutoSizeAxes = Axes.Both, } } @@ -144,7 +123,7 @@ namespace osu.Game.Screens.Play State.ValueChanged += _ => InternalButtons.Deselect(); - updateRetryCount(); + updateInfoText(); } private int retries; @@ -157,12 +136,18 @@ namespace osu.Game.Screens.Play return; retries = value; - if (retryCounterContainer != null) - updateRetryCount(); + + if (IsLoaded) + updateInfoText(); } } - protected override void PopIn() => this.FadeIn(TRANSITION_DURATION, Easing.In); + protected override void PopIn() + { + this.FadeIn(TRANSITION_DURATION, Easing.In); + updateInfoText(); + } + protected override void PopOut() => this.FadeOut(TRANSITION_DURATION, Easing.In); // Don't let mouse down events through the overlay or people can click circles while paused. @@ -170,7 +155,7 @@ namespace osu.Game.Screens.Play protected override bool OnMouseMove(MouseMoveEvent e) => true; - protected void AddButton(string text, Color4 colour, Action action) + protected void AddButton(LocalisableString text, Color4 colour, Action? action) { var button = new Button { @@ -217,35 +202,39 @@ namespace osu.Game.Screens.Play { } - private void updateRetryCount() - { - // "You've retried 1,065 times in this session" - // "You've retried 1 time in this session" + [Resolved] + private IGameplayClock? gameplayClock { get; set; } - retryCounterContainer.Children = new Drawable[] + [Resolved] + private GameplayState? gameplayState { get; set; } + + private void updateInfoText() + { + playInfoText.Clear(); + playInfoText.AddText(GameplayMenuOverlayStrings.RetryCount); + playInfoText.AddText(retries.ToString(), cp => cp.Font = cp.Font.With(weight: FontWeight.Bold)); + + if (getSongProgress() is int progress) { - new OsuSpriteText - { - Text = "You've retried ", - Shadow = true, - ShadowColour = new Color4(0, 0, 0, 0.25f), - Font = OsuFont.GetFont(size: 18), - }, - new OsuSpriteText - { - Text = "time".ToQuantity(retries), - Font = OsuFont.GetFont(weight: FontWeight.Bold, size: 18), - Shadow = true, - ShadowColour = new Color4(0, 0, 0, 0.25f), - }, - new OsuSpriteText - { - Text = " in this session", - Shadow = true, - ShadowColour = new Color4(0, 0, 0, 0.25f), - Font = OsuFont.GetFont(size: 18), - } - }; + playInfoText.NewLine(); + playInfoText.AddText(GameplayMenuOverlayStrings.SongProgress); + playInfoText.AddText($"{progress}%", cp => cp.Font = cp.Font.With(weight: FontWeight.Bold)); + } + } + + private int? getSongProgress() + { + if (gameplayClock == null || gameplayState == null) + return null; + + (double firstHitTime, double lastHitTime) = gameplayState.Beatmap.CalculatePlayableBounds(); + + double playableLength = (lastHitTime - firstHitTime); + + if (playableLength == 0) + return 0; + + return (int)Math.Clamp(((gameplayClock.CurrentTime - firstHitTime) / playableLength) * 100, 0, 100); } private partial class Button : DialogButton @@ -260,9 +249,6 @@ namespace osu.Game.Screens.Play } } - [Resolved] - private GlobalActionContainer globalAction { get; set; } - protected override bool Handle(UIEvent e) { switch (e) diff --git a/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs b/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs index 9dce8996c3..be2ce3b272 100644 --- a/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs +++ b/osu.Game/Screens/Play/HUD/ArgonSongProgress.cs @@ -95,7 +95,6 @@ namespace osu.Game.Screens.Play.HUD private void updateGraphVisibility() { graph.FadeTo(ShowGraph.Value ? 1 : 0, 200, Easing.In); - bar.ShowBackground = !ShowGraph.Value; } protected override void Update() diff --git a/osu.Game/Screens/Play/HUD/ArgonSongProgressBar.cs b/osu.Game/Screens/Play/HUD/ArgonSongProgressBar.cs index dd6e10ba5d..beaee0e9ee 100644 --- a/osu.Game/Screens/Play/HUD/ArgonSongProgressBar.cs +++ b/osu.Game/Screens/Play/HUD/ArgonSongProgressBar.cs @@ -14,7 +14,6 @@ using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Graphics; using osuTK; -using osuTK.Graphics; namespace osu.Game.Screens.Play.HUD { @@ -32,18 +31,8 @@ namespace osu.Game.Screens.Play.HUD private readonly Box background; - private readonly BindableBool showBackground = new BindableBool(); - private readonly ColourInfo mainColour; - private readonly ColourInfo mainColourDarkened; private ColourInfo catchUpColour; - private ColourInfo catchUpColourDarkened; - - public bool ShowBackground - { - get => showBackground.Value; - set => showBackground.Value = value; - } public double StartTime { @@ -95,7 +84,7 @@ namespace osu.Game.Screens.Play.HUD { RelativeSizeAxes = Axes.Both, Alpha = 0, - Colour = Colour4.White.Darken(1 + 1 / 4f) + Colour = OsuColour.Gray(0.2f), }, catchupBar = new RoundedBar { @@ -112,12 +101,10 @@ namespace osu.Game.Screens.Play.HUD Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, CornerRadius = 5, - AccentColour = mainColour = Color4.White, + AccentColour = mainColour = OsuColour.Gray(0.9f), RelativeSizeAxes = Axes.Both }, }; - - mainColourDarkened = Colour4.White.Darken(1 / 3f); } private void setupAlternateValue() @@ -141,16 +128,15 @@ namespace osu.Game.Screens.Play.HUD [BackgroundDependencyLoader] private void load(OsuColour colours) { - catchUpColour = colours.BlueLight; - catchUpColourDarkened = colours.BlueDark; - - showBackground.BindValueChanged(_ => updateBackground(), true); + catchUpColour = colours.BlueDark; } - private void updateBackground() + protected override void LoadComplete() { - background.FadeTo(showBackground.Value ? 1 / 4f : 0, 200, Easing.In); - playfieldBar.TransformTo(nameof(playfieldBar.AccentColour), ShowBackground ? mainColour : mainColourDarkened, 200, Easing.In); + base.LoadComplete(); + + background.FadeTo(0.3f, 200, Easing.In); + playfieldBar.TransformTo(nameof(playfieldBar.AccentColour), mainColour, 200, Easing.In); } protected override bool OnHover(HoverEvent e) @@ -190,8 +176,8 @@ namespace osu.Game.Screens.Play.HUD catchupBar.AccentColour = Interpolation.ValueAt( Math.Min(timeDelta, colour_transition_threshold), - ShowBackground ? mainColour : mainColourDarkened, - ShowBackground ? catchUpColour : catchUpColourDarkened, + mainColour, + catchUpColour, 0, colour_transition_threshold, Easing.OutQuint); diff --git a/osu.Game/Screens/Play/HUD/ArgonSongProgressGraph.cs b/osu.Game/Screens/Play/HUD/ArgonSongProgressGraph.cs index 0899476ed4..be570c1578 100644 --- a/osu.Game/Screens/Play/HUD/ArgonSongProgressGraph.cs +++ b/osu.Game/Screens/Play/HUD/ArgonSongProgressGraph.cs @@ -4,7 +4,10 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Graphics; using osu.Game.Rulesets.Objects; using osu.Game.Graphics.UserInterface; @@ -12,6 +15,10 @@ namespace osu.Game.Screens.Play.HUD { public partial class ArgonSongProgressGraph : SegmentedGraph { + private const int tier_count = 5; + + private const int display_granularity = 200; + private IEnumerable? objects; public IEnumerable Objects @@ -20,19 +27,17 @@ namespace osu.Game.Screens.Play.HUD { objects = value; - const int granularity = 200; - int[] values = new int[granularity]; + int[] values = new int[display_granularity]; if (!objects.Any()) return; - double firstHit = objects.First().StartTime; - double lastHit = objects.Max(o => o.GetEndTime()); + (double firstHit, double lastHit) = BeatmapExtensions.CalculatePlayableBounds(objects); if (lastHit == 0) lastHit = objects.Last().StartTime; - double interval = (lastHit - firstHit + 1) / granularity; + double interval = (lastHit - firstHit + 1) / display_granularity; foreach (var h in objects) { @@ -51,12 +56,12 @@ namespace osu.Game.Screens.Play.HUD } public ArgonSongProgressGraph() - : base(5) + : base(tier_count) { var colours = new List(); - for (int i = 0; i < 5; i++) - colours.Add(Colour4.White.Darken(1 + 1 / 5f).Opacity(1 / 5f)); + for (int i = 0; i < tier_count; i++) + colours.Add(OsuColour.Gray(0.2f).Opacity(0.1f)); TierColours = colours; } diff --git a/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs b/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs index 6eed563703..202ead2d66 100644 --- a/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs +++ b/osu.Game/Screens/Play/HUD/DefaultSongProgress.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Utils; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Localisation.HUD; @@ -101,7 +102,12 @@ namespace osu.Game.Screens.Play.HUD protected override void Update() { base.Update(); - Height = bottom_bar_height + graph_height + handle_size.Y + info.Height - graph.Y; + + // to prevent unnecessary invalidations of the song progress graph due to changes in size, apply tolerance when updating the height. + float newHeight = bottom_bar_height + graph_height + handle_size.Y + info.Height - graph.Y; + + if (!Precision.AlmostEquals(Height, newHeight, 5f)) + Height = newHeight; } private void updateBarVisibility() diff --git a/osu.Game/Screens/Play/HUD/DefaultSongProgressGraph.cs b/osu.Game/Screens/Play/HUD/DefaultSongProgressGraph.cs index bee5978817..047c64a4a4 100644 --- a/osu.Game/Screens/Play/HUD/DefaultSongProgressGraph.cs +++ b/osu.Game/Screens/Play/HUD/DefaultSongProgressGraph.cs @@ -6,6 +6,7 @@ using System.Linq; using System.Collections.Generic; using System.Diagnostics; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects; namespace osu.Game.Screens.Play.HUD @@ -26,8 +27,7 @@ namespace osu.Game.Screens.Play.HUD if (!objects.Any()) return; - double firstHit = objects.First().StartTime; - double lastHit = objects.Max(o => o.GetEndTime()); + (double firstHit, double lastHit) = BeatmapExtensions.CalculatePlayableBounds(objects); if (lastHit == 0) lastHit = objects.Last().StartTime; diff --git a/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs b/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs index 07b80feb3e..dcb2c1071e 100644 --- a/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs +++ b/osu.Game/Screens/Play/HUD/GameplayLeaderboardScore.cs @@ -1,18 +1,17 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; -using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Scoring; using osu.Game.Users; using osu.Game.Users.Drawables; using osu.Game.Utils; @@ -48,7 +47,7 @@ namespace osu.Game.Screens.Play.HUD public Bindable Expanded = new Bindable(); - private OsuSpriteText positionText, scoreText, accuracyText, comboText, usernameText; + private OsuSpriteText positionText = null!, scoreText = null!, accuracyText = null!, comboText = null!, usernameText = null!; public BindableLong TotalScore { get; } = new BindableLong(); public BindableDouble Accuracy { get; } = new BindableDouble(1); @@ -56,6 +55,13 @@ namespace osu.Game.Screens.Play.HUD public BindableBool HasQuit { get; } = new BindableBool(); public Bindable DisplayOrder { get; } = new Bindable(); + private Func? getDisplayScoreFunction; + + public Func GetDisplayScore + { + set => getDisplayScoreFunction = value; + } + public Color4? BackgroundColour { get; set; } public Color4? TextColour { get; set; } @@ -82,40 +88,43 @@ namespace osu.Game.Screens.Play.HUD } } - [CanBeNull] - public IUser User { get; } + public IUser? User { get; } /// /// Whether this score is the local user or a replay player (and should be focused / always visible). /// public readonly bool Tracked; - private Container mainFillContainer; + private Container mainFillContainer = null!; - private Box centralFill; + private Box centralFill = null!; - private Container backgroundPaddingAdjustContainer; + private Container backgroundPaddingAdjustContainer = null!; - private GridContainer gridContainer; + private GridContainer gridContainer = null!; - private Container scoreComponents; + private Container scoreComponents = null!; + + private IBindable scoreDisplayMode = null!; /// /// Creates a new . /// /// The score's player. /// Whether the player is the local user or a replay player. - public GameplayLeaderboardScore([CanBeNull] IUser user, bool tracked) + public GameplayLeaderboardScore(IUser? user, bool tracked) { User = user; Tracked = tracked; AutoSizeAxes = Axes.X; Height = PANEL_HEIGHT; + + GetDisplayScore = _ => TotalScore.Value; } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, OsuConfigManager osuConfigManager) { Container avatarContainer; @@ -226,7 +235,7 @@ namespace osu.Game.Screens.Play.HUD } } }, - usernameText = new OsuSpriteText + usernameText = new TruncatingSpriteText { RelativeSizeAxes = Axes.X, Width = 0.6f, @@ -234,8 +243,7 @@ namespace osu.Game.Screens.Play.HUD Origin = Anchor.CentreLeft, Colour = Color4.White, Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold), - Text = User?.Username, - Truncate = true, + Text = User?.Username ?? string.Empty, Shadow = false, } } @@ -286,7 +294,9 @@ namespace osu.Game.Screens.Play.HUD LoadComponentAsync(new DrawableAvatar(User), avatarContainer.Add); - TotalScore.BindValueChanged(v => scoreText.Text = v.NewValue.ToString("N0"), true); + scoreDisplayMode = osuConfigManager.GetBindable(OsuSetting.ScoreDisplayMode); + scoreDisplayMode.BindValueChanged(_ => updateScore()); + TotalScore.BindValueChanged(_ => updateScore(), true); Accuracy.BindValueChanged(v => { @@ -313,6 +323,8 @@ namespace osu.Game.Screens.Play.HUD FinishTransforms(true); } + private void updateScore() => scoreText.Text = (getDisplayScoreFunction?.Invoke(scoreDisplayMode.Value) ?? TotalScore.Value).ToString("N0"); + private void changeExpandedState(ValueChangedEvent expanded) { if (expanded.NewValue) diff --git a/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs b/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs index a11cccd97c..a086aa6d72 100644 --- a/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs +++ b/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs @@ -1,20 +1,21 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring.Legacy; namespace osu.Game.Screens.Play.HUD { public abstract partial class GameplayScoreCounter : ScoreCounter { - private Bindable scoreDisplayMode; + private Bindable scoreDisplayMode = null!; + + private Bindable totalScoreBindable = null!; protected GameplayScoreCounter() : base(6) @@ -24,6 +25,9 @@ namespace osu.Game.Screens.Play.HUD [BackgroundDependencyLoader] private void load(OsuConfigManager config, ScoreProcessor scoreProcessor) { + totalScoreBindable = scoreProcessor.TotalScore.GetBoundCopy(); + totalScoreBindable.BindValueChanged(_ => updateDisplayScore()); + scoreDisplayMode = config.GetBindable(OsuSetting.ScoreDisplayMode); scoreDisplayMode.BindValueChanged(scoreMode => { @@ -40,9 +44,11 @@ namespace osu.Game.Screens.Play.HUD default: throw new ArgumentOutOfRangeException(nameof(scoreMode)); } + + updateDisplayScore(); }, true); - Current.BindTo(scoreProcessor.TotalScore); + void updateDisplayScore() => Current.Value = scoreProcessor.GetDisplayScore(scoreDisplayMode.Value); } } } diff --git a/osu.Game/Screens/Play/HUD/ILeaderboardScore.cs b/osu.Game/Screens/Play/HUD/ILeaderboardScore.cs index 428390f90c..1a5d7fd9a8 100644 --- a/osu.Game/Screens/Play/HUD/ILeaderboardScore.cs +++ b/osu.Game/Screens/Play/HUD/ILeaderboardScore.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. -#nullable disable - +using System; using osu.Framework.Bindables; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Screens.Play.HUD { @@ -20,5 +20,12 @@ namespace osu.Game.Screens.Play.HUD /// Lower numbers will appear higher in cases of ties. /// Bindable DisplayOrder { get; } + + /// + /// A custom function which handles converting a score to a display score using a provide . + /// + /// + /// If no function is provided, will be used verbatim. + Func GetDisplayScore { set; } } } diff --git a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs index 620f3718c2..922def6174 100644 --- a/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/MultiplayerGameplayLeaderboard.cs @@ -98,6 +98,7 @@ namespace osu.Game.Screens.Play.HUD var trackedUser = UserScores[user.Id]; var leaderboardScore = Add(user, user.Id == api.LocalUser.Value.Id); + leaderboardScore.GetDisplayScore = trackedUser.ScoreProcessor.GetDisplayScore; leaderboardScore.Accuracy.BindTo(trackedUser.ScoreProcessor.Accuracy); leaderboardScore.TotalScore.BindTo(trackedUser.ScoreProcessor.TotalScore); leaderboardScore.Combo.BindTo(trackedUser.ScoreProcessor.Combo); diff --git a/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs b/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs index 4f37c215e9..82f116b4ae 100644 --- a/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs +++ b/osu.Game/Screens/Play/HUD/PerformancePointsCounter.cs @@ -226,7 +226,7 @@ namespace osu.Game.Screens.Play.HUD protected override IBeatmap GetBeatmap() => gameplayBeatmap; - protected override Texture GetBackground() => throw new NotImplementedException(); + public override Texture GetBackground() => throw new NotImplementedException(); protected override Track GetBeatmapTrack() => throw new NotImplementedException(); diff --git a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs index 45b2c1b13c..064d2071ce 100644 --- a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs +++ b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs @@ -42,7 +42,8 @@ namespace osu.Game.Screens.Play.HUD //CollectionSettings = new CollectionSettings(), //DiscussionSettings = new DiscussionSettings(), PlaybackSettings = new PlaybackSettings { Expanded = { Value = false } }, - VisualSettings = new VisualSettings { Expanded = { Value = false } } + VisualSettings = new VisualSettings { Expanded = { Value = false } }, + new AudioSettings { Expanded = { Value = false } } } }; } diff --git a/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.cs b/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.cs index 9f92880919..e9bb1d2101 100644 --- a/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.cs +++ b/osu.Game/Screens/Play/HUD/SoloGameplayLeaderboard.cs @@ -1,7 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -10,6 +9,7 @@ using osu.Game.Configuration; using osu.Game.Online.API.Requests; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; +using osu.Game.Scoring.Legacy; using osu.Game.Screens.Select; using osu.Game.Users; @@ -27,15 +27,9 @@ namespace osu.Game.Screens.Play.HUD public readonly IBindableList Scores = new BindableList(); - // hold references to ensure bindables are updated. - private readonly List> scoreBindables = new List>(); - [Resolved] private ScoreProcessor scoreProcessor { get; set; } = null!; - [Resolved] - private ScoreManager scoreManager { get; set; } = null!; - /// /// Whether the leaderboard should be visible regardless of the configuration value. /// This is true by default, but can be changed. @@ -70,7 +64,6 @@ namespace osu.Game.Screens.Play.HUD private void showScores() { Clear(); - scoreBindables.Clear(); if (!Scores.Any()) return; @@ -79,12 +72,8 @@ namespace osu.Game.Screens.Play.HUD { var score = Add(s.User, false); - var bindableTotal = scoreManager.GetBindableTotalScore(s); - - // Direct binding not possible due to differing types (see https://github.com/ppy/osu/issues/20298). - bindableTotal.BindValueChanged(total => score.TotalScore.Value = total.NewValue, true); - scoreBindables.Add(bindableTotal); - + score.GetDisplayScore = s.GetDisplayScore; + score.TotalScore.Value = s.TotalScore; score.Accuracy.Value = s.Accuracy; score.Combo.Value = s.MaxCombo; score.DisplayOrder.Value = s.OnlineID > 0 ? s.OnlineID : s.Date.ToUnixTimeSeconds(); @@ -92,6 +81,7 @@ namespace osu.Game.Screens.Play.HUD ILeaderboardScore local = Add(trackingUser, true); + local.GetDisplayScore = scoreProcessor.GetDisplayScore; local.TotalScore.BindTarget = scoreProcessor.TotalScore; local.Accuracy.BindTarget = scoreProcessor.Accuracy; local.Combo.BindTarget = scoreProcessor.HighestCombo; diff --git a/osu.Game/Screens/Play/HUD/SongProgress.cs b/osu.Game/Screens/Play/HUD/SongProgress.cs index ebe2fb83e6..4391193df8 100644 --- a/osu.Game/Screens/Play/HUD/SongProgress.cs +++ b/osu.Game/Screens/Play/HUD/SongProgress.cs @@ -2,12 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; -using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Timing; +using osu.Game.Beatmaps; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; using osu.Game.Skinning; @@ -52,9 +52,9 @@ namespace osu.Game.Screens.Play.HUD set { objects = value; - FirstHitTime = objects.FirstOrDefault()?.StartTime ?? 0; - //TODO: this isn't always correct (consider mania where a non-last object may last for longer than the last in the list). - LastHitTime = objects.LastOrDefault()?.GetEndTime() ?? 0; + + (FirstHitTime, LastHitTime) = BeatmapExtensions.CalculatePlayableBounds(objects); + UpdateObjects(objects); } } diff --git a/osu.Game/Screens/Play/HUD/SongProgressInfo.cs b/osu.Game/Screens/Play/HUD/SongProgressInfo.cs index c04ecd671f..2f137f7e78 100644 --- a/osu.Game/Screens/Play/HUD/SongProgressInfo.cs +++ b/osu.Game/Screens/Play/HUD/SongProgressInfo.cs @@ -145,12 +145,12 @@ namespace osu.Game.Screens.Play.HUD double time = gameplayClock?.CurrentTime ?? Time.Current; double songCurrentTime = time - startTime; - int currentPercent = Math.Max(0, Math.Min(100, (int)(songCurrentTime / songLength * 100))); + int currentPercent = songLength == 0 ? 0 : Math.Max(0, Math.Min(100, (int)(songCurrentTime / songLength * 100))); int currentSecond = (int)Math.Floor(songCurrentTime / 1000.0); if (currentPercent != previousPercent) { - progress.Text = currentPercent + @"%"; + progress.Text = $@"{currentPercent}%"; previousPercent = currentPercent; } diff --git a/osu.Game/Screens/Play/PauseOverlay.cs b/osu.Game/Screens/Play/PauseOverlay.cs index db42998c45..88561ada71 100644 --- a/osu.Game/Screens/Play/PauseOverlay.cs +++ b/osu.Game/Screens/Play/PauseOverlay.cs @@ -9,9 +9,11 @@ using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Graphics; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Audio; using osu.Game.Graphics; using osu.Game.Input.Bindings; +using osu.Game.Localisation; using osu.Game.Skinning; using osuTK.Graphics; @@ -23,8 +25,7 @@ namespace osu.Game.Screens.Play public override bool IsPresent => base.IsPresent || pauseLoop.IsPlaying; - public override string Header => "paused"; - public override string Description => "you're not going to do what i think you're going to do, are ya?"; + public override LocalisableString Header => GameplayMenuOverlayStrings.PausedHeader; private SkinnableSound pauseLoop; @@ -33,9 +34,9 @@ namespace osu.Game.Screens.Play [BackgroundDependencyLoader] private void load(OsuColour colours) { - AddButton("Continue", colours.Green, () => OnResume?.Invoke()); - AddButton("Retry", colours.YellowDark, () => OnRetry?.Invoke()); - AddButton("Quit", new Color4(170, 27, 39, 255), () => OnQuit?.Invoke()); + AddButton(GameplayMenuOverlayStrings.Continue, colours.Green, () => OnResume?.Invoke()); + AddButton(GameplayMenuOverlayStrings.Retry, colours.YellowDark, () => OnRetry?.Invoke()); + AddButton(GameplayMenuOverlayStrings.Quit, new Color4(170, 27, 39, 255), () => OnQuit?.Invoke()); AddInternal(pauseLoop = new SkinnableSound(new SampleInfo("Gameplay/pause-loop")) { diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 5174adfc06..18ea9d0acb 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -237,9 +237,6 @@ namespace osu.Game.Screens.Play dependencies.CacheAs(HealthProcessor); - if (!ScoreProcessor.Mode.Disabled) - config.BindWith(OsuSetting.ScoreDisplayMode, ScoreProcessor.Mode); - InternalChild = GameplayClockContainer = CreateGameplayClockContainer(Beatmap.Value, DrawableRuleset.GameplayStartTime); AddInternal(screenSuspension = new ScreenSuspensionHandler(GameplayClockContainer)); diff --git a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs index 9492614b66..b542707185 100644 --- a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs +++ b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs @@ -18,6 +18,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Localisation; using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Ranking.Statistics; @@ -183,7 +184,7 @@ namespace osu.Game.Screens.Play.PlayerSettings if (score.NewValue == null) return; - if (score.NewValue.Mods.Any(m => !m.UserPlayable)) + if (score.NewValue.Mods.Any(m => !m.UserPlayable || m is IHasNoTimedInputs)) return; var hitEvents = score.NewValue.HitEvents; diff --git a/osu.Game/Screens/Play/SquareGraph.cs b/osu.Game/Screens/Play/SquareGraph.cs index 57b7c84e89..b53e86a41b 100644 --- a/osu.Game/Screens/Play/SquareGraph.cs +++ b/osu.Game/Screens/Play/SquareGraph.cs @@ -15,6 +15,7 @@ using osuTK; using osuTK.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Allocation; +using osu.Framework.Layout; using osu.Framework.Threading; namespace osu.Game.Screens.Play @@ -51,7 +52,7 @@ namespace osu.Game.Screens.Play if (value == values) return; values = value; - graphNeedsUpdate = true; + layout.Invalidate(); } } @@ -71,23 +72,25 @@ namespace osu.Game.Screens.Play private ScheduledDelegate scheduledCreate; - private bool graphNeedsUpdate; + private readonly LayoutValue layout = new LayoutValue(Invalidation.DrawSize | Invalidation.DrawInfo); - private Vector2 previousDrawSize; + public SquareGraph() + { + AddLayout(layout); + } protected override void Update() { base.Update(); - if (graphNeedsUpdate || (values != null && DrawSize != previousDrawSize)) + if (!layout.IsValid) { columns?.FadeOut(500, Easing.OutQuint).Expire(); scheduledCreate?.Cancel(); scheduledCreate = Scheduler.AddDelayed(RecreateGraph, 500); - previousDrawSize = DrawSize; - graphNeedsUpdate = false; + layout.Validate(); } } diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs index f23b469f5c..82c429798e 100644 --- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs @@ -73,7 +73,7 @@ namespace osu.Game.Screens.Ranking.Expanded var topStatistics = new List { new AccuracyStatistic(score.Accuracy), - new ComboStatistic(score.MaxCombo, scoreManager.GetMaximumAchievableCombo(score)), + new ComboStatistic(score.MaxCombo, score.GetMaximumAchievableCombo()), new PerformanceStatistic(score), }; @@ -101,23 +101,21 @@ namespace osu.Game.Screens.Ranking.Expanded Direction = FillDirection.Vertical, Children = new Drawable[] { - new OsuSpriteText + new TruncatingSpriteText { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Text = new RomanisableString(metadata.TitleUnicode, metadata.Title), Font = OsuFont.Torus.With(size: 20, weight: FontWeight.SemiBold), MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, - Truncate = true, }, - new OsuSpriteText + new TruncatingSpriteText { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Text = new RomanisableString(metadata.ArtistUnicode, metadata.Artist), Font = OsuFont.Torus.With(size: 14, weight: FontWeight.SemiBold), MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, - Truncate = true, }, new Container { @@ -156,14 +154,13 @@ namespace osu.Game.Screens.Ranking.Expanded AutoSizeAxes = Axes.Both, Children = new Drawable[] { - new OsuSpriteText + new TruncatingSpriteText { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Text = beatmap.DifficultyName, Font = OsuFont.Torus.With(size: 16, weight: FontWeight.SemiBold), MaxWidth = ScorePanel.EXPANDED_WIDTH - padding * 2, - Truncate = true, }, new OsuTextFlowContainer(s => s.Font = OsuFont.Torus.With(size: 12)) { diff --git a/osu.Game/Screens/Ranking/PanelState.cs b/osu.Game/Screens/Ranking/PanelState.cs index 3af74fe0f3..94e2c7cef4 100644 --- a/osu.Game/Screens/Ranking/PanelState.cs +++ b/osu.Game/Screens/Ranking/PanelState.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Screens.Ranking { public enum PanelState diff --git a/osu.Game/Screens/Ranking/ScorePanelList.cs b/osu.Game/Screens/Ranking/ScorePanelList.cs index 29dec42083..b75f3d86ff 100644 --- a/osu.Game/Screens/Ranking/ScorePanelList.cs +++ b/osu.Game/Screens/Ranking/ScorePanelList.cs @@ -9,7 +9,6 @@ using System.Diagnostics; using System.Linq; using System.Threading; using JetBrains.Annotations; -using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -67,9 +66,6 @@ namespace osu.Game.Screens.Ranking public readonly Bindable SelectedScore = new Bindable(); - [Resolved] - private ScoreManager scoreManager { get; set; } - private readonly CancellationTokenSource loadCancellationSource = new CancellationTokenSource(); private readonly Flow flow; private readonly Scroll scroll; @@ -149,7 +145,7 @@ namespace osu.Game.Screens.Ranking var score = trackingContainer.Panel.Score; - flow.SetLayoutPosition(trackingContainer, scoreManager.GetTotalScore(score)); + flow.SetLayoutPosition(trackingContainer, score.TotalScore); trackingContainer.Show(); diff --git a/osu.Game/Screens/Ranking/Statistics/SimpleStatisticTable.cs b/osu.Game/Screens/Ranking/Statistics/SimpleStatisticTable.cs index d10888be43..d68df4558a 100644 --- a/osu.Game/Screens/Ranking/Statistics/SimpleStatisticTable.cs +++ b/osu.Game/Screens/Ranking/Statistics/SimpleStatisticTable.cs @@ -17,7 +17,7 @@ namespace osu.Game.Screens.Ranking.Statistics { /// /// Represents a table with simple statistics (ones that only need textual display). - /// Richer visualisations should be done with s and s. + /// Richer visualisations should be done with s. /// public partial class SimpleStatisticTable : CompositeDrawable { diff --git a/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs index 57d072b7de..73b9897096 100644 --- a/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs +++ b/osu.Game/Screens/Ranking/Statistics/SoloStatisticsPanel.cs @@ -23,32 +23,26 @@ namespace osu.Game.Screens.Ranking.Statistics public Bindable StatisticsUpdate { get; } = new Bindable(); - protected override ICollection CreateStatisticRows(ScoreInfo newScore, IBeatmap playableBeatmap) + protected override ICollection CreateStatisticItems(ScoreInfo newScore, IBeatmap playableBeatmap) { - var rows = base.CreateStatisticRows(newScore, playableBeatmap); + var items = base.CreateStatisticItems(newScore, playableBeatmap); if (newScore.UserID > 1 && newScore.UserID == achievedScore.UserID && newScore.OnlineID > 0 && newScore.OnlineID == achievedScore.OnlineID) { - rows = rows.Append(new StatisticRow + items = items.Append(new StatisticItem("Overall Ranking", () => new OverallRanking { - Columns = new[] - { - new StatisticItem("Overall Ranking", () => new OverallRanking - { - RelativeSizeAxes = Axes.X, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Width = 0.5f, - StatisticsUpdate = { BindTarget = StatisticsUpdate } - }) - } - }).ToArray(); + RelativeSizeAxes = Axes.X, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 0.5f, + StatisticsUpdate = { BindTarget = StatisticsUpdate } + })).ToArray(); } - return rows; + return items; } } } diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs b/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs index 5bbd260d3f..c5bdc6f6f5 100644 --- a/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs +++ b/osu.Game/Screens/Ranking/Statistics/StatisticItem.cs @@ -6,7 +6,6 @@ using System; using JetBrains.Annotations; using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; namespace osu.Game.Screens.Ranking.Statistics @@ -26,29 +25,22 @@ namespace osu.Game.Screens.Ranking.Statistics /// public readonly Func CreateContent; - /// - /// The of this row. This can be thought of as the column dimension of an encompassing . - /// - public readonly Dimension Dimension; - /// /// Whether this item requires hit events. If true, will not be called if no hit events are available. /// public readonly bool RequiresHitEvents; /// - /// Creates a new , to be displayed inside a in the results screen. + /// Creates a new , to be displayed in the results screen. /// /// The name of the item. Can be to hide the item header. /// A function returning the content to be displayed. /// Whether this item requires hit events. If true, will not be called if no hit events are available. - /// The of this item. This can be thought of as the column dimension of an encompassing . - public StatisticItem(LocalisableString name, [NotNull] Func createContent, bool requiresHitEvents = false, [CanBeNull] Dimension dimension = null) + public StatisticItem(LocalisableString name, [NotNull] Func createContent, bool requiresHitEvents = false) { Name = name; RequiresHitEvents = requiresHitEvents; CreateContent = createContent; - Dimension = dimension; } } } diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticRow.cs b/osu.Game/Screens/Ranking/Statistics/StatisticRow.cs deleted file mode 100644 index 9f5f44918e..0000000000 --- a/osu.Game/Screens/Ranking/Statistics/StatisticRow.cs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using JetBrains.Annotations; - -namespace osu.Game.Screens.Ranking.Statistics -{ - /// - /// A row of statistics to be displayed in the results screen. - /// - public class StatisticRow - { - /// - /// The columns of this . - /// - [ItemNotNull] - public StatisticItem[] Columns; - } -} diff --git a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs index 4c22afd8f7..c36d7726dc 100644 --- a/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs +++ b/osu.Game/Screens/Ranking/Statistics/StatisticsPanel.cs @@ -100,9 +100,9 @@ namespace osu.Game.Screens.Ranking.Statistics bool hitEventsAvailable = newScore.HitEvents.Count != 0; Container container; - var statisticRows = CreateStatisticRows(newScore, task.GetResultSafely()); + var statisticItems = CreateStatisticItems(newScore, task.GetResultSafely()); - if (!hitEventsAvailable && statisticRows.SelectMany(r => r.Columns).All(c => c.RequiresHitEvents)) + if (!hitEventsAvailable && statisticItems.All(c => c.RequiresHitEvents)) { container = new FillFlowContainer { @@ -144,33 +144,22 @@ namespace osu.Game.Screens.Ranking.Statistics bool anyRequiredHitEvents = false; - foreach (var row in statisticRows) + foreach (var item in statisticItems) { - var columns = row.Columns; - - if (columns.Length == 0) - continue; - var columnContent = new List(); - var dimensions = new List(); - foreach (var col in columns) + if (!hitEventsAvailable && item.RequiresHitEvents) { - if (!hitEventsAvailable && col.RequiresHitEvents) - { - anyRequiredHitEvents = true; - continue; - } - - columnContent.Add(new StatisticContainer(col) - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }); - - dimensions.Add(col.Dimension ?? new Dimension()); + anyRequiredHitEvents = true; + continue; } + columnContent.Add(new StatisticContainer(item) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }); + rows.Add(new GridContainer { Anchor = Anchor.TopCentre, @@ -178,7 +167,7 @@ namespace osu.Game.Screens.Ranking.Statistics RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Content = new[] { columnContent.ToArray() }, - ColumnDimensions = dimensions.ToArray(), + ColumnDimensions = new[] { new Dimension() }, RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) } }); } @@ -219,11 +208,11 @@ namespace osu.Game.Screens.Ranking.Statistics } /// - /// Creates the s to be displayed in this panel for a given . + /// Creates the s to be displayed in this panel for a given . /// /// The score to create the rows for. /// The beatmap on which the score was set. - protected virtual ICollection CreateStatisticRows(ScoreInfo newScore, IBeatmap playableBeatmap) + protected virtual ICollection CreateStatisticItems(ScoreInfo newScore, IBeatmap playableBeatmap) => newScore.Ruleset.CreateInstance().CreateStatisticsForScore(newScore, playableBeatmap); protected override bool OnClick(ClickEvent e) diff --git a/osu.Game/Screens/ScorePresentType.cs b/osu.Game/Screens/ScorePresentType.cs index 24105467f1..3216f92091 100644 --- a/osu.Game/Screens/ScorePresentType.cs +++ b/osu.Game/Screens/ScorePresentType.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Screens { public enum ScorePresentType diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index 6ba9843f7b..3d87a57295 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -155,7 +155,7 @@ namespace osu.Game.Screens.Select public Bindable RandomAlgorithm = new Bindable(); private readonly List previouslyVisitedRandomSets = new List(); - private readonly Stack randomSelectedBeatmaps = new Stack(); + private readonly List randomSelectedBeatmaps = new List(); private CarouselRoot root; @@ -348,6 +348,11 @@ namespace osu.Game.Screens.Select if (!root.BeatmapSetsByID.TryGetValue(beatmapSetID, out var existingSet)) return; + foreach (var beatmap in existingSet.Beatmaps) + randomSelectedBeatmaps.Remove(beatmap); + + previouslyVisitedRandomSets.Remove(existingSet); + root.RemoveItem(existingSet); itemsCache.Invalidate(); @@ -501,7 +506,7 @@ namespace osu.Game.Screens.Select if (selectedBeatmap != null && selectedBeatmapSet != null) { - randomSelectedBeatmaps.Push(selectedBeatmap); + randomSelectedBeatmaps.Add(selectedBeatmap); // when performing a random, we want to add the current set to the previously visited list // else the user may be "randomised" to the existing selection. @@ -538,9 +543,10 @@ namespace osu.Game.Screens.Select { while (randomSelectedBeatmaps.Any()) { - var beatmap = randomSelectedBeatmaps.Pop(); + var beatmap = randomSelectedBeatmaps[^1]; + randomSelectedBeatmaps.Remove(beatmap); - if (!beatmap.Filtered.Value) + if (!beatmap.Filtered.Value && beatmap.BeatmapInfo.BeatmapSet?.DeletePending != true) { if (selectedBeatmapSet != null) { diff --git a/osu.Game/Screens/Select/BeatmapInfoWedge.cs b/osu.Game/Screens/Select/BeatmapInfoWedge.cs index 2102df1022..961f8684ce 100644 --- a/osu.Game/Screens/Select/BeatmapInfoWedge.cs +++ b/osu.Game/Screens/Select/BeatmapInfoWedge.cs @@ -233,12 +233,11 @@ namespace osu.Game.Screens.Select RelativeSizeAxes = Axes.X, Children = new Drawable[] { - VersionLabel = new OsuSpriteText + VersionLabel = new TruncatingSpriteText { Text = beatmapInfo.DifficultyName, Font = OsuFont.GetFont(size: 24, italics: true), RelativeSizeAxes = Axes.X, - Truncate = true, }, } }, @@ -286,19 +285,17 @@ namespace osu.Game.Screens.Select RelativeSizeAxes = Axes.X, Children = new Drawable[] { - TitleLabel = new OsuSpriteText + TitleLabel = new TruncatingSpriteText { Current = { BindTarget = titleBinding }, Font = OsuFont.GetFont(size: 28, italics: true), RelativeSizeAxes = Axes.X, - Truncate = true, }, - ArtistLabel = new OsuSpriteText + ArtistLabel = new TruncatingSpriteText { Current = { BindTarget = artistBinding }, Font = OsuFont.GetFont(size: 17, italics: true), RelativeSizeAxes = Axes.X, - Truncate = true, }, MapperContainer = new FillFlowContainer { diff --git a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs index 3975bb6bb6..4234184ad1 100644 --- a/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs +++ b/osu.Game/Screens/Select/Carousel/DrawableCarouselBeatmapSet.cs @@ -108,14 +108,15 @@ namespace osu.Game.Screens.Select.Carousel Header.Children = new Drawable[] { - background = new DelayedLoadWrapper(() => new SetPanelBackground(manager.GetWorkingBeatmap(beatmapSet.Beatmaps.FirstOrDefault())) + // Choice of background image matches BSS implementation (always uses the lowest `beatmap_id` from the set). + background = new DelayedLoadWrapper(() => new SetPanelBackground(manager.GetWorkingBeatmap(beatmapSet.Beatmaps.MinBy(b => b.OnlineID))) { RelativeSizeAxes = Axes.Both, - }, 300) + }, 200) { RelativeSizeAxes = Axes.Both }, - mainFlow = new DelayedLoadWrapper(() => new SetPanelContent((CarouselBeatmapSet)Item), 100) + mainFlow = new DelayedLoadWrapper(() => new SetPanelContent((CarouselBeatmapSet)Item), 50) { RelativeSizeAxes = Axes.Both }, @@ -125,7 +126,7 @@ namespace osu.Game.Screens.Select.Carousel mainFlow.DelayedLoadComplete += fadeContentIn; } - private void fadeContentIn(Drawable d) => d.FadeInFromZero(750, Easing.OutQuint); + private void fadeContentIn(Drawable d) => d.FadeInFromZero(150); protected override void Deselected() { diff --git a/osu.Game/Screens/Select/Carousel/SetPanelBackground.cs b/osu.Game/Screens/Select/Carousel/SetPanelBackground.cs index 6f13a34bfc..b8729b7174 100644 --- a/osu.Game/Screens/Select/Carousel/SetPanelBackground.cs +++ b/osu.Game/Screens/Select/Carousel/SetPanelBackground.cs @@ -1,12 +1,14 @@ // 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.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; using osu.Game.Beatmaps; -using osu.Game.Beatmaps.Drawables; using osuTK; using osuTK.Graphics; @@ -21,7 +23,7 @@ namespace osu.Game.Screens.Select.Carousel Children = new Drawable[] { - new BeatmapBackgroundSprite(working) + new PanelBeatmapBackground(working) { RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, @@ -68,5 +70,23 @@ namespace osu.Game.Screens.Select.Carousel }, }; } + + public partial class PanelBeatmapBackground : Sprite + { + private readonly IWorkingBeatmap working; + + public PanelBeatmapBackground(IWorkingBeatmap working) + { + ArgumentNullException.ThrowIfNull(working); + + this.working = working; + } + + [BackgroundDependencyLoader] + private void load() + { + Texture = working.GetPanelBackground(); + } + } } } diff --git a/osu.Game/Screens/Select/Carousel/TopLocalRank.cs b/osu.Game/Screens/Select/Carousel/TopLocalRank.cs index a57a8b0f27..7c632b63db 100644 --- a/osu.Game/Screens/Select/Carousel/TopLocalRank.cs +++ b/osu.Game/Screens/Select/Carousel/TopLocalRank.cs @@ -29,9 +29,6 @@ namespace osu.Game.Screens.Select.Carousel [Resolved] private RealmAccess realm { get; set; } = null!; - [Resolved] - private ScoreManager scoreManager { get; set; } = null!; - [Resolved] private IAPIProvider api { get; set; } = null!; @@ -78,7 +75,7 @@ namespace osu.Game.Screens.Select.Carousel if (changes?.HasCollectionChanges() == false) return; - ScoreInfo? topScore = scoreManager.OrderByTotalScore(sender.Detach()).FirstOrDefault(); + ScoreInfo? topScore = sender.Detach().OrderByTotalScore().FirstOrDefault(); updateable.Rank = topScore?.Rank; updateable.Alpha = topScore != null ? 1 : 0; diff --git a/osu.Game/Screens/Select/Filter/Operator.cs b/osu.Game/Screens/Select/Filter/Operator.cs index a6a53f0c3e..706daf631f 100644 --- a/osu.Game/Screens/Select/Filter/Operator.cs +++ b/osu.Game/Screens/Select/Filter/Operator.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - namespace osu.Game.Screens.Select.Filter { /// diff --git a/osu.Game/Screens/Select/FooterButtonRandom.cs b/osu.Game/Screens/Select/FooterButtonRandom.cs index f413126e87..2d5d049133 100644 --- a/osu.Game/Screens/Select/FooterButtonRandom.cs +++ b/osu.Game/Screens/Select/FooterButtonRandom.cs @@ -93,16 +93,22 @@ namespace osu.Game.Screens.Select protected override bool OnKeyDown(KeyDownEvent e) { - updateText(e.ShiftPressed); + updateText(e); return base.OnKeyDown(e); } protected override void OnKeyUp(KeyUpEvent e) { - updateText(e.ShiftPressed); + updateText(e); base.OnKeyUp(e); } + protected override bool OnMouseDown(MouseDownEvent e) + { + updateText(e); + return base.OnMouseDown(e); + } + protected override bool OnClick(ClickEvent e) { try @@ -119,14 +125,15 @@ namespace osu.Game.Screens.Select protected override void OnMouseUp(MouseUpEvent e) { + base.OnMouseUp(e); + if (e.Button == MouseButton.Right && IsHovered) { rewindSearch = true; TriggerClick(); - return; } - base.OnMouseUp(e); + updateText(e); } public override bool OnPressed(KeyBindingPressEvent e) @@ -151,10 +158,12 @@ namespace osu.Game.Screens.Select } } - private void updateText(bool rewind = false) + private void updateText(UIEvent e) { - randomSpriteText.Alpha = rewind ? 0 : 1; - rewindSpriteText.Alpha = rewind ? 1 : 0; + bool aboutToRewind = e.ShiftPressed || e.CurrentState.Mouse.IsPressed(MouseButton.Right); + + randomSpriteText.Alpha = aboutToRewind ? 0 : 1; + rewindSpriteText.Alpha = aboutToRewind ? 1 : 0; } } } diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs index 2b40b9faf8..4c41ed3622 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs @@ -67,9 +67,6 @@ namespace osu.Game.Screens.Select.Leaderboards } } - [Resolved] - private ScoreManager scoreManager { get; set; } = null!; - [Resolved] private IBindable ruleset { get; set; } = null!; @@ -164,7 +161,7 @@ namespace osu.Game.Screens.Select.Leaderboards return; SetScores( - scoreManager.OrderByTotalScore(response.Scores.Select(s => s.ToScoreInfo(rulesets, fetchBeatmapInfo))), + response.Scores.Select(s => s.ToScoreInfo(rulesets, fetchBeatmapInfo)).OrderByTotalScore(), response.UserScore?.CreateScoreInfo(rulesets, fetchBeatmapInfo) ); }); @@ -222,7 +219,7 @@ namespace osu.Game.Screens.Select.Leaderboards scores = scores.Where(s => selectedMods.SetEquals(s.Mods.Select(m => m.Acronym))); } - scores = scoreManager.OrderByTotalScore(scores.Detach()); + scores = scores.Detach().OrderByTotalScore(); SetScores(scores); } diff --git a/osu.Game/Screens/Select/Options/BeatmapOptionsOverlay.cs b/osu.Game/Screens/Select/Options/BeatmapOptionsOverlay.cs index c92dc2e343..5753c268d9 100644 --- a/osu.Game/Screens/Select/Options/BeatmapOptionsOverlay.cs +++ b/osu.Game/Screens/Select/Options/BeatmapOptionsOverlay.cs @@ -86,8 +86,6 @@ namespace osu.Game.Screens.Select.Options protected override void PopIn() { - base.PopIn(); - this.FadeIn(transition_duration, Easing.OutQuint); if (buttonsContainer.Position.X == 1 || Alpha == 0) diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 4d6a5398c5..47e5325baf 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using Humanizer; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -864,7 +863,7 @@ namespace osu.Game.Screens.Select { // Intentionally not localised until we have proper support for this (see https://github.com/ppy/osu-framework/pull/4918 // but also in this case we want support for formatting a number within a string). - FilterControl.InformationalText = $"{"match".ToQuantity(Carousel.CountDisplayed, "#,0")}"; + FilterControl.InformationalText = Carousel.CountDisplayed != 1 ? $"{Carousel.CountDisplayed:#,0} matches" : $"{Carousel.CountDisplayed:#,0} match"; } private bool boundLocalBindables; diff --git a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs index aa264fa719..e674e7512c 100644 --- a/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs +++ b/osu.Game/Storyboards/Drawables/DrawableStoryboard.cs @@ -69,7 +69,7 @@ namespace osu.Game.Storyboards.Drawables Size = new Vector2(640, 480); - bool onlyHasVideoElements = Storyboard.Layers.SelectMany(l => l.Elements).Any(e => !(e is StoryboardVideo)); + bool onlyHasVideoElements = Storyboard.Layers.SelectMany(l => l.Elements).All(e => e is StoryboardVideo); Width = Height * (storyboard.BeatmapInfo.WidescreenStoryboard || onlyHasVideoElements ? 16 / 9f : 4 / 3f); diff --git a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs index 79f629ce49..b57b0daa1b 100644 --- a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs +++ b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs @@ -232,7 +232,7 @@ namespace osu.Game.Tests.Beatmaps protected override IBeatmap GetBeatmap() => beatmap; - protected override Texture GetBackground() => throw new NotImplementedException(); + public override Texture GetBackground() => throw new NotImplementedException(); protected override Track GetBeatmapTrack() => throw new NotImplementedException(); diff --git a/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs b/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs index 7d2aa99dbe..ba6d9ca8b5 100644 --- a/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs +++ b/osu.Game/Tests/Beatmaps/TestWorkingBeatmap.cs @@ -39,7 +39,7 @@ namespace osu.Game.Tests.Beatmaps public override Stream? GetStream(string storagePath) => null; - protected override Texture? GetBackground() => null; + public override Texture? GetBackground() => null; protected override Track? GetBeatmapTrack() => null; } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index ad5e3f6c4d..c27e30d5bb 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -108,7 +108,8 @@ namespace osu.Game.Tests.Visual.Multiplayer // simulate the server's automatic assignment of users to teams on join. // the "best" team is the one with the least users on it. int bestTeam = teamVersus.Teams - .Select(team => (teamID: team.ID, userCount: ServerRoom.Users.Count(u => (u.MatchState as TeamVersusUserState)?.TeamID == team.ID))).MinBy(pair => pair.userCount).teamID; + .Select(team => (teamID: team.ID, userCount: ServerRoom.Users.Count(u => (u.MatchState as TeamVersusUserState)?.TeamID == team.ID))) + .MinBy(pair => pair.userCount).teamID; user.MatchState = new TeamVersusUserState { TeamID = bestTeam }; ((IMultiplayerClient)this).MatchUserStateChanged(clone(user.UserID), clone(user.MatchState)).WaitSafely(); @@ -232,7 +233,7 @@ namespace osu.Game.Tests.Visual.Multiplayer QueueMode = ServerAPIRoom.QueueMode.Value, AutoStartDuration = ServerAPIRoom.AutoStartDuration.Value }, - Playlist = ServerAPIRoom.Playlist.Select(item => new MultiplayerPlaylistItem(item)).ToList(), + Playlist = ServerAPIRoom.Playlist.Select(CreateMultiplayerPlaylistItem).ToList(), Users = { localUser }, Host = localUser }; @@ -637,5 +638,20 @@ namespace osu.Game.Tests.Visual.Multiplayer byte[]? serialized = MessagePackSerializer.Serialize(typeof(T), incoming, SignalRUnionWorkaroundResolver.OPTIONS); return MessagePackSerializer.Deserialize(serialized, SignalRUnionWorkaroundResolver.OPTIONS); } + + public static MultiplayerPlaylistItem CreateMultiplayerPlaylistItem(PlaylistItem item) => new MultiplayerPlaylistItem + { + ID = item.ID, + OwnerID = item.OwnerID, + BeatmapID = item.Beatmap.OnlineID, + BeatmapChecksum = item.Beatmap.MD5Hash, + RulesetID = item.RulesetID, + RequiredMods = item.RequiredMods.ToArray(), + AllowedMods = item.AllowedMods.ToArray(), + Expired = item.Expired, + PlaylistOrder = item.PlaylistOrder ?? 0, + PlayedAt = item.PlayedAt, + StarRating = item.Beatmap.StarRating, + }; } } diff --git a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs index 1db35b3aaa..305a615102 100644 --- a/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs +++ b/osu.Game/Tests/Visual/Spectator/TestSpectatorClient.cs @@ -12,7 +12,9 @@ using osu.Framework.Utils; using osu.Game.Online.API; using osu.Game.Online.Spectator; using osu.Game.Replays.Legacy; +using osu.Game.Rulesets; using osu.Game.Rulesets.Replays; +using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; namespace osu.Game.Tests.Visual.Spectator @@ -44,6 +46,9 @@ namespace osu.Game.Tests.Visual.Spectator [Resolved] private IAPIProvider api { get; set; } = null!; + [Resolved] + private RulesetStore rulesetStore { get; set; } = null!; + public TestSpectatorClient() { OnNewFrames += (i, bundle) => lastReceivedUserFrames[i] = bundle.Frames[^1]; @@ -119,7 +124,7 @@ namespace osu.Game.Tests.Visual.Spectator if (frames.Count == 0) return; - var bundle = new FrameDataBundle(new ScoreInfo { Combo = currentFrameIndex }, frames.ToArray()); + var bundle = new FrameDataBundle(new ScoreInfo { Combo = currentFrameIndex }, new ScoreProcessor(rulesetStore.GetRuleset(0)!.CreateInstance()), frames.ToArray()); ((ISpectatorClient)this).UserSentFrames(userId, bundle); frames.Clear(); diff --git a/osu.Game/Tests/Visual/TestUserLookupCache.cs b/osu.Game/Tests/Visual/TestUserLookupCache.cs index a3028f1a34..261e0fa75c 100644 --- a/osu.Game/Tests/Visual/TestUserLookupCache.cs +++ b/osu.Game/Tests/Visual/TestUserLookupCache.cs @@ -1,8 +1,6 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Threading; using System.Threading.Tasks; using osu.Game.Database; @@ -18,12 +16,12 @@ namespace osu.Game.Tests.Visual /// public const int UNRESOLVED_USER_ID = -1; - protected override Task ComputeValueAsync(int lookup, CancellationToken token = default) + protected override Task ComputeValueAsync(int lookup, CancellationToken token = default) { if (lookup == UNRESOLVED_USER_ID) - return Task.FromResult((APIUser)null); + return Task.FromResult(null); - return Task.FromResult(new APIUser + return Task.FromResult(new APIUser { Id = lookup, Username = $"User {lookup}" diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 4315f44e07..d94c4a2df9 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -1,4 +1,4 @@ - + net6.0 Library @@ -30,14 +30,14 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/osu.iOS.props b/osu.iOS.props index c5477f765e..96396ca4ad 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -16,6 +16,6 @@ iossimulator-x64 - + diff --git a/osu.sln.DotSettings b/osu.sln.DotSettings index d7486273fb..b54794cd6d 100644 --- a/osu.sln.DotSettings +++ b/osu.sln.DotSettings @@ -329,6 +329,7 @@ False AABB API + ARGB BPM EF FPS @@ -336,6 +337,7 @@ GL GLSL HID + HSL HSPA HSV HTML @@ -352,6 +354,7 @@ OS PM RGB + RGBA RNG SDL SHA