diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 0c6b80e97e..fc61573416 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,2 @@ +github: ppy custom: https://osu.ppy.sh/home/support diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29cbdd2d37..2a3b2fd978 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,48 @@ jobs: name: osu-test-results-${{matrix.os.prettyname}}-${{matrix.threadingMode}} path: ${{github.workspace}}/TestResults/TestResults-${{matrix.os.prettyname}}-${{matrix.threadingMode}}.trx + build-only-android: + name: Build only (Android) + runs-on: windows-latest + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Install .NET 5.0.x + uses: actions/setup-dotnet@v1 + with: + dotnet-version: "5.0.x" + + - name: Setup MSBuild + uses: microsoft/setup-msbuild@v1 + + - name: Build + run: msbuild osu.Android.slnf /restore /p:Configuration=Debug + + build-only-ios: + # While this workflow technically *can* run, it fails as iOS builds are blocked by multiple issues. + # See https://github.com/ppy/osu-framework/issues/4677 for the details. + # The job can be unblocked once those issues are resolved and game deployments can happen again. + if: false + name: Build only (iOS) + runs-on: macos-latest + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Install .NET 5.0.x + uses: actions/setup-dotnet@v1 + with: + dotnet-version: "5.0.x" + + # Contrary to seemingly any other msbuild, msbuild running on macOS/Mono + # cannot accept .sln(f) files as arguments. + # Build just the main game for now. + - name: Build + run: msbuild osu.iOS/osu.iOS.csproj /restore /p:Configuration=Debug + inspect-code: name: Code Quality runs-on: ubuntu-latest @@ -79,9 +121,14 @@ jobs: run: | # TODO: Add ignore filters and GitHub Workflow Command Reporting in CFS. That way we don't have to do this workaround. # FIXME: Suppress warnings from templates project - dotnet codefilesanity | while read -r line; do - echo "::warning::$line" - done + exit_code=0 + while read -r line; do + if [[ ! -z "$line" ]]; then + echo "::error::$line" + exit_code=1 + fi + done <<< $(dotnet codefilesanity) + exit $exit_code # Temporarily disabled due to test failures, but it won't work anyway until the tool is upgraded. # - name: .NET Format (Dry Run) diff --git a/.github/workflows/report-nunit.yml b/.github/workflows/report-nunit.yml index e0ccd50989..358cbda17a 100644 --- a/.github/workflows/report-nunit.yml +++ b/.github/workflows/report-nunit.yml @@ -30,3 +30,5 @@ jobs: name: Test Results (${{matrix.os.prettyname}}, ${{matrix.threadingMode}}) path: "*.trx" reporter: dotnet-trx + list-suites: 'failed' + list-tests: 'failed' diff --git a/osu.Android.props b/osu.Android.props index 5a0e7479fa..552675d706 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,8 +51,8 @@ - - + + diff --git a/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneCatchDistanceSnapGrid.cs b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneCatchDistanceSnapGrid.cs new file mode 100644 index 0000000000..2be0b7e9b2 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Editor/TestSceneCatchDistanceSnapGrid.cs @@ -0,0 +1,91 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; +using osu.Framework.Timing; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Catch.Edit; +using osu.Game.Rulesets.Catch.Edit.Blueprints.Components; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Tests.Visual; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Tests.Editor +{ + public class TestSceneCatchDistanceSnapGrid : OsuManualInputManagerTestScene + { + private readonly ManualClock manualClock = new ManualClock(); + + [Cached(typeof(Playfield))] + private readonly CatchPlayfield playfield; + + private ScrollingHitObjectContainer hitObjectContainer => playfield.HitObjectContainer; + + private readonly CatchDistanceSnapGrid distanceGrid; + + private readonly FruitOutline fruitOutline; + + private readonly Fruit fruit = new Fruit(); + + public TestSceneCatchDistanceSnapGrid() + { + Child = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Y, + Width = 500, + + Children = new Drawable[] + { + new ScrollingTestContainer(ScrollingDirection.Down) + { + RelativeSizeAxes = Axes.Both, + Child = playfield = new CatchPlayfield(new BeatmapDifficulty()) + { + RelativeSizeAxes = Axes.Both, + Clock = new FramedClock(manualClock) + } + }, + distanceGrid = new CatchDistanceSnapGrid(new double[] { 0, -1, 1 }), + fruitOutline = new FruitOutline() + }, + }; + } + + protected override void Update() + { + base.Update(); + + distanceGrid.StartTime = 100; + distanceGrid.StartX = 250; + + Vector2 screenSpacePosition = InputManager.CurrentState.Mouse.Position; + + var result = distanceGrid.GetSnappedPosition(screenSpacePosition); + + if (result != null) + { + fruit.OriginalX = hitObjectContainer.ToLocalSpace(result.ScreenSpacePosition).X; + + if (result.Time != null) + fruit.StartTime = result.Time.Value; + } + + fruitOutline.Position = CatchHitObjectUtils.GetStartPosition(hitObjectContainer, fruit); + fruitOutline.UpdateFrom(fruit); + } + + protected override bool OnScroll(ScrollEvent e) + { + manualClock.CurrentTime -= e.ScrollDelta.Y * 50; + return true; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/CatchDistanceSnapGrid.cs b/osu.Game.Rulesets.Catch/Edit/CatchDistanceSnapGrid.cs new file mode 100644 index 0000000000..137ac1fc59 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/CatchDistanceSnapGrid.cs @@ -0,0 +1,141 @@ +// 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 JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Lines; +using osu.Framework.Graphics.Primitives; +using osu.Game.Rulesets.Catch.UI; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.UI; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK; + +namespace osu.Game.Rulesets.Catch.Edit +{ + /// + /// The guide lines used in the osu!catch editor to compose patterns that can be caught with constant speed. + /// Currently, only forward placement (an object is snapped based on the previous object, not the opposite) is supported. + /// + public class CatchDistanceSnapGrid : CompositeDrawable + { + public double StartTime { get; set; } + + public float StartX { get; set; } + + private const double max_vertical_line_length_in_time = CatchPlayfield.WIDTH / Catcher.BASE_SPEED * 2; + + private readonly double[] velocities; + + private readonly List verticalPaths = new List(); + + private readonly List verticalLineVertices = new List(); + + [Resolved] + private Playfield playfield { get; set; } + + private ScrollingHitObjectContainer hitObjectContainer => (ScrollingHitObjectContainer)playfield.HitObjectContainer; + + public CatchDistanceSnapGrid(double[] velocities) + { + RelativeSizeAxes = Axes.Both; + Anchor = Anchor.BottomLeft; + + this.velocities = velocities; + + for (int i = 0; i < velocities.Length; i++) + { + verticalPaths.Add(new SmoothPath + { + PathRadius = 2, + Alpha = 0.5f, + }); + + verticalLineVertices.Add(new[] { Vector2.Zero, Vector2.Zero }); + } + + AddRangeInternal(verticalPaths); + } + + protected override void Update() + { + base.Update(); + + double currentTime = hitObjectContainer.Time.Current; + + for (int i = 0; i < velocities.Length; i++) + { + double velocity = velocities[i]; + + // The line ends at the top of the playfield. + double endTime = hitObjectContainer.TimeAtPosition(-hitObjectContainer.DrawHeight, currentTime); + + // Non-vertical lines are cut at the sides of the playfield. + // Vertical lines are cut at some reasonable length. + if (velocity > 0) + endTime = Math.Min(endTime, StartTime + (CatchPlayfield.WIDTH - StartX) / velocity); + else if (velocity < 0) + endTime = Math.Min(endTime, StartTime + StartX / -velocity); + else + endTime = Math.Min(endTime, StartTime + max_vertical_line_length_in_time); + + Vector2[] lineVertices = verticalLineVertices[i]; + lineVertices[0] = calculatePosition(velocity, StartTime); + lineVertices[1] = calculatePosition(velocity, endTime); + + var verticalPath = verticalPaths[i]; + verticalPath.Vertices = verticalLineVertices[i]; + verticalPath.OriginPosition = verticalPath.PositionInBoundingBox(Vector2.Zero); + } + + Vector2 calculatePosition(double velocity, double time) + { + // Don't draw inverted lines. + time = Math.Max(time, StartTime); + + float x = StartX + (float)((time - StartTime) * velocity); + float y = hitObjectContainer.PositionAtTime(time, currentTime); + return new Vector2(x, y); + } + } + + [CanBeNull] + public SnapResult GetSnappedPosition(Vector2 screenSpacePosition) + { + double time = hitObjectContainer.TimeAtScreenSpacePosition(screenSpacePosition); + + // If the cursor is below the distance snap grid, snap to the origin. + // Not returning `null` to retain the continuous snapping behavior when the cursor is slightly below the origin. + // This behavior is not currently visible in the editor because editor chooses the snap start time based on the mouse position. + if (time <= StartTime) + { + float y = hitObjectContainer.PositionAtTime(StartTime); + Vector2 originPosition = hitObjectContainer.ToScreenSpace(new Vector2(StartX, y)); + return new SnapResult(originPosition, StartTime); + } + + return enumerateSnappingCandidates(time) + .OrderBy(pos => Vector2.DistanceSquared(screenSpacePosition, pos.ScreenSpacePosition)) + .FirstOrDefault(); + } + + private IEnumerable enumerateSnappingCandidates(double time) + { + float y = hitObjectContainer.PositionAtTime(time); + + foreach (double velocity in velocities) + { + float x = (float)(StartX + (time - StartTime) * velocity); + Vector2 screenSpacePosition = hitObjectContainer.ToScreenSpace(new Vector2(x, y + hitObjectContainer.DrawHeight)); + yield return new SnapResult(screenSpacePosition, time); + } + } + + protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false; + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs index 050c2f625d..67055fb5e0 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs @@ -2,14 +2,23 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input; using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; +using osu.Game.Screens.Edit.Components.TernaryButtons; using osu.Game.Screens.Edit.Compose.Components; using osuTK; @@ -17,6 +26,14 @@ namespace osu.Game.Rulesets.Catch.Edit { public class CatchHitObjectComposer : HitObjectComposer { + private const float distance_snap_radius = 50; + + private CatchDistanceSnapGrid distanceSnapGrid; + + private readonly Bindable distanceSnapToggle = new Bindable(); + + private InputManager inputManager; + public CatchHitObjectComposer(CatchRuleset ruleset) : base(ruleset) { @@ -30,6 +47,27 @@ namespace osu.Game.Rulesets.Catch.Edit RelativeSizeAxes = Axes.Both, PlayfieldBorderStyle = { Value = PlayfieldBorderStyle.Corners } }); + + LayerBelowRuleset.Add(distanceSnapGrid = new CatchDistanceSnapGrid(new[] + { + 0.0, + Catcher.BASE_SPEED, -Catcher.BASE_SPEED, + Catcher.BASE_SPEED / 2, -Catcher.BASE_SPEED / 2, + })); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + inputManager = GetContainingInputManager(); + } + + protected override void Update() + { + base.Update(); + + updateDistanceSnapGrid(); } protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) => @@ -42,14 +80,95 @@ namespace osu.Game.Rulesets.Catch.Edit new BananaShowerCompositionTool() }; + protected override IEnumerable CreateTernaryButtons() => base.CreateTernaryButtons().Concat(new[] + { + new TernaryButton(distanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Ruler }) + }); + public override SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) { var result = base.SnapScreenSpacePositionToValidTime(screenSpacePosition); - // TODO: implement position snap result.ScreenSpacePosition.X = screenSpacePosition.X; + + if (distanceSnapGrid.IsPresent && distanceSnapGrid.GetSnappedPosition(result.ScreenSpacePosition) is SnapResult snapResult && + Vector2.Distance(snapResult.ScreenSpacePosition, result.ScreenSpacePosition) < distance_snap_radius) + { + result = snapResult; + } + return result; } protected override ComposeBlueprintContainer CreateBlueprintContainer() => new CatchBlueprintContainer(this); + + [CanBeNull] + private PalpableCatchHitObject getLastSnappableHitObject(double time) + { + var hitObject = EditorBeatmap.HitObjects.OfType().LastOrDefault(h => h.GetEndTime() < time && !(h is BananaShower)); + + switch (hitObject) + { + case Fruit fruit: + return fruit; + + case JuiceStream juiceStream: + return juiceStream.NestedHitObjects.OfType().LastOrDefault(h => !(h is TinyDroplet)); + + default: + return null; + } + } + + [CanBeNull] + private PalpableCatchHitObject getDistanceSnapGridSourceHitObject() + { + switch (BlueprintContainer.CurrentTool) + { + case SelectTool _: + if (EditorBeatmap.SelectedHitObjects.Count == 0) + return null; + + double minTime = EditorBeatmap.SelectedHitObjects.Min(hitObject => hitObject.StartTime); + return getLastSnappableHitObject(minTime); + + case FruitCompositionTool _: + case JuiceStreamCompositionTool _: + if (!CursorInPlacementArea) + return null; + + if (EditorBeatmap.PlacementObject.Value is JuiceStream) + { + // Juice stream path is not subject to snapping. + return null; + } + + double timeAtCursor = ((CatchPlayfield)Playfield).TimeAtScreenSpacePosition(inputManager.CurrentState.Mouse.Position); + return getLastSnappableHitObject(timeAtCursor); + + default: + return null; + } + } + + private void updateDistanceSnapGrid() + { + if (distanceSnapToggle.Value != TernaryState.True) + { + distanceSnapGrid.Hide(); + return; + } + + var sourceHitObject = getDistanceSnapGridSourceHitObject(); + + if (sourceHitObject == null) + { + distanceSnapGrid.Hide(); + return; + } + + distanceSnapGrid.Show(); + distanceSnapGrid.StartTime = sourceHitObject.GetEndTime(); + distanceSnapGrid.StartX = sourceHitObject.EffectiveX; + } } } diff --git a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs index 0d6925a83d..6d5a960f06 100644 --- a/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs +++ b/osu.Game.Rulesets.Catch/Objects/JuiceStream.cs @@ -42,9 +42,8 @@ namespace osu.Game.Rulesets.Catch.Objects base.ApplyDefaultsToSelf(controlPointInfo, difficulty); TimingControlPoint timingPoint = controlPointInfo.TimingPointAt(StartTime); - DifficultyControlPoint difficultyPoint = controlPointInfo.DifficultyPointAt(StartTime); - double scoringDistance = base_scoring_distance * difficulty.SliderMultiplier * difficultyPoint.SpeedMultiplier; + double scoringDistance = base_scoring_distance * difficulty.SliderMultiplier * DifficultyControlPoint.SliderVelocity; Velocity = scoringDistance / timingPoint.BeatLength; TickDistance = scoringDistance / difficulty.SliderTickRate; diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs index 538a51db5f..5ccb191a9b 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaBeatSnapGrid.cs @@ -13,6 +13,7 @@ using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Edit; using osu.Game.Rulesets.Mania.UI; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; @@ -101,27 +102,27 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor throw new System.NotImplementedException(); } - public override float GetBeatSnapDistanceAt(double referenceTime) + public override float GetBeatSnapDistanceAt(HitObject referenceObject) { throw new System.NotImplementedException(); } - public override float DurationToDistance(double referenceTime, double duration) + public override float DurationToDistance(HitObject referenceObject, double duration) { throw new System.NotImplementedException(); } - public override double DistanceToDuration(double referenceTime, float distance) + public override double DistanceToDuration(HitObject referenceObject, float distance) { throw new System.NotImplementedException(); } - public override double GetSnappedDurationFromDistance(double referenceTime, float distance) + public override double GetSnappedDurationFromDistance(HitObject referenceObject, float distance) { throw new System.NotImplementedException(); } - public override float GetSnappedDistanceFromDistance(double referenceTime, float distance) + public override float GetSnappedDistanceFromDistance(HitObject referenceObject, float distance) { throw new System.NotImplementedException(); } diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs index 471dad87d5..4387bc6b3b 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs @@ -388,7 +388,7 @@ namespace osu.Game.Rulesets.Mania.Tests }, }; - beatmap.ControlPointInfo.Add(0, new DifficultyControlPoint { SpeedMultiplier = 0.1f }); + beatmap.ControlPointInfo.Add(0, new EffectControlPoint { ScrollSpeed = 0.1f }); } AddStep("load player", () => diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneOutOfOrderHits.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneOutOfOrderHits.cs index 18891f8c58..89e13acad6 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneOutOfOrderHits.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneOutOfOrderHits.cs @@ -148,7 +148,7 @@ namespace osu.Game.Rulesets.Mania.Tests }, }); - Beatmap.Value.Beatmap.ControlPointInfo.Add(0, new DifficultyControlPoint { SpeedMultiplier = 0.1f }); + Beatmap.Value.Beatmap.ControlPointInfo.Add(0, new EffectControlPoint { ScrollSpeed = 0.1f }); var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); diff --git a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs index 380efff69f..1ed045f7e0 100644 --- a/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs +++ b/osu.Game.Rulesets.Mania/Beatmaps/Patterns/Legacy/DistanceObjectPatternGenerator.cs @@ -47,7 +47,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy Debug.Assert(distanceData != null); TimingControlPoint timingPoint = beatmap.ControlPointInfo.TimingPointAt(hitObject.StartTime); - DifficultyControlPoint difficultyPoint = beatmap.ControlPointInfo.DifficultyPointAt(hitObject.StartTime); + DifficultyControlPoint difficultyPoint = hitObject.DifficultyControlPoint; double beatLength; #pragma warning disable 618 @@ -55,7 +55,7 @@ namespace osu.Game.Rulesets.Mania.Beatmaps.Patterns.Legacy #pragma warning restore 618 beatLength = timingPoint.BeatLength * legacyDifficultyPoint.BpmMultiplier; else - beatLength = timingPoint.BeatLength / difficultyPoint.SpeedMultiplier; + beatLength = timingPoint.BeatLength / difficultyPoint.SliderVelocity; SpanCount = repeatsData?.SpanCount() ?? 1; StartTime = (int)Math.Round(hitObject.StartTime); diff --git a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs index ac8168dfc9..8e09a01469 100644 --- a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs +++ b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs @@ -28,7 +28,12 @@ namespace osu.Game.Rulesets.Mania.Configuration public override TrackedSettings CreateTrackedSettings() => new TrackedSettings { new TrackedSetting(ManiaRulesetSetting.ScrollTime, - v => new SettingDescription(v, "Scroll Speed", $"{(int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / v)} ({v}ms)")) + scrollTime => new SettingDescription( + rawValue: scrollTime, + name: "Scroll Speed", + value: $"{(int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / scrollTime)} ({scrollTime}ms)" + ) + ) }; } diff --git a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/mania-samples.osu b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/mania-samples.osu index 7c75b45e5f..ca9e5b0b85 100644 --- a/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/mania-samples.osu +++ b/osu.Game.Rulesets.Mania/Resources/Testing/Beatmaps/mania-samples.osu @@ -13,6 +13,7 @@ SliderTickRate:1 [TimingPoints] 0,500,4,1,0,100,1,0 +10000,-150,4,1,0,100,1,0 [HitObjects] 51,192,500,128,0,1500:1:0:0:0: diff --git a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs index 3b7da8d9ba..28e970f397 100644 --- a/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/UI/DrawableManiaRuleset.cs @@ -94,7 +94,7 @@ namespace osu.Game.Rulesets.Mania.UI // For non-mania beatmap, speed changes should only happen through timing points if (!isForCurrentRuleset) - p.DifficultyPoint = new DifficultyControlPoint(); + p.EffectPoint = new EffectControlPoint(); } BarLines.ForEach(Playfield.Add); diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs index 851be2b2f2..ef43c3a696 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs @@ -11,6 +11,7 @@ using osu.Framework.Input.Events; using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Edit; using osu.Game.Rulesets.Osu.Objects; @@ -179,15 +180,15 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor public SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) => new SnapResult(screenSpacePosition, 0); - public float GetBeatSnapDistanceAt(double referenceTime) => (float)beat_length; + public float GetBeatSnapDistanceAt(HitObject referenceObject) => (float)beat_length; - public float DurationToDistance(double referenceTime, double duration) => (float)duration; + public float DurationToDistance(HitObject referenceObject, double duration) => (float)duration; - public double DistanceToDuration(double referenceTime, float distance) => distance; + public double DistanceToDuration(HitObject referenceObject, float distance) => distance; - public double GetSnappedDurationFromDistance(double referenceTime, float distance) => 0; + public double GetSnappedDurationFromDistance(HitObject referenceObject, float distance) => 0; - public float GetSnappedDistanceFromDistance(double referenceTime, float distance) => 0; + public float GetSnappedDistanceFromDistance(HitObject referenceObject, float distance) => 0; } } } diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs index 0ba775e5c7..37f1a846ad 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs @@ -45,8 +45,8 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods { new Spinner { - Duration = 2000, - Position = OsuPlayfield.BASE_SIZE / 2 + Duration = 6000, + Position = OsuPlayfield.BASE_SIZE / 2, } } }, diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs index ed9da36b05..71b575abe2 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModHidden.cs @@ -117,6 +117,42 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods PassCondition = checkSomeHit }); + [Test] + public void TestApproachCirclesOnly() => CreateModTest(new ModTestData + { + Mod = new OsuModHidden { OnlyFadeApproachCircles = { Value = true } }, + Autoplay = true, + Beatmap = new Beatmap + { + HitObjects = new List + { + new HitCircle + { + StartTime = 1000, + Position = new Vector2(206, 142) + }, + new HitCircle + { + StartTime = 2000, + Position = new Vector2(306, 142) + }, + new Slider + { + StartTime = 3000, + Position = new Vector2(156, 242), + Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(200, 0), }) + }, + new Spinner + { + Position = new Vector2(256, 192), + StartTime = 7000, + EndTime = 9000 + } + } + }, + PassCondition = checkSomeHit + }); + private bool checkSomeHit() => Player.ScoreProcessor.JudgedHits >= 4; private bool objectWithIncreasedVisibilityHasIndex(int index) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs index f09aad8b49..1f01ba601b 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneGameplayCursor.cs @@ -60,7 +60,7 @@ namespace osu.Game.Rulesets.Osu.Tests { config.SetValue(OsuSetting.AutoCursorSize, true); gameplayState.Beatmap.Difficulty.CircleSize = val; - Scheduler.AddOnce(() => loadContent(false)); + Scheduler.AddOnce(loadContent); }); AddStep("test cursor container", () => loadContent(false)); @@ -78,7 +78,7 @@ namespace osu.Game.Rulesets.Osu.Tests AddStep($"adjust cs to {circleSize}", () => gameplayState.Beatmap.Difficulty.CircleSize = circleSize); AddStep("turn on autosizing", () => config.SetValue(OsuSetting.AutoCursorSize, true)); - AddStep("load content", () => loadContent()); + AddStep("load content", loadContent); AddUntilStep("cursor size correct", () => lastContainer.ActiveCursor.Scale.X == OsuCursorContainer.GetScaleForCircleSize(circleSize) * userScale); @@ -98,7 +98,9 @@ namespace osu.Game.Rulesets.Osu.Tests AddStep("load content", () => loadContent(false, () => new SkinProvidingContainer(new TopLeftCursorSkin()))); } - private void loadContent(bool automated = true, Func skinProvider = null) + private void loadContent() => loadContent(false); + + private void loadContent(bool automated, Func skinProvider = null) { SetContents(_ => { diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs index ececfb0586..d31e7a31f5 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneObjectOrderedHitPolicy.cs @@ -407,8 +407,6 @@ namespace osu.Game.Rulesets.Osu.Tests }, }); - Beatmap.Value.Beatmap.ControlPointInfo.Add(0, new DifficultyControlPoint { SpeedMultiplier = 0.1f }); - SelectedMods.Value = new[] { new OsuModClassic() }; var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); @@ -439,6 +437,8 @@ namespace osu.Game.Rulesets.Osu.Tests { public TestSlider() { + DifficultyControlPoint = new DifficultyControlPoint { SliderVelocity = 0.1f }; + DefaultsApplied += _ => { HeadCircle.HitWindows = new TestHitWindows(); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs index 81902c25af..03b4254eed 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSlider.cs @@ -13,6 +13,7 @@ using osuTK.Graphics; using osu.Game.Rulesets.Mods; using System.Linq; using NUnit.Framework; +using osu.Game.Beatmaps.Legacy; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Judgements; @@ -328,10 +329,14 @@ namespace osu.Game.Rulesets.Osu.Tests private Drawable createDrawable(Slider slider, float circleSize, double speedMultiplier) { - var cpi = new ControlPointInfo(); - cpi.Add(0, new DifficultyControlPoint { SpeedMultiplier = speedMultiplier }); + var cpi = new LegacyControlPointInfo(); + cpi.Add(0, new DifficultyControlPoint { SliderVelocity = speedMultiplier }); - slider.ApplyDefaults(cpi, new BeatmapDifficulty { CircleSize = circleSize, SliderTickRate = 3 }); + slider.ApplyDefaults(cpi, new BeatmapDifficulty + { + CircleSize = circleSize, + SliderTickRate = 3 + }); var drawable = CreateDrawableSlider(slider); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs index 590d159300..f3392724ec 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs @@ -348,6 +348,7 @@ namespace osu.Game.Rulesets.Osu.Tests { StartTime = time_slider_start, Position = new Vector2(0, 0), + DifficultyControlPoint = new DifficultyControlPoint { SliderVelocity = 0.1f }, Path = new SliderPath(PathType.PerfectCurve, new[] { Vector2.Zero, @@ -362,8 +363,6 @@ namespace osu.Game.Rulesets.Osu.Tests }, }); - Beatmap.Value.Beatmap.ControlPointInfo.Add(0, new DifficultyControlPoint { SpeedMultiplier = 0.1f }); - var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); p.OnLoadComplete += _ => diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs index 9da583a073..52ab39cfbd 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerRotation.cs @@ -30,6 +30,9 @@ namespace osu.Game.Rulesets.Osu.Tests { public class TestSceneSpinnerRotation : TestSceneOsuPlayer { + private const double spinner_start_time = 100; + private const double spinner_duration = 6000; + [Resolved] private AudioManager audioManager { get; set; } @@ -77,7 +80,7 @@ namespace osu.Game.Rulesets.Osu.Tests double finalTrackerRotation = 0, trackerRotationTolerance = 0; double finalSpinnerSymbolRotation = 0, spinnerSymbolRotationTolerance = 0; - addSeekStep(5000); + addSeekStep(spinner_start_time + 5000); AddStep("retrieve disc rotation", () => { finalTrackerRotation = drawableSpinner.RotationTracker.Rotation; @@ -90,7 +93,7 @@ namespace osu.Game.Rulesets.Osu.Tests }); AddStep("retrieve cumulative disc rotation", () => finalCumulativeTrackerRotation = drawableSpinner.Result.RateAdjustedRotation); - addSeekStep(2500); + addSeekStep(spinner_start_time + 2500); AddAssert("disc rotation rewound", // we want to make sure that the rotation at time 2500 is in the same direction as at time 5000, but about half-way in. // due to the exponential damping applied we're allowing a larger margin of error of about 10% @@ -102,7 +105,7 @@ namespace osu.Game.Rulesets.Osu.Tests // cumulative rotation is not damped, so we're treating it as the "ground truth" and allowing a comparatively smaller margin of error. () => Precision.AlmostEquals(drawableSpinner.Result.RateAdjustedRotation, finalCumulativeTrackerRotation / 2, 100)); - addSeekStep(5000); + addSeekStep(spinner_start_time + 5000); AddAssert("is disc rotation almost same", () => Precision.AlmostEquals(drawableSpinner.RotationTracker.Rotation, finalTrackerRotation, trackerRotationTolerance)); AddAssert("is symbol rotation almost same", @@ -140,7 +143,7 @@ namespace osu.Game.Rulesets.Osu.Tests [Test] public void TestSpinnerNormalBonusRewinding() { - addSeekStep(1000); + addSeekStep(spinner_start_time + 1000); AddAssert("player score matching expected bonus score", () => { @@ -201,24 +204,9 @@ namespace osu.Game.Rulesets.Osu.Tests AddAssert("spm almost same", () => Precision.AlmostEquals(expectedSpm, drawableSpinner.SpinsPerMinute.Value, 2.0)); } - private Replay applyRateAdjustment(Replay scoreReplay, double rate) => new Replay - { - Frames = scoreReplay - .Frames - .Cast() - .Select(replayFrame => - { - var adjustedTime = replayFrame.Time * rate; - return new OsuReplayFrame(adjustedTime, replayFrame.Position, replayFrame.Actions.ToArray()); - }) - .Cast() - .ToList() - }; - private void addSeekStep(double time) { AddStep($"seek to {time}", () => Player.GameplayClockContainer.Seek(time)); - AddUntilStep("wait for seek to finish", () => Precision.AlmostEquals(time, Player.DrawableRuleset.FrameStableClock.CurrentTime, 100)); } @@ -241,7 +229,8 @@ namespace osu.Game.Rulesets.Osu.Tests new Spinner { Position = new Vector2(256, 192), - EndTime = 6000, + StartTime = spinner_start_time, + Duration = spinner_duration }, } }; diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs index 1b85e0efde..2d43e1b95e 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs @@ -369,8 +369,6 @@ namespace osu.Game.Rulesets.Osu.Tests }, }); - Beatmap.Value.Beatmap.ControlPointInfo.Add(0, new DifficultyControlPoint { SpeedMultiplier = 0.1f }); - var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); p.OnLoadComplete += _ => @@ -399,6 +397,8 @@ namespace osu.Game.Rulesets.Osu.Tests { public TestSlider() { + DifficultyControlPoint = new DifficultyControlPoint { SliderVelocity = 0.1f }; + DefaultsApplied += _ => { HeadCircle.HitWindows = new TestHitWindows(); diff --git a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs index a2fc4848af..d82186fb52 100644 --- a/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs +++ b/osu.Game.Rulesets.Osu/Beatmaps/OsuBeatmapConverter.cs @@ -11,6 +11,7 @@ using System.Linq; using System.Threading; using osu.Game.Rulesets.Osu.UI; using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Game.Beatmaps.Legacy; namespace osu.Game.Rulesets.Osu.Beatmaps { @@ -44,7 +45,7 @@ namespace osu.Game.Rulesets.Osu.Beatmaps LegacyLastTickOffset = (original as IHasLegacyLastTickOffset)?.LegacyLastTickOffset, // prior to v8, speed multipliers don't adjust for how many ticks are generated over the same distance. // this results in more (or less) ticks being generated in ().Sum(s => s.NestedHitObjects.Count - 1); int hitCirclesCount = beatmap.HitObjects.Count(h => h is HitCircle); + int sliderCount = beatmap.HitObjects.Count(h => h is Slider); int spinnerCount = beatmap.HitObjects.Count(h => h is Spinner); return new OsuDifficultyAttributes @@ -78,6 +79,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty DrainRate = drainRate, MaxCombo = maxCombo, HitCircleCount = hitCirclesCount, + SliderCount = sliderCount, SpinnerCount = spinnerCount, Skills = skills }; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 4e4dbc02a1..4bca87204a 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -25,6 +25,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty private int countMeh; private int countMiss; + private int effectiveMissCount; + public OsuPerformanceCalculator(Ruleset ruleset, DifficultyAttributes attributes, ScoreInfo score) : base(ruleset, attributes, score) { @@ -39,19 +41,20 @@ namespace osu.Game.Rulesets.Osu.Difficulty countOk = Score.Statistics.GetValueOrDefault(HitResult.Ok); countMeh = Score.Statistics.GetValueOrDefault(HitResult.Meh); countMiss = Score.Statistics.GetValueOrDefault(HitResult.Miss); + effectiveMissCount = calculateEffectiveMissCount(); double multiplier = 1.12; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. // Custom multipliers for NoFail and SpunOut. if (mods.Any(m => m is OsuModNoFail)) - multiplier *= Math.Max(0.90, 1.0 - 0.02 * countMiss); + multiplier *= Math.Max(0.90, 1.0 - 0.02 * effectiveMissCount); if (mods.Any(m => m is OsuModSpunOut)) multiplier *= 1.0 - Math.Pow((double)Attributes.SpinnerCount / totalHits, 0.85); if (mods.Any(h => h is OsuModRelax)) { - countMiss += countOk + countMeh; + effectiveMissCount += countOk + countMeh; multiplier *= 0.6; } @@ -97,8 +100,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty aimValue *= lengthBonus; // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses. - if (countMiss > 0) - aimValue *= 0.97 * Math.Pow(1 - Math.Pow((double)countMiss / totalHits, 0.775), countMiss); + if (effectiveMissCount > 0) + aimValue *= 0.97 * Math.Pow(1 - Math.Pow((double)effectiveMissCount / totalHits, 0.775), effectiveMissCount); // Combo scaling. if (Attributes.MaxCombo > 0) @@ -115,7 +118,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty double approachRateBonus = 1.0 + (0.03 + 0.37 * approachRateTotalHitsFactor) * approachRateFactor; if (mods.Any(m => m is OsuModBlinds)) - aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * countMiss)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * Attributes.DrainRate * Attributes.DrainRate); + aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * Attributes.DrainRate * Attributes.DrainRate); else if (mods.Any(h => h is OsuModHidden)) { // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. @@ -142,8 +145,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty speedValue *= lengthBonus; // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses. - if (countMiss > 0) - speedValue *= 0.97 * Math.Pow(1 - Math.Pow((double)countMiss / totalHits, 0.775), Math.Pow(countMiss, .875)); + if (effectiveMissCount > 0) + speedValue *= 0.97 * Math.Pow(1 - Math.Pow((double)effectiveMissCount / totalHits, 0.775), Math.Pow(effectiveMissCount, .875)); // Combo scaling. if (Attributes.MaxCombo > 0) @@ -231,8 +234,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty flashlightValue *= 1.3; // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses. - if (countMiss > 0) - flashlightValue *= 0.97 * Math.Pow(1 - Math.Pow((double)countMiss / totalHits, 0.775), Math.Pow(countMiss, .875)); + if (effectiveMissCount > 0) + flashlightValue *= 0.97 * Math.Pow(1 - Math.Pow((double)effectiveMissCount / totalHits, 0.775), Math.Pow(effectiveMissCount, .875)); // Combo scaling. if (Attributes.MaxCombo > 0) @@ -250,6 +253,24 @@ namespace osu.Game.Rulesets.Osu.Difficulty return flashlightValue; } + private int calculateEffectiveMissCount() + { + // guess the number of misses + slider breaks from combo + double comboBasedMissCount = 0.0; + + if (Attributes.SliderCount > 0) + { + double fullComboThreshold = Attributes.MaxCombo - 0.1 * Attributes.SliderCount; + if (scoreMaxCombo < fullComboThreshold) + comboBasedMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); + } + + // we're clamping misscount because since its derived from combo it can be higher than total hits and that breaks some calculations + comboBasedMissCount = Math.Min(comboBasedMissCount, totalHits); + + return Math.Max(countMiss, (int)Math.Floor(comboBasedMissCount)); + } + private int totalHits => countGreat + countOk + countMeh + countMiss; private int totalSuccessfulHits => countGreat + countOk + countMeh; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index 8e8f9bc06e..5e5993aefe 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -54,6 +54,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing private void setDistances() { + // We don't need to calculate either angle or distance when one of the last->curr objects is a spinner + if (BaseObject is Spinner || lastObject is Spinner) + return; + // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps. float scalingFactor = normalized_radius / (float)BaseObject.Radius; @@ -71,11 +75,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing Vector2 lastCursorPosition = getEndCursorPosition(lastObject); - // Don't need to jump to reach spinners - if (!(BaseObject is Spinner)) - JumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length; + JumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length; - if (lastLastObject != null) + if (lastLastObject != null && !(lastLastObject is Spinner)) { Vector2 lastLastCursorPosition = getEndCursorPosition(lastLastObject); diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs index b9e4ed6fcb..07b6a1bdc2 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs @@ -8,12 +8,14 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Input; using osu.Framework.Input.Events; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles.Components; using osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components; +using osu.Game.Screens.Edit; using osuTK; using osuTK.Input; @@ -67,6 +69,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders inputManager = GetContainingInputManager(); } + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } + public override void UpdateTimeAndPosition(SnapResult result) { base.UpdateTimeAndPosition(result); @@ -75,6 +80,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { case SliderPlacementState.Initial: BeginPlacement(); + + var nearestDifficultyPoint = editorBeatmap.HitObjects.LastOrDefault(h => h.GetEndTime() < HitObject.StartTime)?.DifficultyControlPoint?.DeepClone() as DifficultyControlPoint; + + HitObject.DifficultyControlPoint = nearestDifficultyPoint ?? new DifficultyControlPoint(); HitObject.Position = ToLocalSpace(result.ScreenSpacePosition); break; @@ -212,7 +221,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders private void updateSlider() { - HitObject.Path.ExpectedDistance.Value = composer?.GetSnappedDistanceFromDistance(HitObject.StartTime, (float)HitObject.Path.CalculatedDistance) ?? (float)HitObject.Path.CalculatedDistance; + HitObject.Path.ExpectedDistance.Value = composer?.GetSnappedDistanceFromDistance(HitObject, (float)HitObject.Path.CalculatedDistance) ?? (float)HitObject.Path.CalculatedDistance; bodyPiece.UpdateFrom(HitObject); headCirclePiece.UpdateFrom(HitObject.HeadCircle); diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs index 89724876fa..a7fadfb67f 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs @@ -230,7 +230,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders private void updatePath() { - HitObject.Path.ExpectedDistance.Value = composer?.GetSnappedDistanceFromDistance(HitObject.StartTime, (float)HitObject.Path.CalculatedDistance) ?? (float)HitObject.Path.CalculatedDistance; + HitObject.Path.ExpectedDistance.Value = composer?.GetSnappedDistanceFromDistance(HitObject, (float)HitObject.Path.CalculatedDistance) ?? (float)HitObject.Path.CalculatedDistance; editorBeatmap?.Update(HitObject); } diff --git a/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapGrid.cs b/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapGrid.cs index ff3be97427..8a561f962a 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapGrid.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapGrid.cs @@ -11,7 +11,7 @@ namespace osu.Game.Rulesets.Osu.Edit public class OsuDistanceSnapGrid : CircularDistanceSnapGrid { public OsuDistanceSnapGrid(OsuHitObject hitObject, [CanBeNull] OsuHitObject nextHitObject = null) - : base(hitObject.StackedEndPosition, hitObject.GetEndTime(), nextHitObject?.StartTime) + : base(hitObject, hitObject.StackedEndPosition, hitObject.GetEndTime(), nextHitObject?.StartTime) { Masking = true; } diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs index 9c7784a00a..e162f805a1 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModHidden.cs @@ -5,6 +5,8 @@ using System; using System.Diagnostics; using System.Linq; using osu.Framework.Graphics; +using osu.Framework.Bindables; +using osu.Game.Configuration; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; @@ -17,6 +19,9 @@ namespace osu.Game.Rulesets.Osu.Mods { public class OsuModHidden : ModHidden, IHidesApproachCircles { + [SettingSource("Only fade approach circles", "The main object body will not fade when enabled.")] + public Bindable OnlyFadeApproachCircles { get; } = new BindableBool(); + public override string Description => @"Play with no approach circles and fading circles/sliders."; public override double ScoreMultiplier => 1.06; @@ -44,15 +49,15 @@ namespace osu.Game.Rulesets.Osu.Mods protected override void ApplyIncreasedVisibilityState(DrawableHitObject hitObject, ArmedState state) { - applyState(hitObject, true); + applyHiddenState(hitObject, true); } protected override void ApplyNormalVisibilityState(DrawableHitObject hitObject, ArmedState state) { - applyState(hitObject, false); + applyHiddenState(hitObject, false); } - private void applyState(DrawableHitObject drawableObject, bool increaseVisibility) + private void applyHiddenState(DrawableHitObject drawableObject, bool increaseVisibility) { if (!(drawableObject is DrawableOsuHitObject drawableOsuObject)) return; @@ -61,6 +66,24 @@ namespace osu.Game.Rulesets.Osu.Mods (double fadeStartTime, double fadeDuration) = getFadeOutParameters(drawableOsuObject); + // process approach circle hiding first (to allow for early return below). + if (!increaseVisibility) + { + if (drawableObject is DrawableHitCircle circle) + { + using (circle.BeginAbsoluteSequence(hitObject.StartTime - hitObject.TimePreempt)) + circle.ApproachCircle.Hide(); + } + else if (drawableObject is DrawableSpinner spinner) + { + spinner.Body.OnSkinChanged += () => hideSpinnerApproachCircle(spinner); + hideSpinnerApproachCircle(spinner); + } + } + + if (OnlyFadeApproachCircles.Value) + return; + switch (drawableObject) { case DrawableSliderTail _: @@ -84,12 +107,6 @@ namespace osu.Game.Rulesets.Osu.Mods // only fade the circle piece (not the approach circle) for the increased visibility object. fadeTarget = circle.CirclePiece; } - else - { - // we don't want to see the approach circle - using (circle.BeginAbsoluteSequence(hitObject.StartTime - hitObject.TimePreempt)) - circle.ApproachCircle.Hide(); - } using (drawableObject.BeginAbsoluteSequence(fadeStartTime)) fadeTarget.FadeOut(fadeDuration); @@ -111,9 +128,6 @@ namespace osu.Game.Rulesets.Osu.Mods // hide elements we don't care about. // todo: hide background - spinner.Body.OnSkinChanged += () => hideSpinnerApproachCircle(spinner); - hideSpinnerApproachCircle(spinner); - using (spinner.BeginAbsoluteSequence(fadeStartTime)) spinner.FadeOut(fadeDuration); diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs b/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs new file mode 100644 index 0000000000..c48cbd9992 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Mods/OsuModNoScope.cs @@ -0,0 +1,76 @@ +// 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.UI; +using osu.Game.Rulesets.Mods; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Bindables; +using osu.Framework.Localisation; +using osu.Framework.Utils; +using osu.Game.Graphics.UserInterface; +using osu.Game.Configuration; +using osu.Game.Overlays.Settings; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; + +namespace osu.Game.Rulesets.Osu.Mods +{ + public class OsuModNoScope : Mod, IUpdatableByPlayfield, IApplicableToScoreProcessor + { + /// + /// Slightly higher than the cutoff for . + /// + private const float min_alpha = 0.0002f; + + private const float transition_duration = 100; + + public override string Name => "No Scope"; + public override string Acronym => "NS"; + public override ModType Type => ModType.Fun; + public override IconUsage? Icon => FontAwesome.Solid.EyeSlash; + public override string Description => "Where's the cursor?"; + public override double ScoreMultiplier => 1; + + private BindableNumber currentCombo; + + private float targetAlpha; + + [SettingSource( + "Hidden at combo", + "The combo count at which the cursor becomes completely hidden", + SettingControlType = typeof(SettingsSlider) + )] + public BindableInt HiddenComboCount { get; } = new BindableInt + { + Default = 10, + Value = 10, + MinValue = 0, + MaxValue = 50, + }; + + public ScoreRank AdjustRank(ScoreRank rank, double accuracy) => rank; + + public void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) + { + if (HiddenComboCount.Value == 0) return; + + currentCombo = scoreProcessor.Combo.GetBoundCopy(); + currentCombo.BindValueChanged(combo => + { + targetAlpha = Math.Max(min_alpha, 1 - (float)combo.NewValue / HiddenComboCount.Value); + }, true); + } + + public virtual void Update(Playfield playfield) + { + playfield.Cursor.Alpha = (float)Interpolation.Lerp(playfield.Cursor.Alpha, targetAlpha, Math.Clamp(playfield.Time.Elapsed / transition_duration, 0, 1)); + } + } + + public class HiddenComboSlider : OsuSliderBar + { + public override LocalisableString TooltipText => Current.Value == 0 ? "always hidden" : base.TooltipText; + } +} diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index 1d2666f46b..ba817d2e40 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -140,9 +140,8 @@ namespace osu.Game.Rulesets.Osu.Objects base.ApplyDefaultsToSelf(controlPointInfo, difficulty); TimingControlPoint timingPoint = controlPointInfo.TimingPointAt(StartTime); - DifficultyControlPoint difficultyPoint = controlPointInfo.DifficultyPointAt(StartTime); - double scoringDistance = BASE_SCORING_DISTANCE * difficulty.SliderMultiplier * difficultyPoint.SpeedMultiplier; + double scoringDistance = BASE_SCORING_DISTANCE * difficulty.SliderMultiplier * DifficultyControlPoint.SliderVelocity; Velocity = scoringDistance / timingPoint.BeatLength; TickDistance = scoringDistance / difficulty.SliderTickRate * TickDistanceMultiplier; @@ -152,8 +151,9 @@ namespace osu.Game.Rulesets.Osu.Objects { base.CreateNestedHitObjects(cancellationToken); - foreach (var e in - SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset, cancellationToken)) + var sliderEvents = SliderEventGenerator.Generate(StartTime, SpanDuration, Velocity, TickDistance, Path.Distance, this.SpanCount(), LegacyLastTickOffset, cancellationToken); + + foreach (var e in sliderEvents) { switch (e.Type) { @@ -175,7 +175,6 @@ namespace osu.Game.Rulesets.Osu.Objects StartTime = e.Time, Position = Position, StackHeight = StackHeight, - SampleControlPoint = SampleControlPoint, }); break; diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index f4a93a571d..ee4712c3b8 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -192,6 +192,7 @@ namespace osu.Game.Rulesets.Osu new OsuModBarrelRoll(), new OsuModApproachDifferent(), new OsuModMuted(), + new OsuModNoScope(), }; case ModType.System: diff --git a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs index 50c0ca7f55..32aad6c36a 100644 --- a/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs +++ b/osu.Game.Rulesets.Taiko/Beatmaps/TaikoBeatmapConverter.cs @@ -154,7 +154,7 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps double distance = distanceData.Distance * spans * LegacyBeatmapEncoder.LEGACY_TAIKO_VELOCITY_MULTIPLIER; TimingControlPoint timingPoint = beatmap.ControlPointInfo.TimingPointAt(obj.StartTime); - DifficultyControlPoint difficultyPoint = beatmap.ControlPointInfo.DifficultyPointAt(obj.StartTime); + DifficultyControlPoint difficultyPoint = obj.DifficultyControlPoint; double beatLength; #pragma warning disable 618 @@ -162,7 +162,7 @@ namespace osu.Game.Rulesets.Taiko.Beatmaps #pragma warning restore 618 beatLength = timingPoint.BeatLength * legacyDifficultyPoint.BpmMultiplier; else - beatLength = timingPoint.BeatLength / difficultyPoint.SpeedMultiplier; + beatLength = timingPoint.BeatLength / difficultyPoint.SliderVelocity; double sliderScoringPointDistance = osu_base_scoring_distance * beatmap.Difficulty.SliderMultiplier / beatmap.Difficulty.SliderTickRate; diff --git a/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs index 0318e32991..0e93ad7e73 100644 --- a/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs @@ -63,9 +63,8 @@ namespace osu.Game.Rulesets.Taiko.Objects base.ApplyDefaultsToSelf(controlPointInfo, difficulty); TimingControlPoint timingPoint = controlPointInfo.TimingPointAt(StartTime); - DifficultyControlPoint difficultyPoint = controlPointInfo.DifficultyPointAt(StartTime); - double scoringDistance = base_distance * difficulty.SliderMultiplier * difficultyPoint.SpeedMultiplier; + double scoringDistance = base_distance * difficulty.SliderMultiplier * DifficultyControlPoint.SliderVelocity; Velocity = scoringDistance / timingPoint.BeatLength; tickSpacing = timingPoint.BeatLength / TickRate; diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs index b5e1fa204f..d37e09aa29 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapDecoderTest.cs @@ -192,15 +192,15 @@ namespace osu.Game.Tests.Beatmaps.Formats var difficultyPoint = controlPoints.DifficultyPointAt(0); Assert.AreEqual(0, difficultyPoint.Time); - Assert.AreEqual(1.0, difficultyPoint.SpeedMultiplier); + Assert.AreEqual(1.0, difficultyPoint.SliderVelocity); difficultyPoint = controlPoints.DifficultyPointAt(48428); Assert.AreEqual(0, difficultyPoint.Time); - Assert.AreEqual(1.0, difficultyPoint.SpeedMultiplier); + Assert.AreEqual(1.0, difficultyPoint.SliderVelocity); difficultyPoint = controlPoints.DifficultyPointAt(116999); Assert.AreEqual(116999, difficultyPoint.Time); - Assert.AreEqual(0.75, difficultyPoint.SpeedMultiplier, 0.1); + Assert.AreEqual(0.75, difficultyPoint.SliderVelocity, 0.1); var soundPoint = controlPoints.SamplePointAt(0); Assert.AreEqual(956, soundPoint.Time); @@ -227,7 +227,7 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.IsTrue(effectPoint.KiaiMode); Assert.IsFalse(effectPoint.OmitFirstBarLine); - effectPoint = controlPoints.EffectPointAt(119637); + effectPoint = controlPoints.EffectPointAt(116637); Assert.AreEqual(95901, effectPoint.Time); Assert.IsFalse(effectPoint.KiaiMode); Assert.IsFalse(effectPoint.OmitFirstBarLine); @@ -249,10 +249,10 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.That(controlPoints.EffectPoints.Count, Is.EqualTo(3)); Assert.That(controlPoints.SamplePoints.Count, Is.EqualTo(3)); - Assert.That(controlPoints.DifficultyPointAt(500).SpeedMultiplier, Is.EqualTo(1.5).Within(0.1)); - Assert.That(controlPoints.DifficultyPointAt(1500).SpeedMultiplier, Is.EqualTo(1.5).Within(0.1)); - Assert.That(controlPoints.DifficultyPointAt(2500).SpeedMultiplier, Is.EqualTo(0.75).Within(0.1)); - Assert.That(controlPoints.DifficultyPointAt(3500).SpeedMultiplier, Is.EqualTo(1.5).Within(0.1)); + Assert.That(controlPoints.DifficultyPointAt(500).SliderVelocity, Is.EqualTo(1.5).Within(0.1)); + Assert.That(controlPoints.DifficultyPointAt(1500).SliderVelocity, Is.EqualTo(1.5).Within(0.1)); + Assert.That(controlPoints.DifficultyPointAt(2500).SliderVelocity, Is.EqualTo(0.75).Within(0.1)); + Assert.That(controlPoints.DifficultyPointAt(3500).SliderVelocity, Is.EqualTo(1.5).Within(0.1)); Assert.That(controlPoints.EffectPointAt(500).KiaiMode, Is.True); Assert.That(controlPoints.EffectPointAt(1500).KiaiMode, Is.True); @@ -279,10 +279,10 @@ namespace osu.Game.Tests.Beatmaps.Formats using (var resStream = TestResources.OpenResource("timingpoint-speedmultiplier-reset.osu")) using (var stream = new LineBufferedReader(resStream)) { - var controlPoints = decoder.Decode(stream).ControlPointInfo; + var controlPoints = (LegacyControlPointInfo)decoder.Decode(stream).ControlPointInfo; - Assert.That(controlPoints.DifficultyPointAt(0).SpeedMultiplier, Is.EqualTo(0.5).Within(0.1)); - Assert.That(controlPoints.DifficultyPointAt(2000).SpeedMultiplier, Is.EqualTo(1).Within(0.1)); + Assert.That(controlPoints.DifficultyPointAt(0).SliderVelocity, Is.EqualTo(0.5).Within(0.1)); + Assert.That(controlPoints.DifficultyPointAt(2000).SliderVelocity, Is.EqualTo(1).Within(0.1)); } } @@ -394,12 +394,12 @@ namespace osu.Game.Tests.Beatmaps.Formats using (var resStream = TestResources.OpenResource("controlpoint-difficulty-multiplier.osu")) using (var stream = new LineBufferedReader(resStream)) { - var controlPointInfo = decoder.Decode(stream).ControlPointInfo; + var controlPointInfo = (LegacyControlPointInfo)decoder.Decode(stream).ControlPointInfo; - Assert.That(controlPointInfo.DifficultyPointAt(5).SpeedMultiplier, Is.EqualTo(1)); - Assert.That(controlPointInfo.DifficultyPointAt(1000).SpeedMultiplier, Is.EqualTo(10)); - Assert.That(controlPointInfo.DifficultyPointAt(2000).SpeedMultiplier, Is.EqualTo(1.8518518518518519d)); - Assert.That(controlPointInfo.DifficultyPointAt(3000).SpeedMultiplier, Is.EqualTo(0.5)); + Assert.That(controlPointInfo.DifficultyPointAt(5).SliderVelocity, Is.EqualTo(1)); + Assert.That(controlPointInfo.DifficultyPointAt(1000).SliderVelocity, Is.EqualTo(10)); + Assert.That(controlPointInfo.DifficultyPointAt(2000).SliderVelocity, Is.EqualTo(1.8518518518518519d)); + Assert.That(controlPointInfo.DifficultyPointAt(3000).SliderVelocity, Is.EqualTo(0.5)); } } @@ -775,5 +775,22 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.That(seventh.ControlPoints[4].Type == null); } } + + [Test] + public void TestSliderLengthExtensionEdgeCase() + { + var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false }; + + using (var resStream = TestResources.OpenResource("duplicate-last-position-slider.osu")) + using (var stream = new LineBufferedReader(resStream)) + { + var decoded = decoder.Decode(stream); + + var path = ((IHasPath)decoded.HitObjects[0]).Path; + + Assert.That(path.ExpectedDistance.Value, Is.EqualTo(2)); + Assert.That(path.Distance, Is.EqualTo(1)); + } + } } } diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs index 896aa53f82..d12da1a22f 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyBeatmapEncoderTest.cs @@ -46,8 +46,7 @@ namespace osu.Game.Tests.Beatmaps.Formats sort(decoded.beatmap); sort(decodedAfterEncode.beatmap); - Assert.That(decodedAfterEncode.beatmap.Serialize(), Is.EqualTo(decoded.beatmap.Serialize())); - Assert.IsTrue(areComboColoursEqual(decodedAfterEncode.beatmapSkin.Configuration, decoded.beatmapSkin.Configuration)); + compareBeatmaps(decoded, decodedAfterEncode); } [TestCaseSource(nameof(allBeatmaps))] @@ -62,8 +61,7 @@ namespace osu.Game.Tests.Beatmaps.Formats sort(decoded.beatmap); sort(decodedAfterEncode.beatmap); - Assert.That(decodedAfterEncode.beatmap.Serialize(), Is.EqualTo(decoded.beatmap.Serialize())); - Assert.IsTrue(areComboColoursEqual(decodedAfterEncode.beatmapSkin.Configuration, decoded.beatmapSkin.Configuration)); + compareBeatmaps(decoded, decodedAfterEncode); } [TestCaseSource(nameof(allBeatmaps))] @@ -77,12 +75,7 @@ namespace osu.Game.Tests.Beatmaps.Formats var decodedAfterEncode = decodeFromLegacy(encodeToLegacy(decoded), name); - // in this process, we may lose some detail in the control points section. - // let's focus on only the hitobjects. - var originalHitObjects = decoded.beatmap.HitObjects.Serialize(); - var newHitObjects = decodedAfterEncode.beatmap.HitObjects.Serialize(); - - Assert.That(newHitObjects, Is.EqualTo(originalHitObjects)); + compareBeatmaps(decoded, decodedAfterEncode); ControlPointInfo removeLegacyControlPointTypes(ControlPointInfo controlPointInfo) { @@ -97,7 +90,7 @@ namespace osu.Game.Tests.Beatmaps.Formats // completely ignore "legacy" types, which have been moved to HitObjects. // even though these would mostly be ignored by the Add call, they will still be available in groups, // which isn't what we want to be testing here. - if (point is SampleControlPoint) + if (point is SampleControlPoint || point is DifficultyControlPoint) continue; newControlPoints.Add(point.Time, point.DeepClone()); @@ -107,6 +100,19 @@ namespace osu.Game.Tests.Beatmaps.Formats } } + private void compareBeatmaps((IBeatmap beatmap, TestLegacySkin skin) expected, (IBeatmap beatmap, TestLegacySkin skin) actual) + { + // Check all control points that are still considered to be at a global level. + Assert.That(expected.beatmap.ControlPointInfo.TimingPoints.Serialize(), Is.EqualTo(actual.beatmap.ControlPointInfo.TimingPoints.Serialize())); + Assert.That(expected.beatmap.ControlPointInfo.EffectPoints.Serialize(), Is.EqualTo(actual.beatmap.ControlPointInfo.EffectPoints.Serialize())); + + // Check all hitobjects. + Assert.That(expected.beatmap.HitObjects.Serialize(), Is.EqualTo(actual.beatmap.HitObjects.Serialize())); + + // Check skin. + Assert.IsTrue(areComboColoursEqual(expected.skin.Configuration, actual.skin.Configuration)); + } + [Test] public void TestEncodeMultiSegmentSliderWithFloatingPointError() { @@ -156,7 +162,7 @@ namespace osu.Game.Tests.Beatmaps.Formats } } - private (IBeatmap beatmap, TestLegacySkin beatmapSkin) decodeFromLegacy(Stream stream, string name) + private (IBeatmap beatmap, TestLegacySkin skin) decodeFromLegacy(Stream stream, string name) { using (var reader = new LineBufferedReader(stream)) { @@ -174,7 +180,7 @@ namespace osu.Game.Tests.Beatmaps.Formats } } - private MemoryStream encodeToLegacy((IBeatmap beatmap, ISkin beatmapSkin) fullBeatmap) + private MemoryStream encodeToLegacy((IBeatmap beatmap, ISkin skin) fullBeatmap) { var (beatmap, beatmapSkin) = fullBeatmap; var stream = new MemoryStream(); diff --git a/osu.Game.Tests/Beatmaps/IO/OszArchiveReaderTest.cs b/osu.Game.Tests/Beatmaps/IO/OszArchiveReaderTest.cs index 022b2c1a59..2c1e39c2cf 100644 --- a/osu.Game.Tests/Beatmaps/IO/OszArchiveReaderTest.cs +++ b/osu.Game.Tests/Beatmaps/IO/OszArchiveReaderTest.cs @@ -77,8 +77,7 @@ namespace osu.Game.Tests.Beatmaps.IO { var reader = new ZipArchiveReader(osz); - using (var stream = new StreamReader( - reader.GetStream("Soleily - Renatus (Deif) [Platter].osu"))) + using (var stream = new StreamReader(reader.GetStream("Soleily - Renatus (Deif) [Platter].osu"))) { Assert.AreEqual("osu file format v13", stream.ReadLine()?.Trim()); } diff --git a/osu.Game.Tests/Chat/MessageFormatterTests.cs b/osu.Game.Tests/Chat/MessageFormatterTests.cs index 2c2c4dc24e..af87fc17ad 100644 --- a/osu.Game.Tests/Chat/MessageFormatterTests.cs +++ b/osu.Game.Tests/Chat/MessageFormatterTests.cs @@ -509,5 +509,17 @@ namespace osu.Game.Tests.Chat Assert.AreEqual(LinkAction.External, result.Action); Assert.AreEqual("/relative", result.Argument); } + + [TestCase("https://dev.ppy.sh/home/changelog", "")] + [TestCase("https://dev.ppy.sh/home/changelog/lazer/2021.1012", "lazer/2021.1012")] + public void TestChangelogLinks(string link, string expectedArg) + { + MessageFormatter.WebsiteRootUrl = "dev.ppy.sh"; + + LinkDetails result = MessageFormatter.GetLinkDetails(link); + + Assert.AreEqual(LinkAction.OpenChangelog, result.Action); + Assert.AreEqual(expectedArg, result.Argument); + } } } diff --git a/osu.Game.Tests/Database/BeatmapImporterTests.cs b/osu.Game.Tests/Database/BeatmapImporterTests.cs new file mode 100644 index 0000000000..4cdcf507b6 --- /dev/null +++ b/osu.Game.Tests/Database/BeatmapImporterTests.cs @@ -0,0 +1,820 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.IO.Archives; +using osu.Game.Models; +using osu.Game.Stores; +using osu.Game.Tests.Resources; +using Realms; +using SharpCompress.Archives; +using SharpCompress.Archives.Zip; +using SharpCompress.Common; +using SharpCompress.Writers.Zip; + +#nullable enable + +namespace osu.Game.Tests.Database +{ + [TestFixture] + public class BeatmapImporterTests : RealmTest + { + [Test] + public void TestImportBeatmapThenCleanup() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using (var importer = new BeatmapImporter(realmFactory, storage)) + using (new RealmRulesetStore(realmFactory, storage)) + { + ILive? imported; + + using (var reader = new ZipArchiveReader(TestResources.GetTestBeatmapStream())) + imported = await importer.Import(reader); + + Assert.AreEqual(1, realmFactory.Context.All().Count()); + + Assert.NotNull(imported); + Debug.Assert(imported != null); + + imported.PerformWrite(s => s.DeletePending = true); + + Assert.AreEqual(1, realmFactory.Context.All().Count(s => s.DeletePending)); + } + }); + + Logger.Log("Running with no work to purge pending deletions"); + + RunTestWithRealm((realmFactory, _) => { Assert.AreEqual(0, realmFactory.Context.All().Count()); }); + } + + [Test] + public void TestImportWhenClosed() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + await LoadOszIntoStore(importer, realmFactory.Context); + }); + } + + [Test] + public void TestImportThenDelete() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var imported = await LoadOszIntoStore(importer, realmFactory.Context); + + deleteBeatmapSet(imported, realmFactory.Context); + }); + } + + [Test] + public void TestImportThenDeleteFromStream() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var tempPath = TestResources.GetTestBeatmapForImport(); + + ILive? importedSet; + + using (var stream = File.OpenRead(tempPath)) + { + importedSet = await importer.Import(new ImportTask(stream, Path.GetFileName(tempPath))); + ensureLoaded(realmFactory.Context); + } + + Assert.NotNull(importedSet); + Debug.Assert(importedSet != null); + + Assert.IsTrue(File.Exists(tempPath), "Stream source file somehow went missing"); + File.Delete(tempPath); + + var imported = realmFactory.Context.All().First(beatmapSet => beatmapSet.ID == importedSet.ID); + + deleteBeatmapSet(imported, realmFactory.Context); + }); + } + + [Test] + public void TestImportThenImport() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var imported = await LoadOszIntoStore(importer, realmFactory.Context); + var importedSecondTime = await LoadOszIntoStore(importer, realmFactory.Context); + + // check the newly "imported" beatmap is actually just the restored previous import. since it matches hash. + Assert.IsTrue(imported.ID == importedSecondTime.ID); + Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID); + + checkBeatmapSetCount(realmFactory.Context, 1); + checkSingleReferencedFileCount(realmFactory.Context, 18); + }); + } + + [Test] + public void TestImportThenImportWithReZip() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + Directory.CreateDirectory(extractedFolder); + + try + { + var imported = await LoadOszIntoStore(importer, realmFactory.Context); + + string hashBefore = hashFile(temp); + + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(extractedFolder); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + // zip files differ because different compression or encoder. + Assert.AreNotEqual(hashBefore, hashFile(temp)); + + var importedSecondTime = await importer.Import(new ImportTask(temp)); + + ensureLoaded(realmFactory.Context); + + Assert.NotNull(importedSecondTime); + Debug.Assert(importedSecondTime != null); + + // but contents doesn't, so existing should still be used. + Assert.IsTrue(imported.ID == importedSecondTime.ID); + Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.PerformRead(s => s.Beatmaps.First().ID)); + } + finally + { + Directory.Delete(extractedFolder, true); + } + }); + } + + [Test] + public void TestImportThenImportWithChangedHashedFile() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + Directory.CreateDirectory(extractedFolder); + + try + { + var imported = await LoadOszIntoStore(importer, realmFactory.Context); + + await createScoreForBeatmap(realmFactory.Context, imported.Beatmaps.First()); + + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(extractedFolder); + + // arbitrary write to hashed file + // this triggers the special BeatmapManager.PreImport deletion/replacement flow. + using (var sw = new FileInfo(Directory.GetFiles(extractedFolder, "*.osu").First()).AppendText()) + await sw.WriteLineAsync("// changed"); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + var importedSecondTime = await importer.Import(new ImportTask(temp)); + + ensureLoaded(realmFactory.Context); + + // check the newly "imported" beatmap is not the original. + Assert.NotNull(importedSecondTime); + Debug.Assert(importedSecondTime != null); + + Assert.IsTrue(imported.ID != importedSecondTime.ID); + Assert.IsTrue(imported.Beatmaps.First().ID != importedSecondTime.PerformRead(s => s.Beatmaps.First().ID)); + } + finally + { + Directory.Delete(extractedFolder, true); + } + }); + } + + [Test] + [Ignore("intentionally broken by import optimisations")] + public void TestImportThenImportWithChangedFile() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + Directory.CreateDirectory(extractedFolder); + + try + { + var imported = await LoadOszIntoStore(importer, realmFactory.Context); + + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(extractedFolder); + + // arbitrary write to non-hashed file + using (var sw = new FileInfo(Directory.GetFiles(extractedFolder, "*.mp3").First()).AppendText()) + await sw.WriteLineAsync("text"); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + var importedSecondTime = await importer.Import(new ImportTask(temp)); + + ensureLoaded(realmFactory.Context); + + Assert.NotNull(importedSecondTime); + Debug.Assert(importedSecondTime != null); + + // check the newly "imported" beatmap is not the original. + Assert.IsTrue(imported.ID != importedSecondTime.ID); + Assert.IsTrue(imported.Beatmaps.First().ID != importedSecondTime.PerformRead(s => s.Beatmaps.First().ID)); + } + finally + { + Directory.Delete(extractedFolder, true); + } + }); + } + + [Test] + public void TestImportThenImportWithDifferentFilename() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + Directory.CreateDirectory(extractedFolder); + + try + { + var imported = await LoadOszIntoStore(importer, realmFactory.Context); + + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(extractedFolder); + + // change filename + var firstFile = new FileInfo(Directory.GetFiles(extractedFolder).First()); + firstFile.MoveTo(Path.Combine(firstFile.DirectoryName.AsNonNull(), $"{firstFile.Name}-changed{firstFile.Extension}")); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + var importedSecondTime = await importer.Import(new ImportTask(temp)); + + ensureLoaded(realmFactory.Context); + + Assert.NotNull(importedSecondTime); + Debug.Assert(importedSecondTime != null); + + // check the newly "imported" beatmap is not the original. + Assert.IsTrue(imported.ID != importedSecondTime.ID); + Assert.IsTrue(imported.Beatmaps.First().ID != importedSecondTime.PerformRead(s => s.Beatmaps.First().ID)); + } + finally + { + Directory.Delete(extractedFolder, true); + } + }); + } + + [Test] + [Ignore("intentionally broken by import optimisations")] + public void TestImportCorruptThenImport() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var imported = await LoadOszIntoStore(importer, realmFactory.Context); + + var firstFile = imported.Files.First(); + + long originalLength; + using (var stream = storage.GetStream(firstFile.File.StoragePath)) + originalLength = stream.Length; + + using (var stream = storage.GetStream(firstFile.File.StoragePath, FileAccess.Write, FileMode.Create)) + stream.WriteByte(0); + + var importedSecondTime = await LoadOszIntoStore(importer, realmFactory.Context); + + using (var stream = storage.GetStream(firstFile.File.StoragePath)) + Assert.AreEqual(stream.Length, originalLength, "Corruption was not fixed on second import"); + + // check the newly "imported" beatmap is actually just the restored previous import. since it matches hash. + Assert.IsTrue(imported.ID == importedSecondTime.ID); + Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID); + + checkBeatmapSetCount(realmFactory.Context, 1); + checkSingleReferencedFileCount(realmFactory.Context, 18); + }); + } + + [Test] + public void TestRollbackOnFailure() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + int loggedExceptionCount = 0; + + Logger.NewEntry += l => + { + if (l.Target == LoggingTarget.Database && l.Exception != null) + Interlocked.Increment(ref loggedExceptionCount); + }; + + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var imported = await LoadOszIntoStore(importer, realmFactory.Context); + + realmFactory.Context.Write(() => imported.Hash += "-changed"); + + checkBeatmapSetCount(realmFactory.Context, 1); + checkBeatmapCount(realmFactory.Context, 12); + checkSingleReferencedFileCount(realmFactory.Context, 18); + + var brokenTempFilename = TestResources.GetTestBeatmapForImport(); + + MemoryStream brokenOsu = new MemoryStream(); + MemoryStream brokenOsz = new MemoryStream(await File.ReadAllBytesAsync(brokenTempFilename)); + + File.Delete(brokenTempFilename); + + using (var outStream = File.Open(brokenTempFilename, FileMode.CreateNew)) + using (var zip = ZipArchive.Open(brokenOsz)) + { + zip.AddEntry("broken.osu", brokenOsu, false); + zip.SaveTo(outStream, CompressionType.Deflate); + } + + // this will trigger purging of the existing beatmap (online set id match) but should rollback due to broken osu. + try + { + await importer.Import(new ImportTask(brokenTempFilename)); + } + catch + { + } + + checkBeatmapSetCount(realmFactory.Context, 1); + checkBeatmapCount(realmFactory.Context, 12); + + checkSingleReferencedFileCount(realmFactory.Context, 18); + + Assert.AreEqual(1, loggedExceptionCount); + + File.Delete(brokenTempFilename); + }); + } + + [Test] + public void TestImportThenDeleteThenImport() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var imported = await LoadOszIntoStore(importer, realmFactory.Context); + + deleteBeatmapSet(imported, realmFactory.Context); + + var importedSecondTime = await LoadOszIntoStore(importer, realmFactory.Context); + + // check the newly "imported" beatmap is actually just the restored previous import. since it matches hash. + Assert.IsTrue(imported.ID == importedSecondTime.ID); + Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID); + }); + } + + [Test] + public void TestImportThenDeleteThenImportWithOnlineIDsMissing() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var imported = await LoadOszIntoStore(importer, realmFactory.Context); + + realmFactory.Context.Write(() => + { + foreach (var b in imported.Beatmaps) + b.OnlineID = -1; + }); + + deleteBeatmapSet(imported, realmFactory.Context); + + var importedSecondTime = await LoadOszIntoStore(importer, realmFactory.Context); + + // check the newly "imported" beatmap has been reimported due to mismatch (even though hashes matched) + Assert.IsTrue(imported.ID != importedSecondTime.ID); + Assert.IsTrue(imported.Beatmaps.First().ID != importedSecondTime.Beatmaps.First().ID); + }); + } + + [Test] + public void TestImportWithDuplicateBeatmapIDs() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var metadata = new RealmBeatmapMetadata + { + Artist = "SomeArtist", + Author = "SomeAuthor" + }; + + var ruleset = realmFactory.Context.All().First(); + + var toImport = new RealmBeatmapSet + { + OnlineID = 1, + Beatmaps = + { + new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), metadata) + { + OnlineID = 2, + }, + new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), metadata) + { + OnlineID = 2, + Status = BeatmapSetOnlineStatus.Loved, + } + } + }; + + var imported = await importer.Import(toImport); + + Assert.NotNull(imported); + Debug.Assert(imported != null); + + Assert.AreEqual(-1, imported.PerformRead(s => s.Beatmaps[0].OnlineID)); + Assert.AreEqual(-1, imported.PerformRead(s => s.Beatmaps[1].OnlineID)); + }); + } + + [Test] + public void TestImportWhenFileOpen() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var temp = TestResources.GetTestBeatmapForImport(); + using (File.OpenRead(temp)) + await importer.Import(temp); + ensureLoaded(realmFactory.Context); + File.Delete(temp); + Assert.IsFalse(File.Exists(temp), "We likely held a read lock on the file when we shouldn't"); + }); + } + + [Test] + public void TestImportWithDuplicateHashes() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + Directory.CreateDirectory(extractedFolder); + + try + { + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(extractedFolder); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.AddEntry("duplicate.osu", Directory.GetFiles(extractedFolder, "*.osu").First()); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + await importer.Import(temp); + + ensureLoaded(realmFactory.Context); + } + finally + { + Directory.Delete(extractedFolder, true); + } + }); + } + + [Test] + public void TestImportNestedStructure() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + string subfolder = Path.Combine(extractedFolder, "subfolder"); + + Directory.CreateDirectory(subfolder); + + try + { + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(subfolder); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + var imported = await importer.Import(new ImportTask(temp)); + + Assert.NotNull(imported); + Debug.Assert(imported != null); + + ensureLoaded(realmFactory.Context); + + Assert.IsFalse(imported.PerformRead(s => s.Files.Any(f => f.Filename.Contains("subfolder"))), "Files contain common subfolder"); + } + finally + { + Directory.Delete(extractedFolder, true); + } + }); + } + + [Test] + public void TestImportWithIgnoredDirectoryInArchive() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + string dataFolder = Path.Combine(extractedFolder, "actual_data"); + string resourceForkFolder = Path.Combine(extractedFolder, "__MACOSX"); + string resourceForkFilePath = Path.Combine(resourceForkFolder, ".extracted"); + + Directory.CreateDirectory(dataFolder); + Directory.CreateDirectory(resourceForkFolder); + + using (var resourceForkFile = File.CreateText(resourceForkFilePath)) + { + await resourceForkFile.WriteLineAsync("adding content so that it's not empty"); + } + + try + { + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(dataFolder); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + var imported = await importer.Import(new ImportTask(temp)); + + Assert.NotNull(imported); + Debug.Assert(imported != null); + + ensureLoaded(realmFactory.Context); + + Assert.IsFalse(imported.PerformRead(s => s.Files.Any(f => f.Filename.Contains("__MACOSX"))), "Files contain resource fork folder, which should be ignored"); + Assert.IsFalse(imported.PerformRead(s => s.Files.Any(f => f.Filename.Contains("actual_data"))), "Files contain common subfolder"); + } + finally + { + Directory.Delete(extractedFolder, true); + } + }); + } + + [Test] + public void TestUpdateBeatmapInfo() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var temp = TestResources.GetTestBeatmapForImport(); + await importer.Import(temp); + + // Update via the beatmap, not the beatmap info, to ensure correct linking + RealmBeatmapSet setToUpdate = realmFactory.Context.All().First(); + + var beatmapToUpdate = setToUpdate.Beatmaps.First(); + + realmFactory.Context.Write(() => beatmapToUpdate.DifficultyName = "updated"); + + RealmBeatmap updatedInfo = realmFactory.Context.All().First(b => b.ID == beatmapToUpdate.ID); + Assert.That(updatedInfo.DifficultyName, Is.EqualTo("updated")); + }); + } + + public static async Task LoadQuickOszIntoOsu(BeatmapImporter importer, Realm realm) + { + var temp = TestResources.GetQuickTestBeatmapForImport(); + + var importedSet = await importer.Import(new ImportTask(temp)); + + Assert.NotNull(importedSet); + + ensureLoaded(realm); + + waitForOrAssert(() => !File.Exists(temp), "Temporary file still exists after standard import", 5000); + + return realm.All().FirstOrDefault(beatmapSet => beatmapSet.ID == importedSet!.ID); + } + + public static async Task LoadOszIntoStore(BeatmapImporter importer, Realm realm, string? path = null, bool virtualTrack = false) + { + var temp = path ?? TestResources.GetTestBeatmapForImport(virtualTrack); + + var importedSet = await importer.Import(new ImportTask(temp)); + + Assert.NotNull(importedSet); + Debug.Assert(importedSet != null); + + ensureLoaded(realm); + + waitForOrAssert(() => !File.Exists(temp), "Temporary file still exists after standard import", 5000); + + return realm.All().First(beatmapSet => beatmapSet.ID == importedSet.ID); + } + + private void deleteBeatmapSet(RealmBeatmapSet imported, Realm realm) + { + realm.Write(() => imported.DeletePending = true); + + checkBeatmapSetCount(realm, 0); + checkBeatmapSetCount(realm, 1, true); + + Assert.IsTrue(realm.All().First(_ => true).DeletePending); + } + + private static Task createScoreForBeatmap(Realm realm, RealmBeatmap beatmap) + { + // TODO: reimplement when we have score support in realm. + // return ImportScoreTest.LoadScoreIntoOsu(osu, new ScoreInfo + // { + // OnlineScoreID = 2, + // Beatmap = beatmap, + // BeatmapInfoID = beatmap.ID + // }, new ImportScoreTest.TestArchiveReader()); + + return Task.CompletedTask; + } + + private static void checkBeatmapSetCount(Realm realm, int expected, bool includeDeletePending = false) + { + Assert.AreEqual(expected, includeDeletePending + ? realm.All().Count() + : realm.All().Count(s => !s.DeletePending)); + } + + private static string hashFile(string filename) + { + using (var s = File.OpenRead(filename)) + return s.ComputeMD5Hash(); + } + + private static void checkBeatmapCount(Realm realm, int expected) + { + Assert.AreEqual(expected, realm.All().Where(_ => true).ToList().Count); + } + + private static void checkSingleReferencedFileCount(Realm realm, int expected) + { + int singleReferencedCount = 0; + + foreach (var f in realm.All()) + { + if (f.BacklinksCount == 1) + singleReferencedCount++; + } + + Assert.AreEqual(expected, singleReferencedCount); + } + + private static void ensureLoaded(Realm realm, int timeout = 60000) + { + IQueryable? resultSets = null; + + waitForOrAssert(() => (resultSets = realm.All().Where(s => !s.DeletePending && s.OnlineID == 241526)).Any(), + @"BeatmapSet did not import to the database in allocated time.", timeout); + + // ensure we were stored to beatmap database backing... + Assert.IsTrue(resultSets?.Count() == 1, $@"Incorrect result count found ({resultSets?.Count()} but should be 1)."); + + IEnumerable queryBeatmapSets() => realm.All().Where(s => !s.DeletePending && s.OnlineID == 241526); + + var set = queryBeatmapSets().First(); + + // ReSharper disable once PossibleUnintendedReferenceComparison + IEnumerable queryBeatmaps() => realm.All().Where(s => s.BeatmapSet != null && s.BeatmapSet == set); + + waitForOrAssert(() => queryBeatmaps().Count() == 12, @"Beatmaps did not import to the database in allocated time", timeout); + waitForOrAssert(() => queryBeatmapSets().Count() == 1, @"BeatmapSet did not import to the database in allocated time", timeout); + + int countBeatmapSetBeatmaps = 0; + int countBeatmaps = 0; + + waitForOrAssert(() => + (countBeatmapSetBeatmaps = queryBeatmapSets().First().Beatmaps.Count) == + (countBeatmaps = queryBeatmaps().Count()), + $@"Incorrect database beatmap count post-import ({countBeatmaps} but should be {countBeatmapSetBeatmaps}).", timeout); + + foreach (RealmBeatmap b in set.Beatmaps) + Assert.IsTrue(set.Beatmaps.Any(c => c.OnlineID == b.OnlineID)); + Assert.IsTrue(set.Beatmaps.Count > 0); + } + + private static void waitForOrAssert(Func result, string failureMessage, int timeout = 60000) + { + const int sleep = 200; + + while (timeout > 0) + { + Thread.Sleep(sleep); + timeout -= sleep; + + if (result()) + return; + } + + Assert.Fail(failureMessage); + } + } +} diff --git a/osu.Game.Tests/Database/FileStoreTests.cs b/osu.Game.Tests/Database/FileStoreTests.cs new file mode 100644 index 0000000000..861de5303d --- /dev/null +++ b/osu.Game.Tests/Database/FileStoreTests.cs @@ -0,0 +1,114 @@ +// 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.IO; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Logging; +using osu.Game.Models; +using osu.Game.Stores; + +#nullable enable + +namespace osu.Game.Tests.Database +{ + public class FileStoreTests : RealmTest + { + [Test] + public void TestImportFile() + { + RunTestWithRealm((realmFactory, storage) => + { + var realm = realmFactory.Context; + var files = new RealmFileStore(realmFactory, storage); + + var testData = new MemoryStream(new byte[] { 0, 1, 2, 3 }); + + realm.Write(() => files.Add(testData, realm)); + + Assert.True(files.Storage.Exists("0/05/054edec1d0211f624fed0cbca9d4f9400b0e491c43742af2c5b0abebf0c990d8")); + Assert.True(files.Storage.Exists(realm.All().First().StoragePath)); + }); + } + + [Test] + public void TestImportSameFileTwice() + { + RunTestWithRealm((realmFactory, storage) => + { + var realm = realmFactory.Context; + var files = new RealmFileStore(realmFactory, storage); + + var testData = new MemoryStream(new byte[] { 0, 1, 2, 3 }); + + realm.Write(() => files.Add(testData, realm)); + realm.Write(() => files.Add(testData, realm)); + + Assert.AreEqual(1, realm.All().Count()); + }); + } + + [Test] + public void TestDontPurgeReferenced() + { + RunTestWithRealm((realmFactory, storage) => + { + var realm = realmFactory.Context; + var files = new RealmFileStore(realmFactory, storage); + + var file = realm.Write(() => files.Add(new MemoryStream(new byte[] { 0, 1, 2, 3 }), realm)); + + var timer = new Stopwatch(); + timer.Start(); + + realm.Write(() => + { + // attach the file to an arbitrary beatmap + var beatmapSet = CreateBeatmapSet(CreateRuleset()); + + beatmapSet.Files.Add(new RealmNamedFileUsage(file, "arbitrary.resource")); + + realm.Add(beatmapSet); + }); + + Logger.Log($"Import complete at {timer.ElapsedMilliseconds}"); + + string path = file.StoragePath; + + Assert.True(realm.All().Any()); + Assert.True(files.Storage.Exists(path)); + + files.Cleanup(); + Logger.Log($"Cleanup complete at {timer.ElapsedMilliseconds}"); + + Assert.True(realm.All().Any()); + Assert.True(file.IsValid); + Assert.True(files.Storage.Exists(path)); + }); + } + + [Test] + public void TestPurgeUnreferenced() + { + RunTestWithRealm((realmFactory, storage) => + { + var realm = realmFactory.Context; + var files = new RealmFileStore(realmFactory, storage); + + var file = realm.Write(() => files.Add(new MemoryStream(new byte[] { 0, 1, 2, 3 }), realm)); + + string path = file.StoragePath; + + Assert.True(realm.All().Any()); + Assert.True(files.Storage.Exists(path)); + + files.Cleanup(); + + Assert.False(realm.All().Any()); + Assert.False(file.IsValid); + Assert.False(files.Storage.Exists(path)); + }); + } + } +} diff --git a/osu.Game.Tests/Database/GeneralUsageTests.cs b/osu.Game.Tests/Database/GeneralUsageTests.cs index 245981cd9b..3e8b6091fd 100644 --- a/osu.Game.Tests/Database/GeneralUsageTests.cs +++ b/osu.Game.Tests/Database/GeneralUsageTests.cs @@ -1,3 +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 System.Threading; using System.Threading.Tasks; diff --git a/osu.Game.Tests/Database/RealmLiveTests.cs b/osu.Game.Tests/Database/RealmLiveTests.cs new file mode 100644 index 0000000000..33aa1afb89 --- /dev/null +++ b/osu.Game.Tests/Database/RealmLiveTests.cs @@ -0,0 +1,213 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Models; +using Realms; + +#nullable enable + +namespace osu.Game.Tests.Database +{ + public class RealmLiveTests : RealmTest + { + [Test] + public void TestLiveCastability() + { + RunTestWithRealm((realmFactory, _) => + { + RealmLive beatmap = realmFactory.CreateContext().Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))).ToLive(); + + ILive iBeatmap = beatmap; + + Assert.AreEqual(0, iBeatmap.Value.Length); + }); + } + + [Test] + public void TestValueAccessWithOpenContext() + { + RunTestWithRealm((realmFactory, _) => + { + RealmLive? liveBeatmap = null; + Task.Factory.StartNew(() => + { + using (var threadContext = realmFactory.CreateContext()) + { + var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); + + liveBeatmap = beatmap.ToLive(); + } + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + + Debug.Assert(liveBeatmap != null); + + Task.Factory.StartNew(() => + { + Assert.DoesNotThrow(() => + { + using (realmFactory.CreateContext()) + { + var resolved = liveBeatmap.Value; + + Assert.IsTrue(resolved.Realm.IsClosed); + Assert.IsTrue(resolved.IsValid); + + // can access properties without a crash. + Assert.IsFalse(resolved.Hidden); + } + }); + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + }); + } + + [Test] + public void TestScopedReadWithoutContext() + { + RunTestWithRealm((realmFactory, _) => + { + RealmLive? liveBeatmap = null; + Task.Factory.StartNew(() => + { + using (var threadContext = realmFactory.CreateContext()) + { + var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); + + liveBeatmap = beatmap.ToLive(); + } + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + + Debug.Assert(liveBeatmap != null); + + Task.Factory.StartNew(() => + { + liveBeatmap.PerformRead(beatmap => + { + Assert.IsTrue(beatmap.IsValid); + Assert.IsFalse(beatmap.Hidden); + }); + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + }); + } + + [Test] + public void TestScopedWriteWithoutContext() + { + RunTestWithRealm((realmFactory, _) => + { + RealmLive? liveBeatmap = null; + Task.Factory.StartNew(() => + { + using (var threadContext = realmFactory.CreateContext()) + { + var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); + + liveBeatmap = beatmap.ToLive(); + } + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + + Debug.Assert(liveBeatmap != null); + + Task.Factory.StartNew(() => + { + liveBeatmap.PerformWrite(beatmap => { beatmap.Hidden = true; }); + liveBeatmap.PerformRead(beatmap => { Assert.IsTrue(beatmap.Hidden); }); + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + }); + } + + [Test] + public void TestValueAccessWithoutOpenContextFails() + { + RunTestWithRealm((realmFactory, _) => + { + RealmLive? liveBeatmap = null; + Task.Factory.StartNew(() => + { + using (var threadContext = realmFactory.CreateContext()) + { + var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); + + liveBeatmap = beatmap.ToLive(); + } + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + + Debug.Assert(liveBeatmap != null); + + Task.Factory.StartNew(() => + { + Assert.Throws(() => + { + var unused = liveBeatmap.Value; + }); + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + }); + } + + [Test] + public void TestLiveAssumptions() + { + RunTestWithRealm((realmFactory, _) => + { + int changesTriggered = 0; + + using (var updateThreadContext = realmFactory.CreateContext()) + { + updateThreadContext.All().SubscribeForNotifications(gotChange); + RealmLive? liveBeatmap = null; + + Task.Factory.StartNew(() => + { + using (var threadContext = realmFactory.CreateContext()) + { + var ruleset = CreateRuleset(); + var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); + + // add a second beatmap to ensure that a full refresh occurs below. + // not just a refresh from the resolved Live. + threadContext.Write(r => r.Add(new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); + + liveBeatmap = beatmap.ToLive(); + } + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + + Debug.Assert(liveBeatmap != null); + + // not yet seen by main context + Assert.AreEqual(0, updateThreadContext.All().Count()); + Assert.AreEqual(0, changesTriggered); + + var resolved = liveBeatmap.Value; + + // retrieval causes an implicit refresh. even changes that aren't related to the retrieval are fired at this point. + Assert.AreEqual(2, updateThreadContext.All().Count()); + Assert.AreEqual(1, changesTriggered); + + // even though the realm that this instance was resolved for was closed, it's still valid. + Assert.IsTrue(resolved.Realm.IsClosed); + Assert.IsTrue(resolved.IsValid); + + // can access properties without a crash. + Assert.IsFalse(resolved.Hidden); + + updateThreadContext.Write(r => + { + // can use with the main context. + r.Remove(resolved); + }); + } + + void gotChange(IRealmCollection sender, ChangeSet changes, Exception error) + { + changesTriggered++; + } + }); + } + } +} diff --git a/osu.Game.Tests/Database/RealmTest.cs b/osu.Game.Tests/Database/RealmTest.cs index 576f901c1a..04c9f2577a 100644 --- a/osu.Game.Tests/Database/RealmTest.cs +++ b/osu.Game.Tests/Database/RealmTest.cs @@ -4,12 +4,13 @@ using System; using System.Runtime.CompilerServices; using System.Threading.Tasks; -using Nito.AsyncEx; using NUnit.Framework; +using osu.Framework.Extensions; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Database; +using osu.Game.Models; #nullable enable @@ -28,42 +29,109 @@ namespace osu.Game.Tests.Database protected void RunTestWithRealm(Action testAction, [CallerMemberName] string caller = "") { - AsyncContext.Run(() => + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(caller)) { - var testStorage = storage.GetStorageForDirectory(caller); - - using (var realmFactory = new RealmContextFactory(testStorage, caller)) + host.Run(new RealmTestGame(() => { - Logger.Log($"Running test using realm file {testStorage.GetFullPath(realmFactory.Filename)}"); - testAction(realmFactory, testStorage); + var testStorage = storage.GetStorageForDirectory(caller); - realmFactory.Dispose(); + using (var realmFactory = new RealmContextFactory(testStorage, caller)) + { + Logger.Log($"Running test using realm file {testStorage.GetFullPath(realmFactory.Filename)}"); + testAction(realmFactory, testStorage); - Logger.Log($"Final database size: {getFileSize(testStorage, realmFactory)}"); - realmFactory.Compact(); - Logger.Log($"Final database size after compact: {getFileSize(testStorage, realmFactory)}"); - } - }); + realmFactory.Dispose(); + + Logger.Log($"Final database size: {getFileSize(testStorage, realmFactory)}"); + realmFactory.Compact(); + Logger.Log($"Final database size after compact: {getFileSize(testStorage, realmFactory)}"); + } + })); + } } protected void RunTestWithRealmAsync(Func testAction, [CallerMemberName] string caller = "") { - AsyncContext.Run(async () => + using (HeadlessGameHost host = new CleanRunHeadlessGameHost(caller)) { - var testStorage = storage.GetStorageForDirectory(caller); - - using (var realmFactory = new RealmContextFactory(testStorage, caller)) + host.Run(new RealmTestGame(async () => { - Logger.Log($"Running test using realm file {testStorage.GetFullPath(realmFactory.Filename)}"); - await testAction(realmFactory, testStorage); + var testStorage = storage.GetStorageForDirectory(caller); - realmFactory.Dispose(); + using (var realmFactory = new RealmContextFactory(testStorage, caller)) + { + Logger.Log($"Running test using realm file {testStorage.GetFullPath(realmFactory.Filename)}"); + await testAction(realmFactory, testStorage); - Logger.Log($"Final database size: {getFileSize(testStorage, realmFactory)}"); - realmFactory.Compact(); - Logger.Log($"Final database size after compact: {getFileSize(testStorage, realmFactory)}"); + realmFactory.Dispose(); + + Logger.Log($"Final database size: {getFileSize(testStorage, realmFactory)}"); + realmFactory.Compact(); + } + })); + } + } + + protected static RealmBeatmapSet CreateBeatmapSet(RealmRuleset ruleset) + { + RealmFile createRealmFile() => new RealmFile { Hash = Guid.NewGuid().ToString().ComputeSHA2Hash() }; + + var metadata = new RealmBeatmapMetadata + { + Title = "My Love", + Artist = "Kuba Oms" + }; + + var beatmapSet = new RealmBeatmapSet + { + Beatmaps = + { + new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), metadata) { DifficultyName = "Easy", }, + new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), metadata) { DifficultyName = "Normal", }, + new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), metadata) { DifficultyName = "Hard", }, + new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), metadata) { DifficultyName = "Insane", } + }, + Files = + { + new RealmNamedFileUsage(createRealmFile(), "test [easy].osu"), + new RealmNamedFileUsage(createRealmFile(), "test [normal].osu"), + new RealmNamedFileUsage(createRealmFile(), "test [hard].osu"), + new RealmNamedFileUsage(createRealmFile(), "test [insane].osu"), } - }); + }; + + for (int i = 0; i < 8; i++) + beatmapSet.Files.Add(new RealmNamedFileUsage(createRealmFile(), $"hitsound{i}.mp3")); + + foreach (var b in beatmapSet.Beatmaps) + b.BeatmapSet = beatmapSet; + + return beatmapSet; + } + + protected static RealmRuleset CreateRuleset() => + new RealmRuleset(0, "osu!", "osu", true); + + private class RealmTestGame : Framework.Game + { + public RealmTestGame(Func work) + { + // ReSharper disable once AsyncVoidLambda + Scheduler.Add(async () => + { + await work().ConfigureAwait(true); + Exit(); + }); + } + + public RealmTestGame(Action work) + { + Scheduler.Add(() => + { + work(); + Exit(); + }); + } } private static long getFileSize(Storage testStorage, RealmContextFactory realmFactory) diff --git a/osu.Game.Tests/Database/RulesetStoreTests.cs b/osu.Game.Tests/Database/RulesetStoreTests.cs new file mode 100644 index 0000000000..f4e0838be1 --- /dev/null +++ b/osu.Game.Tests/Database/RulesetStoreTests.cs @@ -0,0 +1,54 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Game.Models; +using osu.Game.Stores; + +namespace osu.Game.Tests.Database +{ + public class RulesetStoreTests : RealmTest + { + [Test] + public void TestCreateStore() + { + RunTestWithRealm((realmFactory, storage) => + { + var rulesets = new RealmRulesetStore(realmFactory, storage); + + Assert.AreEqual(4, rulesets.AvailableRulesets.Count()); + Assert.AreEqual(4, realmFactory.Context.All().Count()); + }); + } + + [Test] + public void TestCreateStoreTwiceDoesntAddRulesetsAgain() + { + RunTestWithRealm((realmFactory, storage) => + { + var rulesets = new RealmRulesetStore(realmFactory, storage); + var rulesets2 = new RealmRulesetStore(realmFactory, storage); + + Assert.AreEqual(4, rulesets.AvailableRulesets.Count()); + Assert.AreEqual(4, rulesets2.AvailableRulesets.Count()); + + Assert.AreEqual(rulesets.AvailableRulesets.First(), rulesets2.AvailableRulesets.First()); + Assert.AreEqual(4, realmFactory.Context.All().Count()); + }); + } + + [Test] + public void TestRetrievedRulesetsAreDetached() + { + RunTestWithRealm((realmFactory, storage) => + { + var rulesets = new RealmRulesetStore(realmFactory, storage); + + Assert.IsTrue((rulesets.AvailableRulesets.First() as RealmRuleset)?.IsManaged == false); + Assert.IsTrue((rulesets.GetRuleset(0) as RealmRuleset)?.IsManaged == false); + Assert.IsTrue((rulesets.GetRuleset("mania") as RealmRuleset)?.IsManaged == false); + }); + } + } +} diff --git a/osu.Game.Tests/Editing/Checks/CheckAudioInVideoTest.cs b/osu.Game.Tests/Editing/Checks/CheckAudioInVideoTest.cs new file mode 100644 index 0000000000..f3a4f10210 --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckAudioInVideoTest.cs @@ -0,0 +1,104 @@ +// 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.IO; +using System.Linq; +using Moq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Objects; +using osu.Game.Storyboards; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Resources; +using FileInfo = osu.Game.IO.FileInfo; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckAudioInVideoTest + { + private CheckAudioInVideo check; + private IBeatmap beatmap; + + [SetUp] + public void Setup() + { + check = new CheckAudioInVideo(); + beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + BeatmapSet = new BeatmapSetInfo + { + Files = new List(new[] + { + new BeatmapSetFileInfo + { + Filename = "abc123.mp4", + FileInfo = new FileInfo { Hash = "abcdef" } + } + }) + } + } + }; + } + + [Test] + public void TestRegularVideoFile() + { + using (var resourceStream = TestResources.OpenResource("Videos/test-video.mp4")) + Assert.IsEmpty(check.Run(getContext(resourceStream))); + } + + [Test] + public void TestVideoFileWithAudio() + { + using (var resourceStream = TestResources.OpenResource("Videos/test-video-with-audio.mp4")) + { + var issues = check.Run(getContext(resourceStream)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckAudioInVideo.IssueTemplateHasAudioTrack); + } + } + + [Test] + public void TestVideoFileWithTrackButNoAudio() + { + using (var resourceStream = TestResources.OpenResource("Videos/test-video-with-track-but-no-audio.mp4")) + { + var issues = check.Run(getContext(resourceStream)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckAudioInVideo.IssueTemplateHasAudioTrack); + } + } + + [Test] + public void TestMissingFile() + { + beatmap.BeatmapInfo.BeatmapSet.Files.Clear(); + + var issues = check.Run(getContext(null)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckAudioInVideo.IssueTemplateMissingFile); + } + + private BeatmapVerifierContext getContext(Stream resourceStream) + { + var storyboard = new Storyboard(); + var layer = storyboard.GetLayer("Video"); + layer.Add(new StoryboardVideo("abc123.mp4", 0)); + + var mockWorkingBeatmap = new Mock(beatmap, null, null); + mockWorkingBeatmap.Setup(w => w.GetStream(It.IsAny())).Returns(resourceStream); + mockWorkingBeatmap.As().SetupGet(w => w.Storyboard).Returns(storyboard); + + return new BeatmapVerifierContext(beatmap, mockWorkingBeatmap.Object); + } + } +} diff --git a/osu.Game.Tests/Editing/Checks/CheckTooShortAudioFilesTest.cs b/osu.Game.Tests/Editing/Checks/CheckTooShortAudioFilesTest.cs new file mode 100644 index 0000000000..9b090591bc --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckTooShortAudioFilesTest.cs @@ -0,0 +1,128 @@ +// 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.IO; +using System.Linq; +using ManagedBass; +using Moq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Objects; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Resources; +using osuTK.Audio; +using FileInfo = osu.Game.IO.FileInfo; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckTooShortAudioFilesTest + { + private CheckTooShortAudioFiles check; + private IBeatmap beatmap; + + [SetUp] + public void Setup() + { + check = new CheckTooShortAudioFiles(); + beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + BeatmapSet = new BeatmapSetInfo + { + Files = new List(new[] + { + new BeatmapSetFileInfo + { + Filename = "abc123.wav", + FileInfo = new FileInfo { Hash = "abcdef" } + } + }) + } + } + }; + + // 0 = No output device. This still allows decoding. + if (!Bass.Init(0) && Bass.LastError != Errors.Already) + throw new AudioException("Could not initialize Bass."); + } + + [Test] + public void TestDifferentExtension() + { + beatmap.BeatmapInfo.BeatmapSet.Files.Clear(); + beatmap.BeatmapInfo.BeatmapSet.Files.Add(new BeatmapSetFileInfo + { + Filename = "abc123.jpg", + FileInfo = new FileInfo { Hash = "abcdef" } + }); + + // Should fail to load, but not produce an error due to the extension not being expected to load. + Assert.IsEmpty(check.Run(getContext(null, allowMissing: true))); + } + + [Test] + public void TestRegularAudioFile() + { + using (var resourceStream = TestResources.OpenResource("Samples/test-sample.mp3")) + { + Assert.IsEmpty(check.Run(getContext(resourceStream))); + } + } + + [Test] + public void TestBlankAudioFile() + { + using (var resourceStream = TestResources.OpenResource("Samples/blank.wav")) + { + // This is a 0 ms duration audio file, commonly used to silence sliderslides/ticks, and so should be fine. + Assert.IsEmpty(check.Run(getContext(resourceStream))); + } + } + + [Test] + public void TestTooShortAudioFile() + { + using (var resourceStream = TestResources.OpenResource("Samples/test-sample-cut.mp3")) + { + var issues = check.Run(getContext(resourceStream)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckTooShortAudioFiles.IssueTemplateTooShort); + } + } + + [Test] + public void TestMissingAudioFile() + { + using (var resourceStream = TestResources.OpenResource("Samples/missing.mp3")) + { + Assert.IsEmpty(check.Run(getContext(resourceStream, allowMissing: true))); + } + } + + [Test] + public void TestCorruptAudioFile() + { + using (var resourceStream = TestResources.OpenResource("Samples/corrupt.wav")) + { + var issues = check.Run(getContext(resourceStream)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckTooShortAudioFiles.IssueTemplateBadFormat); + } + } + + private BeatmapVerifierContext getContext(Stream resourceStream, bool allowMissing = false) + { + var mockWorkingBeatmap = new Mock(beatmap, null, null); + mockWorkingBeatmap.Setup(w => w.GetStream(It.IsAny())).Returns(resourceStream); + + return new BeatmapVerifierContext(beatmap, mockWorkingBeatmap.Object); + } + } +} diff --git a/osu.Game.Tests/Editing/Checks/CheckZeroByteFilesTest.cs b/osu.Game.Tests/Editing/Checks/CheckZeroByteFilesTest.cs new file mode 100644 index 0000000000..c9adc030c1 --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckZeroByteFilesTest.cs @@ -0,0 +1,86 @@ +// 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.IO; +using System.Linq; +using Moq; +using NUnit.Framework; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Objects; +using FileInfo = osu.Game.IO.FileInfo; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckZeroByteFilesTest + { + private CheckZeroByteFiles check; + private IBeatmap beatmap; + + [SetUp] + public void Setup() + { + check = new CheckZeroByteFiles(); + beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + BeatmapSet = new BeatmapSetInfo + { + Files = new List(new[] + { + new BeatmapSetFileInfo + { + Filename = "abc123.jpg", + FileInfo = new FileInfo { Hash = "abcdef" } + } + }) + } + } + }; + } + + [Test] + public void TestNonZeroBytes() + { + Assert.IsEmpty(check.Run(getContext(byteLength: 44))); + } + + [Test] + public void TestZeroBytes() + { + var issues = check.Run(getContext(byteLength: 0)).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.Single().Template is CheckZeroByteFiles.IssueTemplateZeroBytes); + } + + [Test] + public void TestMissing() + { + Assert.IsEmpty(check.Run(getContextMissing())); + } + + private BeatmapVerifierContext getContext(long byteLength) + { + var mockStream = new Mock(); + mockStream.Setup(s => s.Length).Returns(byteLength); + + var mockWorkingBeatmap = new Mock(); + mockWorkingBeatmap.Setup(w => w.GetStream(It.IsAny())).Returns(mockStream.Object); + + return new BeatmapVerifierContext(beatmap, mockWorkingBeatmap.Object); + } + + private BeatmapVerifierContext getContextMissing() + { + var mockWorkingBeatmap = new Mock(); + mockWorkingBeatmap.Setup(w => w.GetStream(It.IsAny())).Returns((Stream)null); + + return new BeatmapVerifierContext(beatmap, mockWorkingBeatmap.Object); + } + } +} diff --git a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs index a40a6dac4c..8eb9452736 100644 --- a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs +++ b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs @@ -8,6 +8,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Rulesets.Osu.Edit; @@ -55,8 +56,6 @@ namespace osu.Game.Tests.Editing composer.EditorBeatmap.Difficulty.SliderMultiplier = 1; composer.EditorBeatmap.ControlPointInfo.Clear(); - - composer.EditorBeatmap.ControlPointInfo.Add(0, new DifficultyControlPoint { SpeedMultiplier = 1 }); composer.EditorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = 1000 }); }); @@ -73,13 +72,13 @@ namespace osu.Game.Tests.Editing [TestCase(2)] public void TestSpeedMultiplier(float multiplier) { - AddStep($"set multiplier = {multiplier}", () => + assertSnapDistance(100 * multiplier, new HitObject { - composer.EditorBeatmap.ControlPointInfo.Clear(); - composer.EditorBeatmap.ControlPointInfo.Add(0, new DifficultyControlPoint { SpeedMultiplier = multiplier }); + DifficultyControlPoint = new DifficultyControlPoint + { + SliderVelocity = multiplier + } }); - - assertSnapDistance(100 * multiplier); } [TestCase(1)] @@ -197,20 +196,20 @@ namespace osu.Game.Tests.Editing assertSnappedDistance(400, 400); } - private void assertSnapDistance(float expectedDistance) - => AddAssert($"distance is {expectedDistance}", () => composer.GetBeatSnapDistanceAt(0) == expectedDistance); + private void assertSnapDistance(float expectedDistance, HitObject hitObject = null) + => AddAssert($"distance is {expectedDistance}", () => composer.GetBeatSnapDistanceAt(hitObject ?? new HitObject()) == expectedDistance); private void assertDurationToDistance(double duration, float expectedDistance) - => AddAssert($"duration = {duration} -> distance = {expectedDistance}", () => composer.DurationToDistance(0, duration) == expectedDistance); + => AddAssert($"duration = {duration} -> distance = {expectedDistance}", () => composer.DurationToDistance(new HitObject(), duration) == expectedDistance); private void assertDistanceToDuration(float distance, double expectedDuration) - => AddAssert($"distance = {distance} -> duration = {expectedDuration}", () => composer.DistanceToDuration(0, distance) == expectedDuration); + => AddAssert($"distance = {distance} -> duration = {expectedDuration}", () => composer.DistanceToDuration(new HitObject(), distance) == expectedDuration); private void assertSnappedDuration(float distance, double expectedDuration) - => AddAssert($"distance = {distance} -> duration = {expectedDuration} (snapped)", () => composer.GetSnappedDurationFromDistance(0, distance) == expectedDuration); + => AddAssert($"distance = {distance} -> duration = {expectedDuration} (snapped)", () => composer.GetSnappedDurationFromDistance(new HitObject(), distance) == expectedDuration); private void assertSnappedDistance(float distance, float expectedDistance) - => AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)", () => composer.GetSnappedDistanceFromDistance(0, distance) == expectedDistance); + => AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)", () => composer.GetSnappedDistanceFromDistance(new HitObject(), distance) == expectedDistance); private class TestHitObjectComposer : OsuHitObjectComposer { diff --git a/osu.Game.Tests/ImportTest.cs b/osu.Game.Tests/ImportTest.cs index e888f51e98..dbeb453d4d 100644 --- a/osu.Game.Tests/ImportTest.cs +++ b/osu.Game.Tests/ImportTest.cs @@ -17,7 +17,7 @@ namespace osu.Game.Tests protected virtual TestOsuGameBase LoadOsuIntoHost(GameHost host, bool withBeatmap = false) { var osu = new TestOsuGameBase(withBeatmap); - Task.Run(() => host.Run(osu)) + Task.Factory.StartNew(() => host.Run(osu), TaskCreationOptions.LongRunning) .ContinueWith(t => Assert.Fail($"Host threw exception {t.Exception}"), TaskContinuationOptions.OnlyOnFaulted); waitForOrAssert(() => osu.IsLoaded, @"osu! failed to start in a reasonable amount of time"); diff --git a/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs b/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs index fabb016d5f..cfda4f6422 100644 --- a/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs +++ b/osu.Game.Tests/NonVisual/ControlPointInfoTest.cs @@ -46,7 +46,7 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestAddRedundantDifficulty() { - var cpi = new ControlPointInfo(); + var cpi = new LegacyControlPointInfo(); cpi.Add(0, new DifficultyControlPoint()); // is redundant cpi.Add(1000, new DifficultyControlPoint()); // is redundant @@ -55,7 +55,7 @@ namespace osu.Game.Tests.NonVisual Assert.That(cpi.DifficultyPoints.Count, Is.EqualTo(0)); Assert.That(cpi.AllControlPoints.Count(), Is.EqualTo(0)); - cpi.Add(1000, new DifficultyControlPoint { SpeedMultiplier = 2 }); // is not redundant + cpi.Add(1000, new DifficultyControlPoint { SliderVelocity = 2 }); // is not redundant Assert.That(cpi.Groups.Count, Is.EqualTo(1)); Assert.That(cpi.DifficultyPoints.Count, Is.EqualTo(1)); @@ -159,7 +159,7 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestAddControlPointToGroup() { - var cpi = new ControlPointInfo(); + var cpi = new LegacyControlPointInfo(); var group = cpi.GroupAt(1000, true); Assert.That(cpi.Groups.Count, Is.EqualTo(1)); @@ -174,23 +174,23 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestAddDuplicateControlPointToGroup() { - var cpi = new ControlPointInfo(); + var cpi = new LegacyControlPointInfo(); var group = cpi.GroupAt(1000, true); Assert.That(cpi.Groups.Count, Is.EqualTo(1)); group.Add(new DifficultyControlPoint()); - group.Add(new DifficultyControlPoint { SpeedMultiplier = 2 }); + group.Add(new DifficultyControlPoint { SliderVelocity = 2 }); Assert.That(group.ControlPoints.Count, Is.EqualTo(1)); Assert.That(cpi.DifficultyPoints.Count, Is.EqualTo(1)); - Assert.That(cpi.DifficultyPoints.First().SpeedMultiplier, Is.EqualTo(2)); + Assert.That(cpi.DifficultyPoints.First().SliderVelocity, Is.EqualTo(2)); } [Test] public void TestRemoveControlPointFromGroup() { - var cpi = new ControlPointInfo(); + var cpi = new LegacyControlPointInfo(); var group = cpi.GroupAt(1000, true); Assert.That(cpi.Groups.Count, Is.EqualTo(1)); @@ -208,14 +208,14 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestOrdering() { - var cpi = new ControlPointInfo(); + var cpi = new LegacyControlPointInfo(); cpi.Add(0, new TimingControlPoint()); cpi.Add(1000, new TimingControlPoint { BeatLength = 500 }); cpi.Add(10000, new TimingControlPoint { BeatLength = 200 }); cpi.Add(5000, new TimingControlPoint { BeatLength = 100 }); - cpi.Add(3000, new DifficultyControlPoint { SpeedMultiplier = 2 }); - cpi.GroupAt(7000, true).Add(new DifficultyControlPoint { SpeedMultiplier = 4 }); + cpi.Add(3000, new DifficultyControlPoint { SliderVelocity = 2 }); + cpi.GroupAt(7000, true).Add(new DifficultyControlPoint { SliderVelocity = 4 }); cpi.GroupAt(1000).Add(new SampleControlPoint { SampleVolume = 0 }); cpi.GroupAt(8000, true).Add(new EffectControlPoint { KiaiMode = true }); @@ -230,14 +230,14 @@ namespace osu.Game.Tests.NonVisual [Test] public void TestClear() { - var cpi = new ControlPointInfo(); + var cpi = new LegacyControlPointInfo(); cpi.Add(0, new TimingControlPoint()); cpi.Add(1000, new TimingControlPoint { BeatLength = 500 }); cpi.Add(10000, new TimingControlPoint { BeatLength = 200 }); cpi.Add(5000, new TimingControlPoint { BeatLength = 100 }); - cpi.Add(3000, new DifficultyControlPoint { SpeedMultiplier = 2 }); - cpi.GroupAt(7000, true).Add(new DifficultyControlPoint { SpeedMultiplier = 4 }); + cpi.Add(3000, new DifficultyControlPoint { SliderVelocity = 2 }); + cpi.GroupAt(7000, true).Add(new DifficultyControlPoint { SliderVelocity = 4 }); cpi.GroupAt(1000).Add(new SampleControlPoint { SampleVolume = 0 }); cpi.GroupAt(8000, true).Add(new EffectControlPoint { KiaiMode = true }); diff --git a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs index 79767bc671..558b874234 100644 --- a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs @@ -168,14 +168,14 @@ namespace osu.Game.Tests.Online return new TestBeatmapModelManager(this, storage, contextFactory, rulesets, api, host); } - protected override BeatmapModelDownloader CreateBeatmapModelDownloader(BeatmapModelManager modelManager, IAPIProvider api, GameHost host) + protected override BeatmapModelDownloader CreateBeatmapModelDownloader(IBeatmapModelManager manager, IAPIProvider api, GameHost host) { - return new TestBeatmapModelDownloader(modelManager, api, host); + return new TestBeatmapModelDownloader(manager, api, host); } internal class TestBeatmapModelDownloader : BeatmapModelDownloader { - public TestBeatmapModelDownloader(BeatmapModelManager modelManager, IAPIProvider apiProvider, GameHost gameHost) + public TestBeatmapModelDownloader(IBeatmapModelManager modelManager, IAPIProvider apiProvider, GameHost gameHost) : base(modelManager, apiProvider, gameHost) { } diff --git a/osu.Game.Tests/Resources/Samples/blank.wav b/osu.Game.Tests/Resources/Samples/blank.wav new file mode 100644 index 0000000000..878bf23cea Binary files /dev/null and b/osu.Game.Tests/Resources/Samples/blank.wav differ diff --git a/osu.Game.Tests/Resources/Samples/corrupt.wav b/osu.Game.Tests/Resources/Samples/corrupt.wav new file mode 100644 index 0000000000..87c7de4b7b Binary files /dev/null and b/osu.Game.Tests/Resources/Samples/corrupt.wav differ diff --git a/osu.Game.Tests/Resources/Samples/test-sample-cut.mp3 b/osu.Game.Tests/Resources/Samples/test-sample-cut.mp3 new file mode 100644 index 0000000000..003fe23dca Binary files /dev/null and b/osu.Game.Tests/Resources/Samples/test-sample-cut.mp3 differ diff --git a/osu.Game.Tests/Resources/Videos/test-video-with-audio.mp4 b/osu.Game.Tests/Resources/Videos/test-video-with-audio.mp4 new file mode 100644 index 0000000000..5d380ab50c Binary files /dev/null and b/osu.Game.Tests/Resources/Videos/test-video-with-audio.mp4 differ diff --git a/osu.Game.Tests/Resources/Videos/test-video-with-track-but-no-audio.mp4 b/osu.Game.Tests/Resources/Videos/test-video-with-track-but-no-audio.mp4 new file mode 100644 index 0000000000..7cdd1939e9 Binary files /dev/null and b/osu.Game.Tests/Resources/Videos/test-video-with-track-but-no-audio.mp4 differ diff --git a/osu.Game.Tests/Resources/Videos/test-video.mp4 b/osu.Game.Tests/Resources/Videos/test-video.mp4 new file mode 100644 index 0000000000..795483c096 Binary files /dev/null and b/osu.Game.Tests/Resources/Videos/test-video.mp4 differ diff --git a/osu.Game.Tests/Resources/duplicate-last-position-slider.osu b/osu.Game.Tests/Resources/duplicate-last-position-slider.osu new file mode 100644 index 0000000000..782dd4263e --- /dev/null +++ b/osu.Game.Tests/Resources/duplicate-last-position-slider.osu @@ -0,0 +1,19 @@ +osu file format v14 + +[Difficulty] +HPDrainRate:7 +CircleSize:10 +OverallDifficulty:9 +ApproachRate:10 +SliderMultiplier:0.4 +SliderTickRate:1 + +[TimingPoints] +382,923.076923076923,3,2,1,75,1,0 +382,-1000,3,2,1,75,0,0 + +[HitObjects] + +// Importantly, the last position is specified twice. +// In this case, osu-stable doesn't extend the slider length even when the "expected" length is higher than the actual. +261,171,25305,6,0,B|262:171|262:171|262:171,1,2,8|0,0:0|0:0,0:0:0:0: diff --git a/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs b/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs index ab47067411..ffb3d41d18 100644 --- a/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs +++ b/osu.Game.Tests/Skins/TestSceneSkinProvidingContainer.cs @@ -6,7 +6,6 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; -using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; @@ -65,10 +64,9 @@ namespace osu.Game.Tests.Skins public new void TriggerSourceChanged() => base.TriggerSourceChanged(); - protected override void OnSourceChanged() + protected override void RefreshSources() { - ResetSources(); - sources.ForEach(AddSource); + SetSources(sources); } } diff --git a/osu.Game.Tests/Visual/Audio/TestSceneAudioFilter.cs b/osu.Game.Tests/Visual/Audio/TestSceneAudioFilter.cs index 211543a881..99be72e958 100644 --- a/osu.Game.Tests/Visual/Audio/TestSceneAudioFilter.cs +++ b/osu.Game.Tests/Visual/Audio/TestSceneAudioFilter.cs @@ -6,6 +6,7 @@ using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Track; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; @@ -18,16 +19,19 @@ namespace osu.Game.Tests.Visual.Audio { public class TestSceneAudioFilter : OsuTestScene { - private OsuSpriteText lowpassText; - private AudioFilter lowpassFilter; + private OsuSpriteText lowPassText; + private AudioFilter lowPassFilter; - private OsuSpriteText highpassText; - private AudioFilter highpassFilter; + private OsuSpriteText highPassText; + private AudioFilter highPassFilter; private Track track; private WaveformTestBeatmap beatmap; + private OsuSliderBar lowPassSlider; + private OsuSliderBar highPassSlider; + [BackgroundDependencyLoader] private void load(AudioManager audio) { @@ -38,53 +42,89 @@ namespace osu.Game.Tests.Visual.Audio { Children = new Drawable[] { - lowpassFilter = new AudioFilter(audio.TrackMixer), - highpassFilter = new AudioFilter(audio.TrackMixer, BQFType.HighPass), - lowpassText = new OsuSpriteText + lowPassFilter = new AudioFilter(audio.TrackMixer), + highPassFilter = new AudioFilter(audio.TrackMixer, BQFType.HighPass), + lowPassText = new OsuSpriteText { Padding = new MarginPadding(20), - Text = $"Low Pass: {lowpassFilter.Cutoff.Value}hz", + Text = $"Low Pass: {lowPassFilter.Cutoff}hz", Font = new FontUsage(size: 40) }, - new OsuSliderBar + lowPassSlider = new OsuSliderBar { Width = 500, Height = 50, Padding = new MarginPadding(20), - Current = { BindTarget = lowpassFilter.Cutoff } + Current = new BindableInt + { + MinValue = 0, + MaxValue = AudioFilter.MAX_LOWPASS_CUTOFF, + } }, - highpassText = new OsuSpriteText + highPassText = new OsuSpriteText { Padding = new MarginPadding(20), - Text = $"High Pass: {highpassFilter.Cutoff.Value}hz", + Text = $"High Pass: {highPassFilter.Cutoff}hz", Font = new FontUsage(size: 40) }, - new OsuSliderBar + highPassSlider = new OsuSliderBar { Width = 500, Height = 50, Padding = new MarginPadding(20), - Current = { BindTarget = highpassFilter.Cutoff } + Current = new BindableInt + { + MinValue = 0, + MaxValue = AudioFilter.MAX_LOWPASS_CUTOFF, + } } } }); - lowpassFilter.Cutoff.ValueChanged += e => lowpassText.Text = $"Low Pass: {e.NewValue}hz"; - highpassFilter.Cutoff.ValueChanged += e => highpassText.Text = $"High Pass: {e.NewValue}hz"; + + lowPassSlider.Current.ValueChanged += e => + { + lowPassText.Text = $"Low Pass: {e.NewValue}hz"; + lowPassFilter.Cutoff = e.NewValue; + }; + + highPassSlider.Current.ValueChanged += e => + { + highPassText.Text = $"High Pass: {e.NewValue}hz"; + highPassFilter.Cutoff = e.NewValue; + }; } + #region Overrides of Drawable + + protected override void Update() + { + base.Update(); + highPassSlider.Current.Value = highPassFilter.Cutoff; + lowPassSlider.Current.Value = lowPassFilter.Cutoff; + } + + #endregion + [SetUpSteps] public void SetUpSteps() { AddStep("Play Track", () => track.Start()); + + AddStep("Reset filters", () => + { + lowPassFilter.Cutoff = AudioFilter.MAX_LOWPASS_CUTOFF; + highPassFilter.Cutoff = 0; + }); + waitTrackPlay(); } [Test] - public void TestLowPass() + public void TestLowPassSweep() { AddStep("Filter Sweep", () => { - lowpassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF).Then() + lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF).Then() .CutoffTo(0, 2000, Easing.OutCubic); }); @@ -92,7 +132,7 @@ namespace osu.Game.Tests.Visual.Audio AddStep("Filter Sweep (reverse)", () => { - lowpassFilter.CutoffTo(0).Then() + lowPassFilter.CutoffTo(0).Then() .CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 2000, Easing.InCubic); }); @@ -101,11 +141,11 @@ namespace osu.Game.Tests.Visual.Audio } [Test] - public void TestHighPass() + public void TestHighPassSweep() { AddStep("Filter Sweep", () => { - highpassFilter.CutoffTo(0).Then() + highPassFilter.CutoffTo(0).Then() .CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 2000, Easing.InCubic); }); @@ -113,7 +153,7 @@ namespace osu.Game.Tests.Visual.Audio AddStep("Filter Sweep (reverse)", () => { - highpassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF).Then() + highPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF).Then() .CutoffTo(0, 2000, Easing.OutCubic); }); @@ -123,5 +163,11 @@ namespace osu.Game.Tests.Visual.Audio } private void waitTrackPlay() => AddWaitStep("Let track play", 10); + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + track?.Dispose(); + } } } diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapSetOnlineStatusPill.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapSetOnlineStatusPill.cs new file mode 100644 index 0000000000..c48b63ac89 --- /dev/null +++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapSetOnlineStatusPill.cs @@ -0,0 +1,53 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Overlays; +using osu.Game.Tests.Visual.UserInterface; +using osuTK; + +namespace osu.Game.Tests.Visual.Beatmaps +{ + public class TestSceneBeatmapSetOnlineStatusPill : ThemeComparisonTestScene + { + protected override Drawable CreateContent() => new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 10), + ChildrenEnumerable = Enum.GetValues(typeof(BeatmapSetOnlineStatus)).Cast().Select(status => new BeatmapSetOnlineStatusPill + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Status = status + }) + }; + + private IEnumerable statusPills => this.ChildrenOfType(); + + [Test] + public void TestFixedWidth() + { + AddStep("create themed content", () => CreateThemedContent(OverlayColourScheme.Red)); + + AddStep("set fixed width", () => statusPills.ForEach(pill => + { + pill.AutoSizeAxes = Axes.Y; + pill.Width = 90; + })); + AddStep("unset fixed width", () => statusPills.ForEach(pill => pill.AutoSizeAxes = Axes.Both)); + } + } +} diff --git a/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs b/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs index 9a999a4931..89e20043fb 100644 --- a/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs +++ b/osu.Game.Tests/Visual/Components/TestScenePreviewTrackManager.cs @@ -224,7 +224,7 @@ namespace osu.Game.Tests.Visual.Components public new PreviewTrack CurrentTrack => base.CurrentTrack; - protected override TrackManagerPreviewTrack CreatePreviewTrack(BeatmapSetInfo beatmapSetInfo, ITrackStore trackStore) => new TestPreviewTrack(beatmapSetInfo, trackStore); + protected override TrackManagerPreviewTrack CreatePreviewTrack(IBeatmapSetInfo beatmapSetInfo, ITrackStore trackStore) => new TestPreviewTrack(beatmapSetInfo, trackStore); public override bool UpdateSubTree() { @@ -240,7 +240,7 @@ namespace osu.Game.Tests.Visual.Components public new Track Track => base.Track; - public TestPreviewTrack(BeatmapSetInfo beatmapSetInfo, ITrackStore trackManager) + public TestPreviewTrack(IBeatmapSetInfo beatmapSetInfo, ITrackStore trackManager) : base(beatmapSetInfo, trackManager) { this.trackManager = trackManager; diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs index 11830ebe35..d1efd22d6f 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Beatmaps; using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Compose.Components; @@ -81,7 +82,7 @@ namespace osu.Game.Tests.Visual.Editing public new float DistanceSpacing => base.DistanceSpacing; public TestDistanceSnapGrid(double? endTime = null) - : base(grid_position, 0, endTime) + : base(new HitObject(), grid_position, 0, endTime) { } @@ -158,15 +159,15 @@ namespace osu.Game.Tests.Visual.Editing public SnapResult SnapScreenSpacePositionToValidTime(Vector2 screenSpacePosition) => new SnapResult(screenSpacePosition, 0); - public float GetBeatSnapDistanceAt(double referenceTime) => 10; + public float GetBeatSnapDistanceAt(HitObject referenceObject) => 10; - public float DurationToDistance(double referenceTime, double duration) => (float)duration; + public float DurationToDistance(HitObject referenceObject, double duration) => (float)duration; - public double DistanceToDuration(double referenceTime, float distance) => distance; + public double DistanceToDuration(HitObject referenceObject, float distance) => distance; - public double GetSnappedDurationFromDistance(double referenceTime, float distance) => 0; + public double GetSnappedDurationFromDistance(HitObject referenceObject, float distance) => 0; - public float GetSnappedDistanceFromDistance(double referenceTime, float distance) => 0; + public float GetSnappedDistanceFromDistance(HitObject referenceObject, float distance) => 0; } } } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs index 2258a209e2..af3d9beb69 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorSaving.cs @@ -8,6 +8,7 @@ using osu.Framework.Testing; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Edit; using osu.Game.Screens.Edit; +using osu.Game.Screens.Edit.Setup; using osu.Game.Screens.Menu; using osu.Game.Screens.Select; using osuTK.Input; @@ -30,22 +31,41 @@ namespace osu.Game.Tests.Visual.Editing PushAndConfirm(() => new EditorLoader()); - AddUntilStep("wait for editor load", () => editor != null); + AddUntilStep("wait for editor load", () => editor?.IsLoaded == true); - AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint())); + AddUntilStep("wait for metadata screen load", () => editor.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); + + // We intentionally switch away from the metadata screen, else there is a feedback loop with the textbox handling which causes metadata changes below to get overwritten. AddStep("Enter compose mode", () => InputManager.Key(Key.F1)); AddUntilStep("Wait for compose mode load", () => editor.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); + AddStep("Set overall difficulty", () => editorBeatmap.Difficulty.OverallDifficulty = 7); + AddStep("Set artist and title", () => + { + editorBeatmap.BeatmapInfo.Metadata.Artist = "artist"; + editorBeatmap.BeatmapInfo.Metadata.Title = "title"; + }); + AddStep("Set difficulty name", () => editorBeatmap.BeatmapInfo.Version = "difficulty"); + + AddStep("Add timing point", () => editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint())); + AddStep("Change to placement mode", () => InputManager.Key(Key.Number2)); AddStep("Move to playfield", () => InputManager.MoveMouseTo(Game.ScreenSpaceDrawQuad.Centre)); AddStep("Place single hitcircle", () => InputManager.Click(MouseButton.Left)); - AddStep("Save and exit", () => - { - InputManager.Keys(PlatformAction.Save); - InputManager.Key(Key.Escape); - }); + checkMutations(); + + // After placement these must be non-default as defaults are read-only. + AddAssert("Placed object has non-default control points", () => + editorBeatmap.HitObjects[0].SampleControlPoint != SampleControlPoint.DEFAULT && + editorBeatmap.HitObjects[0].DifficultyControlPoint != DifficultyControlPoint.DEFAULT); + + AddStep("Save", () => InputManager.Keys(PlatformAction.Save)); + + checkMutations(); + + AddStep("Exit", () => InputManager.Key(Key.Escape)); AddUntilStep("Wait for main menu", () => Game.ScreenStack.CurrentScreen is MainMenu); @@ -56,7 +76,16 @@ namespace osu.Game.Tests.Visual.Editing AddStep("Enter editor", () => InputManager.Key(Key.Number5)); AddUntilStep("Wait for editor load", () => editor != null); + + checkMutations(); + } + + private void checkMutations() + { AddAssert("Beatmap contains single hitcircle", () => editorBeatmap.HitObjects.Count == 1); + AddAssert("Beatmap has correct overall difficulty", () => editorBeatmap.Difficulty.OverallDifficulty == 7); + AddAssert("Beatmap has correct metadata", () => editorBeatmap.BeatmapInfo.Metadata.Artist == "artist" && editorBeatmap.BeatmapInfo.Metadata.Title == "title"); + AddAssert("Beatmap has correct difficulty name", () => editorBeatmap.BeatmapInfo.Version == "difficulty"); } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs index 00b5c38e20..c5ab3974a4 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs @@ -20,14 +20,15 @@ namespace osu.Game.Tests.Visual.Gameplay /// public abstract class TestSceneAllRulesetPlayers : RateAdjustedBeatmapTestScene { - protected Player Player; + protected Player Player { get; private set; } + + protected OsuConfigManager Config { get; private set; } [BackgroundDependencyLoader] private void load(RulesetStore rulesets) { - OsuConfigManager manager; - Dependencies.Cache(manager = new OsuConfigManager(LocalStorage)); - manager.GetBindable(OsuSetting.DimLevel).Value = 1.0; + Dependencies.Cache(Config = new OsuConfigManager(LocalStorage)); + Config.GetBindable(OsuSetting.DimLevel).Value = 1.0; } [Test] diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs index 85aaf20a19..36fc6812bd 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs @@ -2,7 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System; +using NUnit.Framework; using osu.Framework.Graphics.Containers; +using osu.Game.Configuration; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Play; @@ -17,6 +19,14 @@ namespace osu.Game.Tests.Visual.Gameplay return new FailPlayer(); } + [Test] + public void TestOsuWithoutRedTint() + { + AddStep("Disable red tint", () => Config.SetValue(OsuSetting.FadePlayfieldWhenHealthLow, false)); + TestOsu(); + AddStep("Enable red tint", () => Config.SetValue(OsuSetting.FadePlayfieldWhenHealthLow, true)); + } + protected override void AddCheckSteps() { AddUntilStep("wait for fail", () => Player.HasFailed); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs index 5eb71e92c2..ae0decaee1 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFrameStabilityContainer.cs @@ -103,6 +103,30 @@ namespace osu.Game.Tests.Visual.Gameplay checkFrameCount(0); } + [Test] + public void TestRatePreservedWhenTimeNotProgressing() + { + AddStep("set manual clock rate", () => manualClock.Rate = 1); + seekManualTo(5000); + createStabilityContainer(); + checkRate(1); + + seekManualTo(10000); + checkRate(1); + + AddWaitStep("wait some", 3); + checkRate(1); + + seekManualTo(5000); + checkRate(-1); + + AddWaitStep("wait some", 3); + checkRate(-1); + + seekManualTo(10000); + checkRate(1); + } + private const int max_frames_catchup = 50; private void createStabilityContainer(double gameplayStartTime = double.MinValue) => AddStep("create container", () => @@ -116,6 +140,9 @@ namespace osu.Game.Tests.Visual.Gameplay private void checkFrameCount(int frames) => AddAssert($"elapsed frames is {frames}", () => consumer.ElapsedFrames == frames); + private void checkRate(double rate) => + AddAssert($"clock rate is {rate}", () => consumer.Clock.Rate == rate); + public class ClockConsumingChild : CompositeDrawable { private readonly OsuSpriteText text; diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs index aee15a145c..ba0ee5ac6e 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs @@ -291,7 +291,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for current", () => loader.IsCurrentScreen()); - AddAssert($"epilepsy warning {(warning ? "present" : "absent")}", () => this.ChildrenOfType().Any() == warning); + AddAssert($"epilepsy warning {(warning ? "present" : "absent")}", () => (getWarning() != null) == warning); if (warning) { @@ -335,12 +335,17 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for current", () => loader.IsCurrentScreen()); - AddUntilStep("wait for epilepsy warning", () => loader.ChildrenOfType().Single().Alpha > 0); + AddUntilStep("wait for epilepsy warning", () => getWarning().Alpha > 0); + AddUntilStep("warning is shown", () => getWarning().State.Value == Visibility.Visible); + AddStep("exit early", () => loader.Exit()); + AddUntilStep("warning is hidden", () => getWarning().State.Value == Visibility.Hidden); AddUntilStep("sound volume restored", () => Beatmap.Value.Track.AggregateVolume.Value == 1); } + private EpilepsyWarning getWarning() => loader.ChildrenOfType().SingleOrDefault(); + private class TestPlayerLoader : PlayerLoader { public new VisualSettings VisualSettings => base.VisualSettings; diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs index 5ff2e9c439..bf864f844c 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Screens; using osu.Game.Beatmaps; @@ -10,10 +11,13 @@ using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Online.Solo; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko; +using osu.Game.Scoring; using osu.Game.Screens.Ranking; using osu.Game.Tests.Beatmaps; @@ -32,7 +36,7 @@ namespace osu.Game.Tests.Visual.Gameplay protected override bool HasCustomSteps => true; - protected override TestPlayer CreatePlayer(Ruleset ruleset) => new TestPlayer(false); + protected override TestPlayer CreatePlayer(Ruleset ruleset) => new NonImportingPlayer(false); protected override Ruleset CreatePlayerRuleset() => createCustomRuleset?.Invoke() ?? new OsuRuleset(); @@ -86,6 +90,46 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("ensure passing submission", () => Player.SubmittedScore?.ScoreInfo.Passed == true); } + [Test] + public void TestSubmissionForDifferentRuleset() + { + prepareTokenResponse(true); + + createPlayerTest(createRuleset: () => new TaikoRuleset()); + + AddUntilStep("wait for token request", () => Player.TokenCreationRequested); + + AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); + + addFakeHit(); + + AddStep("seek to completion", () => Player.GameplayClockContainer.Seek(Player.DrawableRuleset.Objects.Last().GetEndTime())); + + AddUntilStep("results displayed", () => Player.GetChildScreen() is ResultsScreen); + AddAssert("ensure passing submission", () => Player.SubmittedScore?.ScoreInfo.Passed == true); + AddAssert("submitted score has correct ruleset ID", () => Player.SubmittedScore?.ScoreInfo.RulesetID == new TaikoRuleset().RulesetInfo.ID); + } + + [Test] + public void TestSubmissionForConvertedBeatmap() + { + prepareTokenResponse(true); + + createPlayerTest(createRuleset: () => new ManiaRuleset(), createBeatmap: _ => createTestBeatmap(new OsuRuleset().RulesetInfo)); + + AddUntilStep("wait for token request", () => Player.TokenCreationRequested); + + AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); + + addFakeHit(); + + AddStep("seek to completion", () => Player.GameplayClockContainer.Seek(Player.DrawableRuleset.Objects.Last().GetEndTime())); + + AddUntilStep("results displayed", () => Player.GetChildScreen() is ResultsScreen); + AddAssert("ensure passing submission", () => Player.SubmittedScore?.ScoreInfo.Passed == true); + AddAssert("submitted score has correct ruleset ID", () => Player.SubmittedScore?.ScoreInfo.RulesetID == new ManiaRuleset().RulesetInfo.ID); + } + [Test] public void TestNoSubmissionOnExitWithNoToken() { @@ -183,12 +227,13 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("ensure no submission", () => Player.SubmittedScore == null); } - [Test] - public void TestNoSubmissionOnCustomRuleset() + [TestCase(null)] + [TestCase(10)] + public void TestNoSubmissionOnCustomRuleset(int? rulesetId) { prepareTokenResponse(true); - createPlayerTest(false, createRuleset: () => new OsuRuleset { RulesetInfo = { ID = 10 } }); + createPlayerTest(false, createRuleset: () => new OsuRuleset { RulesetInfo = { ID = rulesetId } }); AddUntilStep("wait for token request", () => Player.TokenCreationRequested); @@ -242,5 +287,33 @@ namespace osu.Game.Tests.Visual.Gameplay }); }); } + + private class NonImportingPlayer : TestPlayer + { + public NonImportingPlayer(bool allowPause = true, bool showResults = true, bool pauseOnFocusLost = false) + : base(allowPause, showResults, pauseOnFocusLost) + { + } + + protected override Task ImportScore(Score score) + { + // It was discovered that Score members could sometimes be half-populated. + // In particular, the RulesetID property could be set to 0 even on non-osu! maps. + // We want to test that the state of that property is consistent in this test. + // EF makes this impossible. + // + // First off, because of the EF navigational property-explicit foreign key field duality, + // it can happen that - for example - the Ruleset navigational property is correctly initialised to mania, + // but the RulesetID foreign key property is not initialised and remains 0. + // EF silently bypasses this by prioritising the Ruleset navigational property over the RulesetID foreign key one. + // + // Additionally, adding an entity to an EF DbSet CAUSES SIDE EFFECTS with regard to the foreign key property. + // In the above instance, if a ScoreInfo with Ruleset = {mania} and RulesetID = 0 is attached to an EF context, + // RulesetID WILL BE SILENTLY SET TO THE CORRECT VALUE of 3. + // + // For the above reasons, importing is disabled in this test. + return Task.CompletedTask; + } + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs index 2f15e549f7..283fe594ea 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneScrollingHitObjects.cs @@ -93,9 +93,9 @@ namespace osu.Game.Tests.Visual.Gameplay private IList testControlPoints => new List { - new MultiplierControlPoint(time_range) { DifficultyPoint = { SpeedMultiplier = 1.25 } }, - new MultiplierControlPoint(1.5 * time_range) { DifficultyPoint = { SpeedMultiplier = 1 } }, - new MultiplierControlPoint(2 * time_range) { DifficultyPoint = { SpeedMultiplier = 1.5 } } + new MultiplierControlPoint(time_range) { EffectPoint = { ScrollSpeed = 1.25 } }, + new MultiplierControlPoint(1.5 * time_range) { EffectPoint = { ScrollSpeed = 1 } }, + new MultiplierControlPoint(2 * time_range) { EffectPoint = { ScrollSpeed = 1.5 } } }; [Test] diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorHost.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorHost.cs new file mode 100644 index 0000000000..89fea1f92d --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorHost.cs @@ -0,0 +1,51 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Game.Online.API; +using osu.Game.Online.Spectator; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mania; +using osu.Game.Tests.Visual.Spectator; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneSpectatorHost : PlayerTestScene + { + protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); + + [Cached(typeof(SpectatorClient))] + private TestSpectatorClient spectatorClient { get; } = new TestSpectatorClient(); + + private DummyAPIAccess dummyAPIAccess => (DummyAPIAccess)API; + private const int dummy_user_id = 42; + + public override void SetUpSteps() + { + AddStep("set dummy user", () => dummyAPIAccess.LocalUser.Value = new User + { + Id = dummy_user_id, + Username = "DummyUser" + }); + AddStep("add test spectator client", () => Add(spectatorClient)); + AddStep("add watching user", () => spectatorClient.WatchUser(dummy_user_id)); + base.SetUpSteps(); + } + + [Test] + public void TestClientSendsCorrectRuleset() + { + AddUntilStep("spectator client sending frames", () => spectatorClient.PlayingUserStates.ContainsKey(dummy_user_id)); + AddAssert("spectator client sent correct ruleset", () => spectatorClient.PlayingUserStates[dummy_user_id].RulesetID == Ruleset.Value.ID); + } + + public override void TearDownSteps() + { + base.TearDownSteps(); + AddStep("stop watching user", () => spectatorClient.StopWatchingUser(dummy_user_id)); + AddStep("remove test spectator client", () => Remove(spectatorClient)); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs index 3ed274690e..48a97d54f7 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneStoryboardWithOutro.cs @@ -90,8 +90,12 @@ namespace osu.Game.Tests.Visual.Gameplay CreateTest(() => { AddStep("fail on first judgement", () => currentFailConditions = (_, __) => true); - AddStep("set storyboard duration to 1.3s", () => currentStoryboardDuration = 1300); + + // Fail occurs at 164ms with the provided beatmap. + // Fail animation runs for 2.5s realtime but the gameplay time change is *variable* due to the frequency transform being applied, so we need a bit of lenience. + AddStep("set storyboard duration to 0.6s", () => currentStoryboardDuration = 600); }); + AddUntilStep("wait for fail", () => Player.HasFailed); AddUntilStep("storyboard ends", () => Player.GameplayClockContainer.GameplayClock.CurrentTime >= currentStoryboardDuration); AddUntilStep("wait for fail overlay", () => Player.FailOverlay.State.Value == Visibility.Visible); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableLoungeRoom.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableLoungeRoom.cs new file mode 100644 index 0000000000..ea895a23d2 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableLoungeRoom.cs @@ -0,0 +1,167 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Moq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Testing; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Screens.OnlinePlay.Lounge; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneDrawableLoungeRoom : OsuManualInputManagerTestScene + { + private readonly Room room = new Room + { + HasPassword = { Value = true } + }; + + [Cached] + protected readonly OverlayColourProvider ColourProvider = new OverlayColourProvider(OverlayColourScheme.Pink); + + private DrawableLoungeRoom drawableRoom; + private SearchTextBox searchTextBox; + + private readonly ManualResetEventSlim allowResponseCallback = new ManualResetEventSlim(); + + [BackgroundDependencyLoader] + private void load() + { + var mockLounge = new Mock(); + mockLounge + .Setup(l => l.Join(It.IsAny(), It.IsAny(), It.IsAny>(), It.IsAny>())) + .Callback, Action>((a, b, c, d) => + { + Task.Run(() => + { + allowResponseCallback.Wait(); + allowResponseCallback.Reset(); + Schedule(() => d?.Invoke("Incorrect password")); + }); + }); + + Dependencies.CacheAs(mockLounge.Object); + } + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("create drawable", () => + { + Child = new PopoverContainer + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + searchTextBox = new SearchTextBox + { + HoldFocus = true, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Margin = new MarginPadding(50), + Width = 500, + Depth = float.MaxValue + }, + drawableRoom = new DrawableLoungeRoom(room) + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + }; + }); + } + + [Test] + public void TestFocusViaKeyboardCommit() + { + DrawableLoungeRoom.PasswordEntryPopover popover = null; + + AddAssert("search textbox has focus", () => checkFocus(searchTextBox)); + AddStep("click room twice", () => + { + InputManager.MoveMouseTo(drawableRoom); + InputManager.Click(MouseButton.Left); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("wait for popover", () => (popover = InputManager.ChildrenOfType().SingleOrDefault()) != null); + + AddAssert("textbox has focus", () => checkFocus(popover.ChildrenOfType().Single())); + + AddStep("enter password", () => popover.ChildrenOfType().Single().Text = "password"); + AddStep("commit via enter", () => InputManager.Key(Key.Enter)); + + AddAssert("popover has focus", () => checkFocus(popover)); + + AddStep("attempt another enter", () => InputManager.Key(Key.Enter)); + + AddAssert("popover still has focus", () => checkFocus(popover)); + + AddStep("unblock response", () => allowResponseCallback.Set()); + + AddUntilStep("wait for textbox refocus", () => checkFocus(popover.ChildrenOfType().Single())); + + AddStep("press escape", () => InputManager.Key(Key.Escape)); + AddStep("press escape", () => InputManager.Key(Key.Escape)); + + AddUntilStep("search textbox has focus", () => checkFocus(searchTextBox)); + } + + [Test] + public void TestFocusViaMouseCommit() + { + DrawableLoungeRoom.PasswordEntryPopover popover = null; + + AddAssert("search textbox has focus", () => checkFocus(searchTextBox)); + AddStep("click room twice", () => + { + InputManager.MoveMouseTo(drawableRoom); + InputManager.Click(MouseButton.Left); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("wait for popover", () => (popover = InputManager.ChildrenOfType().SingleOrDefault()) != null); + + AddAssert("textbox has focus", () => checkFocus(popover.ChildrenOfType().Single())); + + AddStep("enter password", () => popover.ChildrenOfType().Single().Text = "password"); + + AddStep("commit via click button", () => + { + var button = popover.ChildrenOfType().Single(); + InputManager.MoveMouseTo(button); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("popover has focus", () => checkFocus(popover)); + + AddStep("attempt another click", () => InputManager.Click(MouseButton.Left)); + + AddAssert("popover still has focus", () => checkFocus(popover)); + + AddStep("unblock response", () => allowResponseCallback.Set()); + + AddUntilStep("wait for textbox refocus", () => checkFocus(popover.ChildrenOfType().Single())); + + AddStep("click away", () => + { + InputManager.MoveMouseTo(searchTextBox); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("search textbox has focus", () => checkFocus(searchTextBox)); + } + + private bool checkFocus(Drawable expected) => + InputManager.FocusedDrawable == expected; + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs index a3a1cacb0d..512d206a06 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneGameplayChatDisplay.cs @@ -92,6 +92,18 @@ namespace osu.Game.Tests.Visual.Multiplayer assertChatFocused(true); } + [Test] + public void TestFocusLostOnBackKey() + { + setLocalUserPlaying(true); + + assertChatFocused(false); + AddStep("press tab", () => InputManager.Key(Key.Tab)); + assertChatFocused(true); + AddStep("press escape", () => InputManager.Key(Key.Escape)); + assertChatFocused(false); + } + [Test] public void TestFocusOnTabKeyWhenNotExpanded() { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs index 99b530c2a2..1bdf3c2750 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneLoungeRoomsContainer.cs @@ -5,6 +5,7 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; using osu.Framework.Testing; using osu.Game.Online.Rooms; using osu.Game.Rulesets.Catch; @@ -25,12 +26,18 @@ namespace osu.Game.Tests.Visual.Multiplayer [SetUp] public new void Setup() => Schedule(() => { - Child = container = new RoomsContainer + Child = new PopoverContainer { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, Anchor = Anchor.Centre, Origin = Anchor.Centre, Width = 0.5f, - SelectedRoom = { BindTarget = SelectedRoom } + + Child = container = new RoomsContainer + { + SelectedRoom = { BindTarget = SelectedRoom } + } }; }); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index 0b70703870..2bb77395ef 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -564,11 +564,18 @@ namespace osu.Game.Tests.Visual.Multiplayer } }); - AddRepeatStep("click spectate button", () => + AddUntilStep("wait for ready button to be enabled", () => readyButton.ChildrenOfType().Single().Enabled.Value); + + AddStep("click ready button", () => { - InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.MoveMouseTo(readyButton); InputManager.Click(MouseButton.Left); - }, 2); + }); + + AddUntilStep("wait for player to be ready", () => client.Room?.Users[0].State == MultiplayerUserState.Ready); + AddUntilStep("wait for ready button to be enabled", () => readyButton.ChildrenOfType().Single().Enabled.Value); + + AddStep("click start button", () => InputManager.Click(MouseButton.Left)); AddUntilStep("wait for player", () => Stack.CurrentScreen is Player); @@ -582,6 +589,8 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for results", () => Stack.CurrentScreen is ResultsScreen); } + private MultiplayerReadyButton readyButton => this.ChildrenOfType().Single(); + private void createRoom(Func room) { AddUntilStep("wait for lounge", () => multiplayerScreen.ChildrenOfType().SingleOrDefault()?.IsLoaded == true); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs index c4ebc13245..d1980b03c7 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs @@ -275,6 +275,68 @@ namespace osu.Game.Tests.Visual.Multiplayer var state = i; AddStep($"set state: {state}", () => Client.ChangeUserState(0, state)); } + + AddStep("set state: downloading", () => Client.ChangeUserBeatmapAvailability(0, BeatmapAvailability.Downloading(0))); + + AddStep("set state: locally available", () => Client.ChangeUserBeatmapAvailability(0, BeatmapAvailability.LocallyAvailable())); + } + + [Test] + public void TestModOverlap() + { + AddStep("add dummy mods", () => + { + Client.ChangeUserMods(new Mod[] + { + new OsuModNoFail(), + new OsuModDoubleTime() + }); + }); + + AddStep("add user with mods", () => + { + Client.AddUser(new User + { + Id = 0, + Username = "Baka", + RulesetsStatistics = new Dictionary + { + { + Ruleset.Value.ShortName, + new UserStatistics { GlobalRank = RNG.Next(1, 100000), } + } + }, + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }); + Client.ChangeUserMods(0, new Mod[] + { + new OsuModHardRock(), + new OsuModDoubleTime() + }); + }); + + AddStep("set 0 ready", () => Client.ChangeState(MultiplayerUserState.Ready)); + + AddStep("set 1 spectate", () => Client.ChangeUserState(0, MultiplayerUserState.Spectating)); + + // Have to set back to idle due to status priority. + AddStep("set 0 no map, 1 ready", () => + { + Client.ChangeState(MultiplayerUserState.Idle); + Client.ChangeBeatmapAvailability(BeatmapAvailability.NotDownloaded()); + Client.ChangeUserState(0, MultiplayerUserState.Ready); + }); + + AddStep("set 0 downloading", () => Client.ChangeBeatmapAvailability(BeatmapAvailability.Downloading(0))); + + AddStep("set 0 spectate", () => Client.ChangeUserState(0, MultiplayerUserState.Spectating)); + + AddStep("make both default", () => + { + Client.ChangeBeatmapAvailability(BeatmapAvailability.LocallyAvailable()); + Client.ChangeUserState(0, MultiplayerUserState.Idle); + Client.ChangeState(MultiplayerUserState.Idle); + }); } private void createNewParticipantsList() diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneStartupImport.cs b/osu.Game.Tests/Visual/Navigation/TestSceneStartupImport.cs new file mode 100644 index 0000000000..bd723eeed6 --- /dev/null +++ b/osu.Game.Tests/Visual/Navigation/TestSceneStartupImport.cs @@ -0,0 +1,31 @@ +// 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.Testing; +using osu.Game.Overlays.Notifications; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Visual.Navigation +{ + public class TestSceneStartupImport : OsuGameTestScene + { + private string importFilename; + + protected override TestOsuGame CreateTestGame() => new TestOsuGame(LocalStorage, API, new[] { importFilename }); + + public override void SetUpSteps() + { + AddStep("Prepare import beatmap", () => importFilename = TestResources.GetTestBeatmapForImport()); + + base.SetUpSteps(); + } + + [Test] + public void TestImportCreatedNotification() + { + AddUntilStep("Import notification was presented", () => Game.Notifications.ChildrenOfType().Count() == 1); + } + } +} diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapAvailability.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapAvailability.cs index fe94165777..6f9744ca73 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapAvailability.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapAvailability.cs @@ -3,6 +3,7 @@ using NUnit.Framework; using osu.Game.Beatmaps; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.BeatmapSet; namespace osu.Game.Tests.Visual.Online @@ -22,7 +23,7 @@ namespace osu.Game.Tests.Visual.Online { AddStep("set undownloadable beatmapset with link", () => container.BeatmapSet = new BeatmapSetInfo { - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Availability = new BeatmapSetOnlineAvailability { @@ -40,7 +41,7 @@ namespace osu.Game.Tests.Visual.Online { AddStep("set undownloadable beatmapset without link", () => container.BeatmapSet = new BeatmapSetInfo { - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Availability = new BeatmapSetOnlineAvailability { @@ -57,7 +58,7 @@ namespace osu.Game.Tests.Visual.Online { AddStep("set parts-removed beatmapset with link", () => container.BeatmapSet = new BeatmapSetInfo { - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Availability = new BeatmapSetOnlineAvailability { @@ -75,7 +76,7 @@ namespace osu.Game.Tests.Visual.Online { AddStep("set normal beatmapset", () => container.BeatmapSet = new BeatmapSetInfo { - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Availability = new BeatmapSetOnlineAvailability { diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs index 963809ebe1..7042f1e4fe 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapListingOverlay.cs @@ -7,14 +7,12 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; -using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapListing; -using osu.Game.Rulesets; using osu.Game.Scoring; using osu.Game.Users; using osuTK.Input; @@ -92,7 +90,7 @@ namespace osu.Game.Tests.Visual.Online { AddAssert("is visible", () => overlay.State.Value == Visibility.Visible); - AddStep("show many results", () => fetchFor(Enumerable.Repeat(CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet, 100).ToArray())); + AddStep("show many results", () => fetchFor(Enumerable.Repeat(CreateAPIBeatmapSet(Ruleset.Value), 10).ToArray())); AddUntilStep("placeholder hidden", () => !overlay.ChildrenOfType().Any(d => d.IsPresent)); @@ -114,7 +112,7 @@ namespace osu.Game.Tests.Visual.Online AddStep("fetch for 0 beatmaps", () => fetchFor()); AddUntilStep("placeholder shown", () => overlay.ChildrenOfType().SingleOrDefault()?.IsPresent == true); - AddStep("fetch for 1 beatmap", () => fetchFor(CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet)); + AddStep("fetch for 1 beatmap", () => fetchFor(CreateAPIBeatmapSet(Ruleset.Value))); AddUntilStep("placeholder hidden", () => !overlay.ChildrenOfType().Any(d => d.IsPresent)); AddStep("fetch for 0 beatmaps", () => fetchFor()); @@ -188,7 +186,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestUserWithoutSupporterUsesSupporterOnlyFiltersWithResults() { - AddStep("fetch for 1 beatmap", () => fetchFor(CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet)); + AddStep("fetch for 1 beatmap", () => fetchFor(CreateAPIBeatmapSet(Ruleset.Value))); AddStep("set dummy as non-supporter", () => ((DummyAPIAccess)API).LocalUser.Value.IsSupporter = false); // only Rank Achieved filter @@ -218,7 +216,7 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestUserWithSupporterUsesSupporterOnlyFiltersWithResults() { - AddStep("fetch for 1 beatmap", () => fetchFor(CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet)); + AddStep("fetch for 1 beatmap", () => fetchFor(CreateAPIBeatmapSet(Ruleset.Value))); AddStep("set dummy as supporter", () => ((DummyAPIAccess)API).LocalUser.Value.IsSupporter = true); // only Rank Achieved filter @@ -247,10 +245,10 @@ namespace osu.Game.Tests.Visual.Online private static int searchCount; - private void fetchFor(params BeatmapSetInfo[] beatmaps) + private void fetchFor(params APIBeatmapSet[] beatmaps) { setsForResponse.Clear(); - setsForResponse.AddRange(beatmaps.Select(b => new TestAPIBeatmapSet(b))); + setsForResponse.AddRange(beatmaps); // trigger arbitrary change for fetching. searchControl.Query.Value = $"search {searchCount++}"; @@ -286,17 +284,5 @@ namespace osu.Game.Tests.Visual.Online !overlay.ChildrenOfType().Any(d => d.IsPresent) && !overlay.ChildrenOfType().Any(d => d.IsPresent)); } - - private class TestAPIBeatmapSet : APIBeatmapSet - { - private readonly BeatmapSetInfo beatmapSet; - - public TestAPIBeatmapSet(BeatmapSetInfo beatmapSet) - { - this.beatmapSet = beatmapSet; - } - - public override BeatmapSetInfo ToBeatmapSet(RulesetStore rulesets) => beatmapSet; - } } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs index 453e26ef96..7f9b56e873 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs @@ -11,6 +11,7 @@ using osu.Game.Users; using System; using System.Collections.Generic; using System.Linq; +using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Tests.Visual.Online { @@ -63,7 +64,7 @@ namespace osu.Game.Tests.Visual.Online Id = 3, }, }, - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Preview = @"https://b.ppy.sh/preview/12345.mp3", PlayCount = 123, @@ -72,10 +73,10 @@ namespace osu.Game.Tests.Visual.Online Ranked = DateTime.Now, BPM = 111, HasVideo = true, + Ratings = Enumerable.Range(0, 11).ToArray(), HasStoryboard = true, Covers = new BeatmapSetOnlineCovers(), }, - Metrics = new BeatmapSetMetrics { Ratings = Enumerable.Range(0, 11).ToArray() }, Beatmaps = new List { new BeatmapInfo @@ -91,17 +92,17 @@ namespace osu.Game.Tests.Visual.Online OverallDifficulty = 4.5f, ApproachRate = 6, }, - OnlineInfo = new BeatmapOnlineInfo + OnlineInfo = new APIBeatmap { CircleCount = 111, SliderCount = 12, PlayCount = 222, PassCount = 21, - }, - Metrics = new BeatmapMetrics - { - Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), - Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), + FailTimes = new APIFailTimes + { + Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), + Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), + }, }, }, }, @@ -134,7 +135,7 @@ namespace osu.Game.Tests.Visual.Online Id = 3, }, }, - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Availability = new BeatmapSetOnlineAvailability { @@ -152,8 +153,8 @@ namespace osu.Game.Tests.Visual.Online Covers = new BeatmapSetOnlineCovers(), Language = new BeatmapSetOnlineLanguage { Id = 3, Name = "English" }, Genre = new BeatmapSetOnlineGenre { Id = 4, Name = "Rock" }, + Ratings = Enumerable.Range(0, 11).ToArray(), }, - Metrics = new BeatmapSetMetrics { Ratings = Enumerable.Range(0, 11).ToArray() }, Beatmaps = new List { new BeatmapInfo @@ -169,17 +170,17 @@ namespace osu.Game.Tests.Visual.Online OverallDifficulty = 7, ApproachRate = 6, }, - OnlineInfo = new BeatmapOnlineInfo + OnlineInfo = new APIBeatmap { CircleCount = 123, SliderCount = 45, PlayCount = 567, PassCount = 89, - }, - Metrics = new BeatmapMetrics - { - Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), - Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), + FailTimes = new APIFailTimes + { + Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), + Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), + }, }, }, }, @@ -203,12 +204,14 @@ namespace osu.Game.Tests.Visual.Online Version = ruleset.Name, Ruleset = ruleset, BaseDifficulty = new BeatmapDifficulty(), - OnlineInfo = new BeatmapOnlineInfo(), - Metrics = new BeatmapMetrics + OnlineInfo = new APIBeatmap { - Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), - Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), - }, + FailTimes = new APIFailTimes + { + Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), + Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), + }, + } }); } @@ -224,11 +227,11 @@ namespace osu.Game.Tests.Visual.Online Id = 3, } }, - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Covers = new BeatmapSetOnlineCovers(), + Ratings = Enumerable.Range(0, 11).ToArray(), }, - Metrics = new BeatmapSetMetrics { Ratings = Enumerable.Range(0, 11).ToArray() }, Beatmaps = beatmaps }); }); @@ -287,12 +290,14 @@ namespace osu.Game.Tests.Visual.Online { OverallDifficulty = 3.5f, }, - OnlineInfo = new BeatmapOnlineInfo(), - Metrics = new BeatmapMetrics + OnlineInfo = new APIBeatmap { - Fails = Enumerable.Range(1, 100).Select(j => j % 12 - 6).ToArray(), - Retries = Enumerable.Range(-2, 100).Select(j => j % 12 - 6).ToArray(), - }, + FailTimes = new APIFailTimes + { + Fails = Enumerable.Range(1, 100).Select(j => j % 12 - 6).ToArray(), + Retries = Enumerable.Range(-2, 100).Select(j => j % 12 - 6).ToArray(), + }, + } }); } @@ -309,14 +314,14 @@ namespace osu.Game.Tests.Visual.Online Id = 3, }, }, - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Preview = @"https://b.ppy.sh/preview/123.mp3", HasVideo = true, HasStoryboard = true, Covers = new BeatmapSetOnlineCovers(), + Ratings = Enumerable.Range(0, 11).ToArray(), }, - Metrics = new BeatmapSetMetrics { Ratings = Enumerable.Range(0, 11).ToArray() }, Beatmaps = beatmaps, }; } diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlayDetails.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlayDetails.cs index f7099b0615..d14f9f47d1 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlayDetails.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlayDetails.cs @@ -8,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapSet; using osu.Game.Screens.Select.Details; @@ -38,27 +39,30 @@ namespace osu.Game.Tests.Visual.Online var secondSet = createSet(); AddStep("set first set", () => details.BeatmapSet = firstSet); - AddAssert("ratings set", () => details.Ratings.Metrics == firstSet.Metrics); + AddAssert("ratings set", () => details.Ratings.Ratings == firstSet.Ratings); AddStep("set second set", () => details.BeatmapSet = secondSet); - AddAssert("ratings set", () => details.Ratings.Metrics == secondSet.Metrics); + AddAssert("ratings set", () => details.Ratings.Ratings == secondSet.Ratings); static BeatmapSetInfo createSet() => new BeatmapSetInfo { - Metrics = new BeatmapSetMetrics { Ratings = Enumerable.Range(0, 11).Select(_ => RNG.Next(10)).ToArray() }, Beatmaps = new List { new BeatmapInfo { - Metrics = new BeatmapMetrics + OnlineInfo = new APIBeatmap { - Fails = Enumerable.Range(1, 100).Select(_ => RNG.Next(10)).ToArray(), - Retries = Enumerable.Range(-2, 100).Select(_ => RNG.Next(10)).ToArray(), - }, + FailTimes = new APIFailTimes + { + Fails = Enumerable.Range(1, 100).Select(_ => RNG.Next(10)).ToArray(), + Retries = Enumerable.Range(-2, 100).Select(_ => RNG.Next(10)).ToArray(), + }, + } } }, - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { + Ratings = Enumerable.Range(0, 11).Select(_ => RNG.Next(10)).ToArray(), Status = BeatmapSetOnlineStatus.Ranked } }; diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs index fe8e33f783..b3b67fcbca 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlaySuccessRate.cs @@ -11,6 +11,7 @@ using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapSet; using osu.Game.Screens.Select.Details; @@ -59,17 +60,20 @@ namespace osu.Game.Tests.Visual.Online var secondBeatmap = createBeatmap(); AddStep("set first set", () => successRate.BeatmapInfo = firstBeatmap); - AddAssert("ratings set", () => successRate.Graph.Metrics == firstBeatmap.Metrics); + AddAssert("ratings set", () => successRate.Graph.FailTimes == firstBeatmap.FailTimes); AddStep("set second set", () => successRate.BeatmapInfo = secondBeatmap); - AddAssert("ratings set", () => successRate.Graph.Metrics == secondBeatmap.Metrics); + AddAssert("ratings set", () => successRate.Graph.FailTimes == secondBeatmap.FailTimes); static BeatmapInfo createBeatmap() => new BeatmapInfo { - Metrics = new BeatmapMetrics + OnlineInfo = new APIBeatmap { - Fails = Enumerable.Range(1, 100).Select(_ => RNG.Next(10)).ToArray(), - Retries = Enumerable.Range(-2, 100).Select(_ => RNG.Next(10)).ToArray(), + FailTimes = new APIFailTimes + { + Fails = Enumerable.Range(1, 100).Select(_ => RNG.Next(10)).ToArray(), + Retries = Enumerable.Range(-2, 100).Select(_ => RNG.Next(10)).ToArray(), + } } }; } @@ -79,13 +83,16 @@ namespace osu.Game.Tests.Visual.Online { AddStep("set beatmap", () => successRate.BeatmapInfo = new BeatmapInfo { - Metrics = new BeatmapMetrics + OnlineInfo = new APIBeatmap { - Fails = Enumerable.Range(1, 100).ToArray(), + FailTimes = new APIFailTimes + { + Fails = Enumerable.Range(1, 100).ToArray(), + } } }); - AddAssert("graph max values correct", - () => successRate.ChildrenOfType().All(graph => graph.MaxValue == 100)); + + AddAssert("graph max values correct", () => successRate.ChildrenOfType().All(graph => graph.MaxValue == 100)); } [Test] @@ -93,11 +100,13 @@ namespace osu.Game.Tests.Visual.Online { AddStep("set beatmap", () => successRate.BeatmapInfo = new BeatmapInfo { - Metrics = new BeatmapMetrics() + OnlineInfo = new APIBeatmap + { + FailTimes = new APIFailTimes(), + } }); - AddAssert("graph max values correct", - () => successRate.ChildrenOfType().All(graph => graph.MaxValue == 0)); + AddAssert("graph max values correct", () => successRate.ChildrenOfType().All(graph => graph.MaxValue == 0)); } private class GraphExposingSuccessRate : SuccessRate diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs index 9562b41363..ab4e1b4457 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net; using JetBrains.Annotations; using NUnit.Framework; using osu.Framework.Allocation; @@ -105,7 +106,7 @@ namespace osu.Game.Tests.Visual.Online } else { - getUser.TriggerFailure(new Exception()); + getUser.TriggerFailure(new WebException()); } return true; diff --git a/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs b/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs index 3fc894da0d..bb7fcc2fce 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs @@ -7,6 +7,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Online; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.BeatmapListing.Panels; using osu.Game.Rulesets.Osu; using osu.Game.Tests.Resources; @@ -74,7 +75,7 @@ namespace osu.Game.Tests.Visual.Online { ID = 1, OnlineBeatmapSetID = 241526, - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Availability = new BeatmapSetOnlineAvailability { diff --git a/osu.Game.Tests/Visual/Online/TestSceneDirectPanel.cs b/osu.Game.Tests/Visual/Online/TestSceneDirectPanel.cs index 722010ace2..6caca2a67c 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneDirectPanel.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneDirectPanel.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Audio; using osu.Game.Beatmaps; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.BeatmapListing.Panels; using osu.Game.Rulesets; using osu.Game.Users; @@ -31,7 +32,7 @@ namespace osu.Game.Tests.Visual.Online Id = 3, }, }, - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Availability = new BeatmapSetOnlineAvailability { @@ -86,7 +87,7 @@ namespace osu.Game.Tests.Visual.Online Id = 3, } }, - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { HasVideo = true, HasStoryboard = true, diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernames.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernames.cs index 1e9d62f379..b5d2d15392 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernames.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernames.cs @@ -3,11 +3,7 @@ using System; using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests; using osu.Game.Overlays.Profile.Header.Components; using osu.Game.Users; @@ -16,48 +12,50 @@ namespace osu.Game.Tests.Visual.Online [TestFixture] public class TestSceneUserProfilePreviousUsernames : OsuTestScene { - [Resolved] - private IAPIProvider api { get; set; } + private PreviousUsernames container; - private readonly Bindable user = new Bindable(); - - public TestSceneUserProfilePreviousUsernames() + [SetUp] + public void SetUp() => Schedule(() => { - Child = new PreviousUsernames + Child = container = new PreviousUsernames { Anchor = Anchor.Centre, Origin = Anchor.Centre, - User = { BindTarget = user }, }; + }); - User[] users = - { - new User { PreviousUsernames = new[] { "username1" } }, - new User { PreviousUsernames = new[] { "longusername", "longerusername" } }, - new User { PreviousUsernames = new[] { "test", "angelsim", "verylongusername" } }, - new User { PreviousUsernames = new[] { "ihavenoidea", "howcani", "makethistext", "anylonger" } }, - new User { PreviousUsernames = Array.Empty() }, - null - }; - - AddStep("single username", () => user.Value = users[0]); - AddStep("two usernames", () => user.Value = users[1]); - AddStep("three usernames", () => user.Value = users[2]); - AddStep("four usernames", () => user.Value = users[3]); - AddStep("no username", () => user.Value = users[4]); - AddStep("null user", () => user.Value = users[5]); - } - - protected override void LoadComplete() + [Test] + public void TestVisibility() { - base.LoadComplete(); + AddAssert("Is Hidden", () => container.Alpha == 0); - AddStep("online user (Angelsim)", () => - { - var request = new GetUserRequest(1777162); - request.Success += user => this.user.Value = user; - api.Queue(request); - }); + AddStep("1 username", () => container.User.Value = users[0]); + AddUntilStep("Is visible", () => container.Alpha == 1); + + AddStep("2 usernames", () => container.User.Value = users[1]); + AddUntilStep("Is visible", () => container.Alpha == 1); + + AddStep("3 usernames", () => container.User.Value = users[2]); + AddUntilStep("Is visible", () => container.Alpha == 1); + + AddStep("4 usernames", () => container.User.Value = users[3]); + AddUntilStep("Is visible", () => container.Alpha == 1); + + AddStep("No username", () => container.User.Value = users[4]); + AddUntilStep("Is hidden", () => container.Alpha == 0); + + AddStep("Null user", () => container.User.Value = users[5]); + AddUntilStep("Is hidden", () => container.Alpha == 0); } + + private static readonly User[] users = + { + new User { Id = 1, PreviousUsernames = new[] { "username1" } }, + new User { Id = 2, PreviousUsernames = new[] { "longusername", "longerusername" } }, + new User { Id = 3, PreviousUsernames = new[] { "test", "angelsim", "verylongusername" } }, + new User { Id = 4, PreviousUsernames = new[] { "ihavenoidea", "howcani", "makethistext", "anylonger" } }, + new User { Id = 5, PreviousUsernames = Array.Empty() }, + null + }; } } diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs index 4bcc887b9f..d948aebbbf 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs @@ -32,12 +32,14 @@ namespace osu.Game.Tests.Visual.Playlists private TestResultsScreen resultsScreen; private int currentScoreId; private bool requestComplete; + private int totalCount; [SetUp] public void Setup() => Schedule(() => { currentScoreId = 0; requestComplete = false; + totalCount = 0; bindHandler(); }); @@ -53,7 +55,6 @@ namespace osu.Game.Tests.Visual.Playlists }); createResults(() => userScore); - waitForDisplay(); AddAssert("user score selected", () => this.ChildrenOfType().Single(p => p.Score.OnlineScoreID == userScore.OnlineScoreID).State == PanelState.Expanded); } @@ -62,7 +63,6 @@ namespace osu.Game.Tests.Visual.Playlists public void TestShowNullUserScore() { createResults(); - waitForDisplay(); AddAssert("top score selected", () => this.ChildrenOfType().OrderByDescending(p => p.Score.TotalScore).First().State == PanelState.Expanded); } @@ -79,7 +79,6 @@ namespace osu.Game.Tests.Visual.Playlists }); createResults(() => userScore); - waitForDisplay(); AddAssert("more than 1 panel displayed", () => this.ChildrenOfType().Count() > 1); AddAssert("user score selected", () => this.ChildrenOfType().Single(p => p.Score.OnlineScoreID == userScore.OnlineScoreID).State == PanelState.Expanded); @@ -91,7 +90,6 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("bind delayed handler", () => bindHandler(true)); createResults(); - waitForDisplay(); AddAssert("top score selected", () => this.ChildrenOfType().OrderByDescending(p => p.Score.TotalScore).First().State == PanelState.Expanded); } @@ -100,7 +98,6 @@ namespace osu.Game.Tests.Visual.Playlists public void TestFetchWhenScrolledToTheRight() { createResults(); - waitForDisplay(); AddStep("bind delayed handler", () => bindHandler(true)); @@ -131,7 +128,6 @@ namespace osu.Game.Tests.Visual.Playlists }); createResults(() => userScore); - waitForDisplay(); AddStep("bind delayed handler", () => bindHandler(true)); @@ -161,13 +157,15 @@ namespace osu.Game.Tests.Visual.Playlists })); }); - AddUntilStep("wait for load", () => resultsScreen.ChildrenOfType().FirstOrDefault()?.AllPanelsVisible == true); + waitForDisplay(); } private void waitForDisplay() { - AddUntilStep("wait for request to complete", () => requestComplete); - AddUntilStep("wait for panels to be visible", () => resultsScreen.ChildrenOfType().FirstOrDefault()?.AllPanelsVisible == true); + AddUntilStep("wait for load to complete", () => + requestComplete + && resultsScreen.ScorePanelList.GetScorePanels().Count() == totalCount + && resultsScreen.ScorePanelList.AllPanelsVisible); AddWaitStep("wait for display", 5); } @@ -203,6 +201,7 @@ namespace osu.Game.Tests.Visual.Playlists triggerFail(s); else triggerSuccess(s, createUserResponse(userScore)); + break; case IndexPlaylistScoresRequest i: @@ -248,6 +247,8 @@ namespace osu.Game.Tests.Visual.Playlists } }; + totalCount++; + for (int i = 1; i <= scores_per_result; i++) { multiplayerUserScore.ScoresAround.Lower.Scores.Add(new MultiplayerScore @@ -285,6 +286,8 @@ namespace osu.Game.Tests.Visual.Playlists }, Statistics = userScore.Statistics }); + + totalCount += 2; } addCursor(multiplayerUserScore.ScoresAround.Lower); @@ -325,6 +328,8 @@ namespace osu.Game.Tests.Visual.Playlists { HitResult.Great, 300 } } }); + + totalCount++; } addCursor(result); diff --git a/osu.Game.Tests/Visual/Settings/TestSceneRestoreDefaultValueButton.cs b/osu.Game.Tests/Visual/Settings/TestSceneRestoreDefaultValueButton.cs new file mode 100644 index 0000000000..3eb7a77600 --- /dev/null +++ b/osu.Game.Tests/Visual/Settings/TestSceneRestoreDefaultValueButton.cs @@ -0,0 +1,63 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Tests.Visual.Settings +{ + public class TestSceneRestoreDefaultValueButton : OsuTestScene + { + [Resolved] + private OsuColour colours { get; set; } + + private float scale = 1; + + private readonly Bindable current = new Bindable + { + Default = default, + Value = 1, + }; + + [Test] + public void TestBasic() + { + RestoreDefaultValueButton restoreDefaultValueButton = null; + + AddStep("create button", () => Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.GreySeafoam + }, + restoreDefaultValueButton = new RestoreDefaultValueButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(scale), + Current = current, + } + } + }); + AddSliderStep("set scale", 1, 4, 1, scale => + { + this.scale = scale; + if (restoreDefaultValueButton != null) + restoreDefaultValueButton.Scale = new Vector2(scale); + }); + AddToggleStep("toggle default state", state => current.Value = state ? default : 1); + AddToggleStep("toggle disabled state", state => current.Disabled = state); + } + } +} diff --git a/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs b/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs index d9cce69ee3..83265e13ad 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs @@ -5,6 +5,9 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Bindables; using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Settings; using osu.Game.Overlays; @@ -29,9 +32,10 @@ namespace osu.Game.Tests.Visual.Settings Value = "test" } }; - - restoreDefaultValueButton = textBox.ChildrenOfType>().Single(); }); + AddUntilStep("wait for loaded", () => textBox.IsLoaded); + AddStep("retrieve restore default button", () => restoreDefaultValueButton = textBox.ChildrenOfType>().Single()); + AddAssert("restore button hidden", () => restoreDefaultValueButton.Alpha == 0); AddStep("change value from default", () => textBox.Current.Value = "non-default"); @@ -41,6 +45,48 @@ namespace osu.Game.Tests.Visual.Settings AddUntilStep("restore button hidden", () => restoreDefaultValueButton.Alpha == 0); } + [Test] + public void TestSetAndClearLabelText() + { + SettingsTextBox textBox = null; + RestoreDefaultValueButton restoreDefaultValueButton = null; + OsuTextBox control = null; + + AddStep("create settings item", () => + { + Child = textBox = new SettingsTextBox + { + Current = new Bindable + { + Default = "test", + Value = "test" + } + }; + }); + AddUntilStep("wait for loaded", () => textBox.IsLoaded); + AddStep("retrieve components", () => + { + restoreDefaultValueButton = textBox.ChildrenOfType>().Single(); + control = textBox.ChildrenOfType().Single(); + }); + + AddStep("set non-default value", () => restoreDefaultValueButton.Current.Value = "non-default"); + AddAssert("default value button centre aligned to control size", () => Precision.AlmostEquals(restoreDefaultValueButton.Parent.DrawHeight, control.DrawHeight, 1)); + + AddStep("set label", () => textBox.LabelText = "label text"); + AddAssert("default value button centre aligned to label size", () => + { + var label = textBox.ChildrenOfType().Single(spriteText => spriteText.Text == "label text"); + return Precision.AlmostEquals(restoreDefaultValueButton.Parent.DrawHeight, label.DrawHeight, 1); + }); + + AddStep("clear label", () => textBox.LabelText = default); + AddAssert("default value button centre aligned to control size", () => Precision.AlmostEquals(restoreDefaultValueButton.Parent.DrawHeight, control.DrawHeight, 1)); + + AddStep("set warning text", () => textBox.WarningText = "This is some very important warning text! Hopefully it doesn't break the alignment of the default value indicator..."); + AddAssert("default value button centre aligned to control size", () => Precision.AlmostEquals(restoreDefaultValueButton.Parent.DrawHeight, control.DrawHeight, 1)); + } + /// /// Ensures that the reset to default button uses the correct implementation of IsDefault to determine whether it should be shown or not. /// Values have been chosen so that after being set, Value != Default (but they are close enough that the difference is negligible compared to Precision). @@ -64,9 +110,9 @@ namespace osu.Game.Tests.Visual.Settings Precision = 0.1f, } }; - - restoreDefaultValueButton = sliderBar.ChildrenOfType>().Single(); }); + AddUntilStep("wait for loaded", () => sliderBar.IsLoaded); + AddStep("retrieve restore default button", () => restoreDefaultValueButton = sliderBar.ChildrenOfType>().Single()); AddAssert("restore button hidden", () => restoreDefaultValueButton.Alpha == 0); diff --git a/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs b/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs index 997eac709d..dc5b0e0d77 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneTabletSettings.cs @@ -3,6 +3,7 @@ using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input.Handlers.Tablet; @@ -21,6 +22,9 @@ namespace osu.Game.Tests.Visual.Settings private TestTabletHandler tabletHandler; private TabletSettings settings; + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple); + [SetUpSteps] public void SetUpSteps() { diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs index d5b4fb9a80..1125e16d91 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapDetails.cs @@ -6,6 +6,7 @@ using NUnit.Framework; using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Screens.Select; namespace osu.Game.Tests.Visual.SongSelect @@ -34,7 +35,10 @@ namespace osu.Game.Tests.Visual.SongSelect { BeatmapSet = new BeatmapSetInfo { - Metrics = new BeatmapSetMetrics { Ratings = Enumerable.Range(0, 11).ToArray() } + OnlineInfo = new APIBeatmapSet + { + Ratings = Enumerable.Range(0, 11).ToArray(), + } }, Version = "All Metrics", Metadata = new BeatmapMetadata @@ -50,11 +54,14 @@ namespace osu.Game.Tests.Visual.SongSelect ApproachRate = 3.5f, }, StarDifficulty = 5.3f, - Metrics = new BeatmapMetrics + OnlineInfo = new APIBeatmap { - Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), - Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), - }, + FailTimes = new APIFailTimes + { + Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), + Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), + }, + } }); } @@ -65,7 +72,10 @@ namespace osu.Game.Tests.Visual.SongSelect { BeatmapSet = new BeatmapSetInfo { - Metrics = new BeatmapSetMetrics { Ratings = Enumerable.Range(0, 11).ToArray() } + OnlineInfo = new APIBeatmapSet + { + Ratings = Enumerable.Range(0, 11).ToArray(), + } }, Version = "All Metrics", Metadata = new BeatmapMetadata @@ -80,11 +90,14 @@ namespace osu.Game.Tests.Visual.SongSelect ApproachRate = 3.5f, }, StarDifficulty = 5.3f, - Metrics = new BeatmapMetrics + OnlineInfo = new APIBeatmap { - Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), - Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), - }, + FailTimes = new APIFailTimes + { + Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), + Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), + }, + } }); } @@ -95,7 +108,10 @@ namespace osu.Game.Tests.Visual.SongSelect { BeatmapSet = new BeatmapSetInfo { - Metrics = new BeatmapSetMetrics { Ratings = Enumerable.Range(0, 11).ToArray() } + OnlineInfo = new APIBeatmapSet + { + Ratings = Enumerable.Range(0, 11).ToArray(), + } }, Version = "Only Ratings", Metadata = new BeatmapMetadata @@ -133,11 +149,14 @@ namespace osu.Game.Tests.Visual.SongSelect ApproachRate = 7, }, StarDifficulty = 2.91f, - Metrics = new BeatmapMetrics + OnlineInfo = new APIBeatmap { - Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), - Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), - }, + FailTimes = new APIFailTimes + { + Fails = Enumerable.Range(1, 100).Select(i => i % 12 - 6).ToArray(), + Retries = Enumerable.Range(-2, 100).Select(i => i % 12 - 6).ToArray(), + }, + } }); } diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs index a5b90e6655..0ae4e0c5dc 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneFilterControl.cs @@ -205,7 +205,7 @@ namespace osu.Game.Tests.Visual.SongSelect private void assertCollectionDropdownContains(string collectionName, bool shouldContain = true) => AddAssert($"collection dropdown {(shouldContain ? "contains" : "does not contain")} '{collectionName}'", // A bit of a roundabout way of going about this, see: https://github.com/ppy/osu-framework/issues/3871 + https://github.com/ppy/osu-framework/issues/3872 - () => shouldContain == (getCollectionDropdownItems().Any(i => i.ChildrenOfType().OfType().First().Text == collectionName))); + () => shouldContain == (getCollectionDropdownItems().Any(i => i.ChildrenOfType().OfType().First().Text == collectionName))); private IconButton getAddOrRemoveButton(int index) => getCollectionDropdownItems().ElementAt(index).ChildrenOfType().Single(); diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index 067f1cabb4..4811fc979e 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -142,6 +142,8 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("store selected beatmap", () => selected = Beatmap.Value); + AddUntilStep("wait for beatmaps to load", () => songSelect.Carousel.ChildrenOfType().Any()); + AddStep("select next and enter", () => { InputManager.MoveMouseTo(songSelect.Carousel.ChildrenOfType() @@ -599,10 +601,10 @@ namespace osu.Game.Tests.Visual.SongSelect }); FilterableDifficultyIcon difficultyIcon = null; - AddStep("Find an icon", () => + AddUntilStep("Find an icon", () => { - difficultyIcon = set.ChildrenOfType() - .First(icon => getDifficultyIconIndex(set, icon) != getCurrentBeatmapIndex()); + return (difficultyIcon = set.ChildrenOfType() + .FirstOrDefault(icon => getDifficultyIconIndex(set, icon) != getCurrentBeatmapIndex())) != null; }); AddStep("Click on a difficulty", () => @@ -765,10 +767,10 @@ namespace osu.Game.Tests.Visual.SongSelect }); FilterableGroupedDifficultyIcon groupIcon = null; - AddStep("Find group icon for different ruleset", () => + AddUntilStep("Find group icon for different ruleset", () => { - groupIcon = set.ChildrenOfType() - .First(icon => icon.Items.First().BeatmapInfo.Ruleset.ID == 3); + return (groupIcon = set.ChildrenOfType() + .FirstOrDefault(icon => icon.Items.First().BeatmapInfo.Ruleset.ID == 3)) != null; }); AddAssert("Check ruleset is osu!", () => Ruleset.Value.ID == 0); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs index 008d91f649..a9fe7ed7d8 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapListing; using osuTK; @@ -111,7 +112,7 @@ namespace osu.Game.Tests.Visual.UserInterface private static readonly BeatmapSetInfo beatmap_set = new BeatmapSetInfo { - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Covers = new BeatmapSetOnlineCovers { @@ -122,7 +123,7 @@ namespace osu.Game.Tests.Visual.UserInterface private static readonly BeatmapSetInfo no_cover_beatmap_set = new BeatmapSetInfo { - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Covers = new BeatmapSetOnlineCovers { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDashboardBeatmapListing.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDashboardBeatmapListing.cs index c51204eaba..6727c7560b 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDashboardBeatmapListing.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDashboardBeatmapListing.cs @@ -11,6 +11,7 @@ using osu.Game.Users; using System; using osu.Framework.Graphics.Shapes; using System.Collections.Generic; +using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Tests.Visual.UserInterface { @@ -69,7 +70,7 @@ namespace osu.Game.Tests.Visual.UserInterface Id = 100 } }, - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Covers = new BeatmapSetOnlineCovers { @@ -90,7 +91,7 @@ namespace osu.Game.Tests.Visual.UserInterface Id = 100 } }, - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Covers = new BeatmapSetOnlineCovers { @@ -115,7 +116,7 @@ namespace osu.Game.Tests.Visual.UserInterface Id = 100 } }, - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Covers = new BeatmapSetOnlineCovers { @@ -136,7 +137,7 @@ namespace osu.Game.Tests.Visual.UserInterface Id = 100 } }, - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Covers = new BeatmapSetOnlineCovers { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs index 189b143a35..9a75d3c309 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs @@ -163,7 +163,6 @@ namespace osu.Game.Tests.Visual.UserInterface }); AddUntilStep("wait for fetch", () => leaderboard.Scores != null); - AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineScoreID != scoreBeingDeleted.OnlineScoreID)); } @@ -171,6 +170,7 @@ namespace osu.Game.Tests.Visual.UserInterface public void TestDeleteViaDatabase() { AddStep("delete top score", () => scoreManager.Delete(importedScores[0])); + AddUntilStep("wait for fetch", () => leaderboard.Scores != null); AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineScoreID != importedScores[0].OnlineScoreID)); } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSliderBar.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSliderBar.cs index 393420e700..1b7f65f9a0 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSliderBar.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneLabelledSliderBar.cs @@ -1,11 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; +using System.Linq; using NUnit.Framework; +using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Overlays; namespace osu.Game.Tests.Visual.UserInterface { @@ -19,28 +23,62 @@ namespace osu.Game.Tests.Visual.UserInterface { AddStep("create component", () => { - LabelledSliderBar component; + FillFlowContainer flow; - Child = new Container + Child = flow = new FillFlowContainer { Anchor = Anchor.Centre, Origin = Anchor.Centre, Width = 500, AutoSizeAxes = Axes.Y, - Child = component = new LabelledSliderBar + Direction = FillDirection.Vertical, + Children = new Drawable[] { - Current = new BindableDouble(5) + new LabelledSliderBar { - MinValue = 0, - MaxValue = 10, - Precision = 1, - } - } + Current = new BindableDouble(5) + { + MinValue = 0, + MaxValue = 10, + Precision = 1, + }, + Label = "a sample component", + Description = hasDescription ? "this text describes the component" : string.Empty, + }, + }, }; - component.Label = "a sample component"; - component.Description = hasDescription ? "this text describes the component" : string.Empty; + foreach (var colour in Enum.GetValues(typeof(OverlayColourScheme)).OfType()) + { + flow.Add(new OverlayColourContainer(colour) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = new LabelledSliderBar + { + Current = new BindableDouble(5) + { + MinValue = 0, + MaxValue = 10, + Precision = 1, + }, + Label = "a sample component", + Description = hasDescription ? "this text describes the component" : string.Empty, + } + }); + } }); } + + private class OverlayColourContainer : Container + { + [Cached] + private OverlayColourProvider colourProvider; + + public OverlayColourContainer(OverlayColourScheme scheme) + { + colourProvider = new OverlayColourProvider(scheme); + } + } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuDropdown.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuDropdown.cs new file mode 100644 index 0000000000..9e77fcf675 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuDropdown.cs @@ -0,0 +1,20 @@ +// 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; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneOsuDropdown : ThemeComparisonTestScene + { + protected override Drawable CreateContent() => + new OsuEnumDropdown + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 150 + }; + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs index 756928d3ec..fc1866cdf3 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs @@ -1,80 +1,67 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; +using System.Linq; using NUnit.Framework; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; using osuTK; -using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneOsuTextBox : OsuTestScene + public class TestSceneOsuTextBox : ThemeComparisonTestScene { - private readonly OsuNumberBox numberBox; + private IEnumerable numberBoxes => this.ChildrenOfType(); - public TestSceneOsuTextBox() + protected override Drawable CreateContent() => new FillFlowContainer { - Child = new Container + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding(50f), + Spacing = new Vector2(0f, 50f), + Children = new[] { - Masking = true, - CornerRadius = 10f, - AutoSizeAxes = Axes.Both, - Padding = new MarginPadding(15f), - Children = new Drawable[] + new OsuTextBox { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.DarkSlateGray, - Alpha = 0.75f, - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Padding = new MarginPadding(50f), - Spacing = new Vector2(0f, 50f), - Children = new[] - { - new OsuTextBox - { - Width = 500f, - PlaceholderText = "Normal textbox", - }, - new OsuPasswordTextBox - { - Width = 500f, - PlaceholderText = "Password textbox", - }, - numberBox = new OsuNumberBox - { - Width = 500f, - PlaceholderText = "Number textbox" - } - } - } + RelativeSizeAxes = Axes.X, + PlaceholderText = "Normal textbox", + }, + new OsuPasswordTextBox + { + RelativeSizeAxes = Axes.X, + PlaceholderText = "Password textbox", + }, + new OsuNumberBox + { + RelativeSizeAxes = Axes.X, + PlaceholderText = "Number textbox" } - }; - } + } + }; [Test] public void TestNumberBox() { - clearTextbox(numberBox); - AddStep("enter numbers", () => numberBox.Text = "987654321"); - expectedValue(numberBox, "987654321"); + AddStep("create themed content", () => CreateThemedContent(OverlayColourScheme.Red)); - clearTextbox(numberBox); - AddStep("enter text + single number", () => numberBox.Text = "1 hello 2 world 3"); - expectedValue(numberBox, "123"); + clearTextboxes(numberBoxes); + AddStep("enter numbers", () => numberBoxes.ForEach(numberBox => numberBox.Text = "987654321")); + expectedValue(numberBoxes, "987654321"); - clearTextbox(numberBox); + clearTextboxes(numberBoxes); + AddStep("enter text + single number", () => numberBoxes.ForEach(numberBox => numberBox.Text = "1 hello 2 world 3")); + expectedValue(numberBoxes, "123"); + + clearTextboxes(numberBoxes); } - private void clearTextbox(OsuTextBox textBox) => AddStep("clear textbox", () => textBox.Text = null); - private void expectedValue(OsuTextBox textBox, string value) => AddAssert("expected textbox value", () => textBox.Text == value); + private void clearTextboxes(IEnumerable textBoxes) => AddStep("clear textbox", () => textBoxes.ForEach(textBox => textBox.Text = null)); + private void expectedValue(IEnumerable textBoxes, string value) => AddAssert("expected textbox value", () => textBoxes.All(textbox => textbox.Text == value)); } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneRoundedButton.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneRoundedButton.cs new file mode 100644 index 0000000000..9ccfba7c74 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneRoundedButton.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics.UserInterfaceV2; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneRoundedButton : OsuTestScene + { + [Test] + public void TestBasic() + { + RoundedButton button = null; + + AddStep("create button", () => Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.DarkGray + }, + button = new RoundedButton + { + Width = 400, + Text = "Test button", + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Action = () => { } + } + } + }); + + AddToggleStep("toggle disabled", disabled => button.Action = disabled ? (Action)null : () => { }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsCheckbox.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsCheckbox.cs new file mode 100644 index 0000000000..fb04c5bad0 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneSettingsCheckbox.cs @@ -0,0 +1,68 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Overlays; +using osu.Game.Overlays.Settings; +using osuTK; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneSettingsCheckbox : OsuTestScene + { + [TestCase] + public void TestCheckbox() + { + AddStep("create component", () => + { + FillFlowContainer flow; + + Child = flow = new FillFlowContainer + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 500, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(5), + Direction = FillDirection.Vertical, + Children = new Drawable[] + { + new SettingsCheckbox + { + LabelText = "a sample component", + }, + }, + }; + + foreach (var colour1 in Enum.GetValues(typeof(OverlayColourScheme)).OfType()) + { + flow.Add(new OverlayColourContainer(colour1) + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Child = new SettingsCheckbox + { + LabelText = "a sample component", + } + }); + } + }); + } + + private class OverlayColourContainer : Container + { + [Cached] + private OverlayColourProvider colourProvider; + + public OverlayColourContainer(OverlayColourScheme scheme) + { + colourProvider = new OverlayColourProvider(scheme); + } + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapSetCover.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapSetCover.cs index 4fef93e291..3ac3002713 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapSetCover.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapSetCover.cs @@ -13,6 +13,7 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; using osuTK; namespace osu.Game.Tests.Visual.UserInterface @@ -22,21 +23,21 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestLocal([Values] BeatmapSetCoverType coverType) { - AddStep("setup cover", () => Child = new UpdateableBeatmapSetCover(coverType) + AddStep("setup cover", () => Child = new UpdateableOnlineBeatmapSetCover(coverType) { BeatmapSet = CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet, RelativeSizeAxes = Axes.Both, Masking = true, }); - AddUntilStep("wait for load", () => this.ChildrenOfType().SingleOrDefault()?.IsLoaded ?? false); + AddUntilStep("wait for load", () => this.ChildrenOfType().SingleOrDefault()?.IsLoaded ?? false); } [Test] public void TestUnloadAndReload() { OsuScrollContainer scroll = null; - List covers = new List(); + List covers = new List(); AddStep("setup covers", () => { @@ -65,7 +66,7 @@ namespace osu.Game.Tests.Visual.UserInterface { var coverType = coverTypes[i % coverTypes.Count]; - var cover = new UpdateableBeatmapSetCover(coverType) + var cover = new UpdateableOnlineBeatmapSetCover(coverType) { BeatmapSet = setInfo, Height = 100, @@ -84,7 +85,7 @@ namespace osu.Game.Tests.Visual.UserInterface } }); - var loadedCovers = covers.Where(c => c.ChildrenOfType().SingleOrDefault()?.IsLoaded ?? false); + var loadedCovers = covers.Where(c => c.ChildrenOfType().SingleOrDefault()?.IsLoaded ?? false); AddUntilStep("some loaded", () => loadedCovers.Any()); AddStep("scroll to end", () => scroll.ScrollToEnd()); @@ -94,9 +95,9 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestSetNullBeatmapWhileLoading() { - TestUpdateableBeatmapSetCover updateableCover = null; + TestUpdateableOnlineBeatmapSetCover updateableCover = null; - AddStep("setup cover", () => Child = updateableCover = new TestUpdateableBeatmapSetCover + AddStep("setup cover", () => Child = updateableCover = new TestUpdateableOnlineBeatmapSetCover { BeatmapSet = CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet, RelativeSizeAxes = Axes.Both, @@ -111,10 +112,10 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestCoverChangeOnNewBeatmap() { - TestUpdateableBeatmapSetCover updateableCover = null; - BeatmapSetCover initialCover = null; + TestUpdateableOnlineBeatmapSetCover updateableCover = null; + OnlineBeatmapSetCover initialCover = null; - AddStep("setup cover", () => Child = updateableCover = new TestUpdateableBeatmapSetCover(0) + AddStep("setup cover", () => Child = updateableCover = new TestUpdateableOnlineBeatmapSetCover(0) { BeatmapSet = createBeatmapWithCover("https://assets.ppy.sh/beatmaps/1189904/covers/cover.jpg"), RelativeSizeAxes = Axes.Both, @@ -122,38 +123,38 @@ namespace osu.Game.Tests.Visual.UserInterface Alpha = 0.4f }); - AddUntilStep("cover loaded", () => updateableCover.ChildrenOfType().Any()); - AddStep("store initial cover", () => initialCover = updateableCover.ChildrenOfType().Single()); + AddUntilStep("cover loaded", () => updateableCover.ChildrenOfType().Any()); + AddStep("store initial cover", () => initialCover = updateableCover.ChildrenOfType().Single()); AddUntilStep("wait for fade complete", () => initialCover.Alpha == 1); AddStep("switch beatmap", () => updateableCover.BeatmapSet = createBeatmapWithCover("https://assets.ppy.sh/beatmaps/1079428/covers/cover.jpg")); - AddUntilStep("new cover loaded", () => updateableCover.ChildrenOfType().Except(new[] { initialCover }).Any()); + AddUntilStep("new cover loaded", () => updateableCover.ChildrenOfType().Except(new[] { initialCover }).Any()); } private static BeatmapSetInfo createBeatmapWithCover(string coverUrl) => new BeatmapSetInfo { - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Covers = new BeatmapSetOnlineCovers { Cover = coverUrl } } }; - private class TestUpdateableBeatmapSetCover : UpdateableBeatmapSetCover + private class TestUpdateableOnlineBeatmapSetCover : UpdateableOnlineBeatmapSetCover { private readonly int loadDelay; - public TestUpdateableBeatmapSetCover(int loadDelay = 10000) + public TestUpdateableOnlineBeatmapSetCover(int loadDelay = 10000) { this.loadDelay = loadDelay; } - protected override Drawable CreateDrawable(BeatmapSetInfo model) + protected override Drawable CreateDrawable(IBeatmapSetOnlineInfo model) { if (model == null) return null; - return new TestBeatmapSetCover(model, loadDelay) + return new TestOnlineBeatmapSetCover(model, loadDelay) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -163,11 +164,11 @@ namespace osu.Game.Tests.Visual.UserInterface } } - private class TestBeatmapSetCover : BeatmapSetCover + private class TestOnlineBeatmapSetCover : OnlineBeatmapSetCover { private readonly int loadDelay; - public TestBeatmapSetCover(BeatmapSetInfo set, int loadDelay) + public TestOnlineBeatmapSetCover(IBeatmapSetOnlineInfo set, int loadDelay) : base(set) { this.loadDelay = loadDelay; diff --git a/osu.Game.Tests/Visual/UserInterface/ThemeComparisonTestScene.cs b/osu.Game.Tests/Visual/UserInterface/ThemeComparisonTestScene.cs new file mode 100644 index 0000000000..db1c90f287 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/ThemeComparisonTestScene.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; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Overlays; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public abstract class ThemeComparisonTestScene : OsuGridTestScene + { + protected ThemeComparisonTestScene() + : base(1, 2) + { + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Cell(0, 0).AddRange(new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.GreySeafoam + }, + CreateContent() + }); + } + + protected void CreateThemedContent(OverlayColourScheme colourScheme) + { + var colourProvider = new OverlayColourProvider(colourScheme); + + Cell(0, 1).Clear(); + Cell(0, 1).Add(new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] + { + (typeof(OverlayColourProvider), colourProvider) + }, + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4 + }, + CreateContent() + } + }); + } + + protected abstract Drawable CreateContent(); + + [Test] + public void TestAllColourSchemes() + { + foreach (var scheme in Enum.GetValues(typeof(OverlayColourScheme)).Cast()) + AddStep($"set {scheme} scheme", () => CreateThemedContent(scheme)); + } + } +} diff --git a/osu.Game.Tournament.Tests/NonVisual/LadderInfoSerialisationTest.cs b/osu.Game.Tournament.Tests/NonVisual/LadderInfoSerialisationTest.cs new file mode 100644 index 0000000000..13cbcd3caf --- /dev/null +++ b/osu.Game.Tournament.Tests/NonVisual/LadderInfoSerialisationTest.cs @@ -0,0 +1,65 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; +using NUnit.Framework; +using osu.Game.Tournament.Models; + +namespace osu.Game.Tournament.Tests.NonVisual +{ + [TestFixture] + public class LadderInfoSerialisationTest + { + [Test] + public void TestDeserialise() + { + var ladder = createSampleLadder(); + string serialised = JsonConvert.SerializeObject(ladder); + + JsonConvert.DeserializeObject(serialised, new JsonPointConverter()); + } + + [Test] + public void TestSerialise() + { + var ladder = createSampleLadder(); + JsonConvert.SerializeObject(ladder); + } + + private static LadderInfo createSampleLadder() + { + var match = TournamentTestScene.CreateSampleMatch(); + + return new LadderInfo + { + PlayersPerTeam = { Value = 4 }, + Teams = + { + match.Team1.Value, + match.Team2.Value, + }, + Rounds = + { + new TournamentRound + { + Beatmaps = + { + new RoundBeatmap { BeatmapInfo = TournamentTestScene.CreateSampleBeatmapInfo() }, + new RoundBeatmap { BeatmapInfo = TournamentTestScene.CreateSampleBeatmapInfo() }, + } + } + }, + + Matches = + { + match, + }, + Progressions = + { + new TournamentProgression(1, 2), + new TournamentProgression(1, 3, true), + } + }; + } + } +} diff --git a/osu.Game.Tournament.Tests/NonVisual/TournamentHostTest.cs b/osu.Game.Tournament.Tests/NonVisual/TournamentHostTest.cs index b14684200f..319a768e65 100644 --- a/osu.Game.Tournament.Tests/NonVisual/TournamentHostTest.cs +++ b/osu.Game.Tournament.Tests/NonVisual/TournamentHostTest.cs @@ -14,7 +14,7 @@ namespace osu.Game.Tournament.Tests.NonVisual public static TournamentGameBase LoadTournament(GameHost host, TournamentGameBase tournament = null) { tournament ??= new TournamentGameBase(); - Task.Run(() => host.Run(tournament)) + Task.Factory.StartNew(() => host.Run(tournament), TaskCreationOptions.LongRunning) .ContinueWith(t => Assert.Fail($"Host threw exception {t.Exception}"), TaskContinuationOptions.OnlyOnFaulted); WaitForOrAssert(() => tournament.IsLoaded, @"osu! failed to start in a reasonable amount of time"); return tournament; diff --git a/osu.Game.Tournament.Tests/TournamentTestScene.cs b/osu.Game.Tournament.Tests/TournamentTestScene.cs index 93e1e018a5..ce9fd91ff1 100644 --- a/osu.Game.Tournament.Tests/TournamentTestScene.cs +++ b/osu.Game.Tournament.Tests/TournamentTestScene.cs @@ -8,6 +8,7 @@ using osu.Framework.Platform; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; using osu.Game.Tests.Visual; using osu.Game.Tournament.IO; @@ -152,7 +153,16 @@ namespace osu.Game.Tournament.Tests }; public static BeatmapInfo CreateSampleBeatmapInfo() => - new BeatmapInfo { Metadata = new BeatmapMetadata { Title = "Test Title", Artist = "Test Artist", ID = RNG.Next(0, 1000000) } }; + new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Title = "Test Title", + Artist = "Test Artist", + ID = RNG.Next(0, 1000000) + }, + OnlineInfo = new APIBeatmap(), + }; protected override ITestSceneTestRunner CreateRunner() => new TournamentTestSceneTestRunner(); diff --git a/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs index 0e5a66e7fe..be29566e07 100644 --- a/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs +++ b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs @@ -10,7 +10,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Textures; -using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; @@ -21,7 +20,8 @@ namespace osu.Game.Tournament.Components { public class TournamentBeatmapPanel : CompositeDrawable { - public readonly BeatmapInfo BeatmapInfo; + public readonly IBeatmapInfo BeatmapInfo; + private readonly string mod; private const float horizontal_padding = 10; @@ -32,12 +32,13 @@ namespace osu.Game.Tournament.Components private readonly Bindable currentMatch = new Bindable(); private Box flash; - public TournamentBeatmapPanel(BeatmapInfo beatmapInfo, string mod = null) + public TournamentBeatmapPanel(IBeatmapInfo beatmapInfo, string mod = null) { if (beatmapInfo == null) throw new ArgumentNullException(nameof(beatmapInfo)); BeatmapInfo = beatmapInfo; this.mod = mod; + Width = 400; Height = HEIGHT; } @@ -57,11 +58,11 @@ namespace osu.Game.Tournament.Components RelativeSizeAxes = Axes.Both, Colour = Color4.Black, }, - new UpdateableBeatmapSetCover + new UpdateableOnlineBeatmapSetCover { RelativeSizeAxes = Axes.Both, Colour = OsuColour.Gray(0.5f), - BeatmapSet = BeatmapInfo.BeatmapSet, + BeatmapSet = BeatmapInfo.BeatmapSet as IBeatmapSetOnlineInfo, }, new FillFlowContainer { @@ -74,9 +75,7 @@ namespace osu.Game.Tournament.Components { new TournamentSpriteText { - Text = new RomanisableString( - $"{BeatmapInfo.Metadata.ArtistUnicode ?? BeatmapInfo.Metadata.Artist} - {BeatmapInfo.Metadata.TitleUnicode ?? BeatmapInfo.Metadata.Title}", - $"{BeatmapInfo.Metadata.Artist} - {BeatmapInfo.Metadata.Title}"), + Text = BeatmapInfo.GetDisplayTitleRomanisable(false), Font = OsuFont.Torus.With(weight: FontWeight.Bold), }, new FillFlowContainer @@ -93,7 +92,7 @@ namespace osu.Game.Tournament.Components }, new TournamentSpriteText { - Text = BeatmapInfo.Metadata.AuthorString, + Text = BeatmapInfo.Metadata?.Author, Padding = new MarginPadding { Right = 20 }, Font = OsuFont.Torus.With(weight: FontWeight.Bold, size: 14) }, @@ -105,7 +104,7 @@ namespace osu.Game.Tournament.Components }, new TournamentSpriteText { - Text = BeatmapInfo.Version, + Text = BeatmapInfo.DifficultyName, Font = OsuFont.Torus.With(weight: FontWeight.Bold, size: 14) }, } @@ -149,7 +148,7 @@ namespace osu.Game.Tournament.Components private void updateState() { - var found = currentMatch.Value.PicksBans.FirstOrDefault(p => p.BeatmapID == BeatmapInfo.OnlineBeatmapID); + var found = currentMatch.Value.PicksBans.FirstOrDefault(p => p.BeatmapID == BeatmapInfo.OnlineID); bool doFlash = found != choice; choice = found; diff --git a/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs b/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs index 994dee4da0..77101e4023 100644 --- a/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs +++ b/osu.Game.Tournament/Screens/Gameplay/Components/TournamentMatchScoreDisplay.cs @@ -127,7 +127,7 @@ namespace osu.Game.Tournament.Screens.Gameplay.Components score2Text.X = Math.Max(5 + score2Text.DrawWidth / 2, score2Bar.DrawWidth); } - private class MatchScoreCounter : ScoreCounter + private class MatchScoreCounter : CommaSeparatedScoreCounter { private OsuSpriteText displayedSpriteText; diff --git a/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs b/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs index 1e3c550323..5f6546c303 100644 --- a/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs +++ b/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs @@ -147,11 +147,11 @@ namespace osu.Game.Tournament.Screens.MapPool if (map != null) { - if (e.Button == MouseButton.Left && map.BeatmapInfo.OnlineBeatmapID != null) - addForBeatmap(map.BeatmapInfo.OnlineBeatmapID.Value); + if (e.Button == MouseButton.Left && map.BeatmapInfo.OnlineID > 0) + addForBeatmap(map.BeatmapInfo.OnlineID); else { - var existing = CurrentMatch.Value.PicksBans.FirstOrDefault(p => p.BeatmapID == map.BeatmapInfo.OnlineBeatmapID); + var existing = CurrentMatch.Value.PicksBans.FirstOrDefault(p => p.BeatmapID == map.BeatmapInfo.OnlineID); if (existing != null) { diff --git a/osu.Game.Tournament/TournamentGame.cs b/osu.Game.Tournament/TournamentGame.cs index 7a43fee013..f3927bb852 100644 --- a/osu.Game.Tournament/TournamentGame.cs +++ b/osu.Game.Tournament/TournamentGame.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Handlers.Mouse; +using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Graphics; using osu.Game.Graphics.Cursor; @@ -60,72 +61,89 @@ namespace osu.Game.Tournament loadingSpinner.Show(); - BracketLoadTask.ContinueWith(_ => LoadComponentsAsync(new[] + BracketLoadTask.ContinueWith(t => { - new Container + if (t.IsFaulted) { - CornerRadius = 10, - Depth = float.MinValue, - Position = new Vector2(5), - Masking = true, - AutoSizeAxes = Axes.Both, - Anchor = Anchor.BottomRight, - Origin = Anchor.BottomRight, - Children = new Drawable[] + Schedule(() => { - new Box - { - Colour = OsuColour.Gray(0.2f), - RelativeSizeAxes = Axes.Both, - }, - new TourneyButton - { - Text = "Save Changes", - Width = 140, - Height = 50, - Padding = new MarginPadding - { - Top = 10, - Left = 10, - }, - Margin = new MarginPadding - { - Right = 10, - Bottom = 10, - }, - Action = SaveChanges, - }, - } - }, - heightWarning = new WarningBox("Please make the window wider") - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Margin = new MarginPadding(20), - }, - new OsuContextMenuContainer - { - RelativeSizeAxes = Axes.Both, - Child = new TournamentSceneManager() + loadingSpinner.Hide(); + loadingSpinner.Expire(); + + Logger.Error(t.Exception, "Couldn't load bracket with error"); + Add(new WarningBox("Your bracket.json file could not be parsed. Please check runtime.log for more details.")); + }); + + return; } - }, drawables => - { - loadingSpinner.Hide(); - loadingSpinner.Expire(); - AddRange(drawables); - - windowSize.BindValueChanged(size => ScheduleAfterChildren(() => + LoadComponentsAsync(new[] { - var minWidth = (int)(size.NewValue.Height / 768f * TournamentSceneManager.REQUIRED_WIDTH) - 1; - heightWarning.Alpha = size.NewValue.Width < minWidth ? 1 : 0; - }), true); - - windowMode.BindValueChanged(mode => ScheduleAfterChildren(() => + new Container + { + CornerRadius = 10, + Depth = float.MinValue, + Position = new Vector2(5), + Masking = true, + AutoSizeAxes = Axes.Both, + Anchor = Anchor.BottomRight, + Origin = Anchor.BottomRight, + Children = new Drawable[] + { + new Box + { + Colour = OsuColour.Gray(0.2f), + RelativeSizeAxes = Axes.Both, + }, + new TourneyButton + { + Text = "Save Changes", + Width = 140, + Height = 50, + Padding = new MarginPadding + { + Top = 10, + Left = 10, + }, + Margin = new MarginPadding + { + Right = 10, + Bottom = 10, + }, + Action = SaveChanges, + }, + } + }, + heightWarning = new WarningBox("Please make the window wider") + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Margin = new MarginPadding(20), + }, + new OsuContextMenuContainer + { + RelativeSizeAxes = Axes.Both, + Child = new TournamentSceneManager() + } + }, drawables => { - windowMode.Value = WindowMode.Windowed; - }), true); - })); + loadingSpinner.Hide(); + loadingSpinner.Expire(); + + AddRange(drawables); + + windowSize.BindValueChanged(size => ScheduleAfterChildren(() => + { + var minWidth = (int)(size.NewValue.Height / 768f * TournamentSceneManager.REQUIRED_WIDTH) - 1; + heightWarning.Alpha = size.NewValue.Width < minWidth ? 1 : 0; + }), true); + + windowMode.BindValueChanged(mode => ScheduleAfterChildren(() => + { + windowMode.Value = WindowMode.Windowed; + }), true); + }); + }); } } } diff --git a/osu.Game.Tournament/TournamentGameBase.cs b/osu.Game.Tournament/TournamentGameBase.cs index bdf7269c83..a6b0fa5cfc 100644 --- a/osu.Game.Tournament/TournamentGameBase.cs +++ b/osu.Game.Tournament/TournamentGameBase.cs @@ -58,75 +58,83 @@ namespace osu.Game.Tournament private void readBracket() { - if (storage.Exists(bracket_filename)) + try { - using (Stream stream = storage.GetStream(bracket_filename, FileAccess.Read, FileMode.Open)) - using (var sr = new StreamReader(stream)) - ladder = JsonConvert.DeserializeObject(sr.ReadToEnd(), new JsonPointConverter()); - } - - ladder ??= new LadderInfo(); - - ladder.Ruleset.Value = RulesetStore.GetRuleset(ladder.Ruleset.Value?.ShortName) - ?? RulesetStore.AvailableRulesets.First(); - - bool addedInfo = false; - - // assign teams - foreach (var match in ladder.Matches) - { - match.Team1.Value = ladder.Teams.FirstOrDefault(t => t.Acronym.Value == match.Team1Acronym); - match.Team2.Value = ladder.Teams.FirstOrDefault(t => t.Acronym.Value == match.Team2Acronym); - - foreach (var conditional in match.ConditionalMatches) + if (storage.Exists(bracket_filename)) { - conditional.Team1.Value = ladder.Teams.FirstOrDefault(t => t.Acronym.Value == conditional.Team1Acronym); - conditional.Team2.Value = ladder.Teams.FirstOrDefault(t => t.Acronym.Value == conditional.Team2Acronym); - conditional.Round.Value = match.Round.Value; + using (Stream stream = storage.GetStream(bracket_filename, FileAccess.Read, FileMode.Open)) + using (var sr = new StreamReader(stream)) + ladder = JsonConvert.DeserializeObject(sr.ReadToEnd(), new JsonPointConverter()); } - } - // assign progressions - foreach (var pair in ladder.Progressions) - { - var src = ladder.Matches.FirstOrDefault(p => p.ID == pair.SourceID); - var dest = ladder.Matches.FirstOrDefault(p => p.ID == pair.TargetID); + ladder ??= new LadderInfo(); - if (src == null) - continue; + ladder.Ruleset.Value = RulesetStore.GetRuleset(ladder.Ruleset.Value?.ShortName) + ?? RulesetStore.AvailableRulesets.First(); - if (dest != null) + bool addedInfo = false; + + // assign teams + foreach (var match in ladder.Matches) { - if (pair.Losers) - src.LosersProgression.Value = dest; - else - src.Progression.Value = dest; - } - } + match.Team1.Value = ladder.Teams.FirstOrDefault(t => t.Acronym.Value == match.Team1Acronym); + match.Team2.Value = ladder.Teams.FirstOrDefault(t => t.Acronym.Value == match.Team2Acronym); - // link matches to rounds - foreach (var round in ladder.Rounds) - { - foreach (var id in round.Matches) - { - var found = ladder.Matches.FirstOrDefault(p => p.ID == id); - - if (found != null) + foreach (var conditional in match.ConditionalMatches) { - found.Round.Value = round; - if (round.StartDate.Value > found.Date.Value) - found.Date.Value = round.StartDate.Value; + conditional.Team1.Value = ladder.Teams.FirstOrDefault(t => t.Acronym.Value == conditional.Team1Acronym); + conditional.Team2.Value = ladder.Teams.FirstOrDefault(t => t.Acronym.Value == conditional.Team2Acronym); + conditional.Round.Value = match.Round.Value; } } + + // assign progressions + foreach (var pair in ladder.Progressions) + { + var src = ladder.Matches.FirstOrDefault(p => p.ID == pair.SourceID); + var dest = ladder.Matches.FirstOrDefault(p => p.ID == pair.TargetID); + + if (src == null) + continue; + + if (dest != null) + { + if (pair.Losers) + src.LosersProgression.Value = dest; + else + src.Progression.Value = dest; + } + } + + // link matches to rounds + foreach (var round in ladder.Rounds) + { + foreach (var id in round.Matches) + { + var found = ladder.Matches.FirstOrDefault(p => p.ID == id); + + if (found != null) + { + found.Round.Value = round; + if (round.StartDate.Value > found.Date.Value) + found.Date.Value = round.StartDate.Value; + } + } + } + + addedInfo |= addPlayers(); + addedInfo |= addBeatmaps(); + + if (addedInfo) + SaveChanges(); + + ladder.CurrentMatch.Value = ladder.Matches.FirstOrDefault(p => p.Current.Value); + } + catch (Exception e) + { + taskCompletionSource.SetException(e); + return; } - - addedInfo |= addPlayers(); - addedInfo |= addBeatmaps(); - - if (addedInfo) - SaveChanges(); - - ladder.CurrentMatch.Value = ladder.Matches.FirstOrDefault(p => p.Current.Value); Schedule(() => { diff --git a/osu.Game.Tournament/TournamentSceneManager.cs b/osu.Game.Tournament/TournamentSceneManager.cs index ced1a8ec72..914d1163ad 100644 --- a/osu.Game.Tournament/TournamentSceneManager.cs +++ b/osu.Game.Tournament/TournamentSceneManager.cs @@ -237,7 +237,7 @@ namespace osu.Game.Tournament { Type = type; BackgroundColour = OsuColour.Gray(0.2f); - Action = () => RequestSelection(type); + Action = () => RequestSelection?.Invoke(type); RelativeSizeAxes = Axes.X; } diff --git a/osu.Game/Audio/Effects/AudioFilter.cs b/osu.Game/Audio/Effects/AudioFilter.cs index ee48bdd7d9..d2a39e9db7 100644 --- a/osu.Game/Audio/Effects/AudioFilter.cs +++ b/osu.Game/Audio/Effects/AudioFilter.cs @@ -4,7 +4,6 @@ using System.Diagnostics; using ManagedBass.Fx; using osu.Framework.Audio.Mixing; -using osu.Framework.Bindables; using osu.Framework.Graphics; namespace osu.Game.Audio.Effects @@ -21,10 +20,25 @@ namespace osu.Game.Audio.Effects private readonly BQFParameters filter; private readonly BQFType type; + private bool isAttached; + + private int cutoff; + /// - /// The current cutoff of this filter. + /// The cutoff frequency of this filter. /// - public BindableNumber Cutoff { get; } + public int Cutoff + { + get => cutoff; + set + { + if (value == cutoff) + return; + + cutoff = value; + updateFilter(cutoff); + } + } /// /// A Component that implements a BASS FX BiQuad Filter Effect. @@ -36,102 +50,96 @@ namespace osu.Game.Audio.Effects this.mixer = mixer; this.type = type; - int initialCutoff; - - switch (type) - { - case BQFType.HighPass: - initialCutoff = 1; - break; - - case BQFType.LowPass: - initialCutoff = MAX_LOWPASS_CUTOFF; - break; - - default: - initialCutoff = 500; // A default that should ensure audio remains audible for other filters. - break; - } - - Cutoff = new BindableNumber(initialCutoff) - { - MinValue = 1, - MaxValue = MAX_LOWPASS_CUTOFF - }; - filter = new BQFParameters { lFilter = type, - fCenter = initialCutoff, fBandwidth = 0, - fQ = 0.7f // This allows fCenter to go up to 22049hz (nyquist - 1hz) without overflowing and causing weird filter behaviour (see: https://www.un4seen.com/forum/?topic=19542.0) + // This allows fCenter to go up to 22049hz (nyquist - 1hz) without overflowing and causing weird filter behaviour (see: https://www.un4seen.com/forum/?topic=19542.0) + fQ = 0.7f }; - // Don't start attached if this is low-pass or high-pass filter (as they have special auto-attach/detach logic) - if (type != BQFType.LowPass && type != BQFType.HighPass) - attachFilter(); - - Cutoff.ValueChanged += updateFilter; + Cutoff = getInitialCutoff(type); } - private void attachFilter() + private int getInitialCutoff(BQFType type) { - Debug.Assert(!mixer.Effects.Contains(filter)); - mixer.Effects.Add(filter); - } - - private void detachFilter() - { - Debug.Assert(mixer.Effects.Contains(filter)); - mixer.Effects.Remove(filter); - } - - private void updateFilter(ValueChangedEvent cutoff) - { - // Workaround for weird behaviour when rapidly setting fCenter of a low-pass filter to nyquist - 1hz. - if (type == BQFType.LowPass) + switch (type) { - if (cutoff.NewValue >= MAX_LOWPASS_CUTOFF) - { - detachFilter(); - return; - } + case BQFType.HighPass: + return 1; - if (cutoff.OldValue >= MAX_LOWPASS_CUTOFF && cutoff.NewValue < MAX_LOWPASS_CUTOFF) - attachFilter(); + case BQFType.LowPass: + return MAX_LOWPASS_CUTOFF; + + default: + return 500; // A default that should ensure audio remains audible for other filters. + } + } + + private void updateFilter(int newValue) + { + switch (type) + { + case BQFType.LowPass: + // Workaround for weird behaviour when rapidly setting fCenter of a low-pass filter to nyquist - 1hz. + if (newValue >= MAX_LOWPASS_CUTOFF) + { + ensureDetached(); + return; + } + + break; + + // Workaround for weird behaviour when rapidly setting fCenter of a high-pass filter to 1hz. + case BQFType.HighPass: + if (newValue <= 1) + { + ensureDetached(); + return; + } + + break; } - // Workaround for weird behaviour when rapidly setting fCenter of a high-pass filter to 1hz. - if (type == BQFType.HighPass) - { - if (cutoff.NewValue <= 1) - { - detachFilter(); - return; - } - - if (cutoff.OldValue <= 1 && cutoff.NewValue > 1) - attachFilter(); - } + ensureAttached(); var filterIndex = mixer.Effects.IndexOf(filter); + if (filterIndex < 0) return; if (mixer.Effects[filterIndex] is BQFParameters existingFilter) { - existingFilter.fCenter = cutoff.NewValue; + existingFilter.fCenter = newValue; // required to update effect with new parameters. mixer.Effects[filterIndex] = existingFilter; } } + private void ensureAttached() + { + if (isAttached) + return; + + Debug.Assert(!mixer.Effects.Contains(filter)); + mixer.Effects.Add(filter); + isAttached = true; + } + + private void ensureDetached() + { + if (!isAttached) + return; + + Debug.Assert(mixer.Effects.Contains(filter)); + mixer.Effects.Remove(filter); + isAttached = false; + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - - if (mixer.Effects.Contains(filter)) - detachFilter(); + ensureDetached(); } } } diff --git a/osu.Game/Audio/Effects/ITransformableFilter.cs b/osu.Game/Audio/Effects/ITransformableFilter.cs index e4de4cf8ff..fb6a924f68 100644 --- a/osu.Game/Audio/Effects/ITransformableFilter.cs +++ b/osu.Game/Audio/Effects/ITransformableFilter.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 osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Transforms; @@ -12,7 +11,7 @@ namespace osu.Game.Audio.Effects /// /// The filter cutoff. /// - BindableNumber Cutoff { get; } + int Cutoff { get; set; } } public static class FilterableAudioComponentExtensions @@ -40,7 +39,7 @@ namespace osu.Game.Audio.Effects public static TransformSequence CutoffTo(this T component, int newCutoff, double duration, TEasing easing) where T : class, ITransformableFilter, IDrawable where TEasing : IEasingFunction - => component.TransformBindableTo(component.Cutoff, newCutoff, duration, easing); + => component.TransformTo(nameof(component.Cutoff), newCutoff, duration, easing); /// /// Smoothly adjusts filter cutoff over time. @@ -49,6 +48,6 @@ namespace osu.Game.Audio.Effects public static TransformSequence CutoffTo(this TransformSequence sequence, int newCutoff, double duration, TEasing easing) where T : class, ITransformableFilter, IDrawable where TEasing : IEasingFunction - => sequence.Append(o => o.TransformBindableTo(o.Cutoff, newCutoff, duration, easing)); + => sequence.Append(o => o.TransformTo(nameof(o.Cutoff), newCutoff, duration, easing)); } } diff --git a/osu.Game/Audio/PreviewTrackManager.cs b/osu.Game/Audio/PreviewTrackManager.cs index 1de9e1561f..ca63add31d 100644 --- a/osu.Game/Audio/PreviewTrackManager.cs +++ b/osu.Game/Audio/PreviewTrackManager.cs @@ -43,11 +43,11 @@ namespace osu.Game.Audio } /// - /// Retrieves a for a . + /// Retrieves a for a . /// - /// The to retrieve the preview track for. + /// The to retrieve the preview track for. /// The playable . - public PreviewTrack Get(BeatmapSetInfo beatmapSetInfo) + public PreviewTrack Get(IBeatmapSetInfo beatmapSetInfo) { var track = CreatePreviewTrack(beatmapSetInfo, trackStore); @@ -91,7 +91,7 @@ namespace osu.Game.Audio /// /// Creates the . /// - protected virtual TrackManagerPreviewTrack CreatePreviewTrack(BeatmapSetInfo beatmapSetInfo, ITrackStore trackStore) => + protected virtual TrackManagerPreviewTrack CreatePreviewTrack(IBeatmapSetInfo beatmapSetInfo, ITrackStore trackStore) => new TrackManagerPreviewTrack(beatmapSetInfo, trackStore); public class TrackManagerPreviewTrack : PreviewTrack @@ -99,10 +99,10 @@ namespace osu.Game.Audio [Resolved(canBeNull: true)] public IPreviewTrackOwner Owner { get; private set; } - private readonly BeatmapSetInfo beatmapSetInfo; + private readonly IBeatmapSetInfo beatmapSetInfo; private readonly ITrackStore trackManager; - public TrackManagerPreviewTrack(BeatmapSetInfo beatmapSetInfo, ITrackStore trackManager) + public TrackManagerPreviewTrack(IBeatmapSetInfo beatmapSetInfo, ITrackStore trackManager) { this.beatmapSetInfo = beatmapSetInfo; this.trackManager = trackManager; @@ -114,7 +114,7 @@ namespace osu.Game.Audio Logger.Log($"A {nameof(PreviewTrack)} was created without a containing {nameof(IPreviewTrackOwner)}. An owner should be added for correct behaviour."); } - protected override Track GetTrack() => trackManager.Get($"https://b.ppy.sh/preview/{beatmapSetInfo?.OnlineBeatmapSetID}.mp3"); + protected override Track GetTrack() => trackManager.Get($"https://b.ppy.sh/preview/{beatmapSetInfo.OnlineID}.mp3"); } private class PreviewTrackStore : AudioCollectionManager, ITrackStore diff --git a/osu.Game/Beatmaps/BeatmapMetrics.cs b/osu.Game/Beatmaps/APIFailTimes.cs similarity index 96% rename from osu.Game/Beatmaps/BeatmapMetrics.cs rename to osu.Game/Beatmaps/APIFailTimes.cs index b164aa6b30..7218906b38 100644 --- a/osu.Game/Beatmaps/BeatmapMetrics.cs +++ b/osu.Game/Beatmaps/APIFailTimes.cs @@ -9,7 +9,7 @@ namespace osu.Game.Beatmaps /// /// Beatmap metrics based on accumulated online data from community plays. /// - public class BeatmapMetrics + public class APIFailTimes { /// /// Points of failure on a relative time scale (usually 0..100). diff --git a/osu.Game/Beatmaps/BeatmapConverter.cs b/osu.Game/Beatmaps/BeatmapConverter.cs index f3434c5153..627e54c803 100644 --- a/osu.Game/Beatmaps/BeatmapConverter.cs +++ b/osu.Game/Beatmaps/BeatmapConverter.cs @@ -40,7 +40,13 @@ namespace osu.Game.Beatmaps public IBeatmap Convert(CancellationToken cancellationToken = default) { // We always operate on a clone of the original beatmap, to not modify it game-wide - return ConvertBeatmap(Beatmap.Clone(), cancellationToken); + var original = Beatmap.Clone(); + + // Shallow clone isn't enough to ensure we don't mutate beatmap info unexpectedly. + // Can potentially be removed after `Beatmap.Difficulty` doesn't save back to `Beatmap.BeatmapInfo`. + original.BeatmapInfo = original.BeatmapInfo.Clone(); + + return ConvertBeatmap(original, cancellationToken); } /// diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index ac5b5d7a8a..9069ea4404 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -9,6 +9,7 @@ using System.Linq; using Newtonsoft.Json; using osu.Framework.Testing; using osu.Game.Database; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; using osu.Game.Scoring; @@ -16,7 +17,7 @@ namespace osu.Game.Beatmaps { [ExcludeFromDynamicCompile] [Serializable] - public class BeatmapInfo : IEquatable, IHasPrimaryKey, IBeatmapInfo + public class BeatmapInfo : IEquatable, IHasPrimaryKey, IBeatmapInfo, IBeatmapOnlineInfo { public int ID { get; set; } @@ -47,10 +48,7 @@ namespace osu.Game.Beatmaps public BeatmapDifficulty BaseDifficulty { get; set; } [NotMapped] - public BeatmapMetrics Metrics { get; set; } - - [NotMapped] - public BeatmapOnlineInfo OnlineInfo { get; set; } + public APIBeatmap OnlineInfo { get; set; } [NotMapped] public int? MaxCombo { get; set; } @@ -178,19 +176,49 @@ namespace osu.Game.Beatmaps #region Implementation of IHasOnlineID - public int? OnlineID => OnlineBeatmapID; + public int OnlineID => OnlineBeatmapID ?? -1; #endregion #region Implementation of IBeatmapInfo + [JsonIgnore] string IBeatmapInfo.DifficultyName => Version; + + [JsonIgnore] IBeatmapMetadataInfo IBeatmapInfo.Metadata => Metadata; + + [JsonIgnore] IBeatmapDifficultyInfo IBeatmapInfo.Difficulty => BaseDifficulty; + + [JsonIgnore] IBeatmapSetInfo IBeatmapInfo.BeatmapSet => BeatmapSet; + + [JsonIgnore] IRulesetInfo IBeatmapInfo.Ruleset => Ruleset; + + [JsonIgnore] double IBeatmapInfo.StarRating => StarDifficulty; #endregion + + #region Implementation of IBeatmapOnlineInfo + + [JsonIgnore] + public int CircleCount => OnlineInfo.CircleCount; + + [JsonIgnore] + public int SliderCount => OnlineInfo.SliderCount; + + [JsonIgnore] + public int PlayCount => OnlineInfo.PlayCount; + + [JsonIgnore] + public int PassCount => OnlineInfo.PassCount; + + [JsonIgnore] + public APIFailTimes FailTimes => OnlineInfo.FailTimes; + + #endregion } } diff --git a/osu.Game/Beatmaps/BeatmapInfoExtensions.cs b/osu.Game/Beatmaps/BeatmapInfoExtensions.cs index eba19ac1a1..836302c424 100644 --- a/osu.Game/Beatmaps/BeatmapInfoExtensions.cs +++ b/osu.Game/Beatmaps/BeatmapInfoExtensions.cs @@ -16,12 +16,17 @@ namespace osu.Game.Beatmaps /// /// A user-presentable display title representing this beatmap, with localisation handling for potentially romanisable fields. /// - public static RomanisableString GetDisplayTitleRomanisable(this IBeatmapInfo beatmapInfo) + public static RomanisableString GetDisplayTitleRomanisable(this IBeatmapInfo beatmapInfo, bool includeDifficultyName = true) { var metadata = getClosestMetadata(beatmapInfo).GetDisplayTitleRomanisable(); - var versionString = getVersionString(beatmapInfo); - return new RomanisableString($"{metadata.GetPreferred(true)} {versionString}".Trim(), $"{metadata.GetPreferred(false)} {versionString}".Trim()); + if (includeDifficultyName) + { + var versionString = getVersionString(beatmapInfo); + return new RomanisableString($"{metadata.GetPreferred(true)} {versionString}".Trim(), $"{metadata.GetPreferred(false)} {versionString}".Trim()); + } + + return new RomanisableString($"{metadata.GetPreferred(true)}".Trim(), $"{metadata.GetPreferred(false)}".Trim()); } public static string[] GetSearchableTerms(this IBeatmapInfo beatmapInfo) => new[] diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 240db22c00..0509a9db47 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -29,7 +29,7 @@ namespace osu.Game.Beatmaps /// Handles general operations related to global beatmap management. /// [ExcludeFromDynamicCompile] - public class BeatmapManager : IModelDownloader, IModelManager, IModelFileManager, ICanAcceptFiles, IWorkingBeatmapCache, IDisposable + public class BeatmapManager : IModelDownloader, IModelManager, IModelFileManager, IWorkingBeatmapCache, IDisposable { private readonly BeatmapModelManager beatmapModelManager; private readonly BeatmapModelDownloader beatmapModelDownloader; @@ -54,7 +54,7 @@ namespace osu.Game.Beatmaps } } - protected virtual BeatmapModelDownloader CreateBeatmapModelDownloader(BeatmapModelManager modelManager, IAPIProvider api, GameHost host) + protected virtual BeatmapModelDownloader CreateBeatmapModelDownloader(IBeatmapModelManager modelManager, IAPIProvider api, GameHost host) { return new BeatmapModelDownloader(modelManager, api, host); } @@ -176,11 +176,6 @@ namespace osu.Game.Beatmaps } } - /// - /// Fired when the user requests to view the resulting import. - /// - public Action>> PresentImport { set => beatmapModelManager.PostImport = value; } - /// /// Delete a beatmap difficulty. /// @@ -338,5 +333,14 @@ namespace osu.Game.Beatmaps } #endregion + + #region Implementation of IPostImports + + public Action>> PostImport + { + set => beatmapModelManager.PostImport = value; + } + + #endregion } } diff --git a/osu.Game/Beatmaps/BeatmapModelDownloader.cs b/osu.Game/Beatmaps/BeatmapModelDownloader.cs index ae482eeafd..30dc95a966 100644 --- a/osu.Game/Beatmaps/BeatmapModelDownloader.cs +++ b/osu.Game/Beatmaps/BeatmapModelDownloader.cs @@ -13,7 +13,7 @@ namespace osu.Game.Beatmaps protected override ArchiveDownloadRequest CreateDownloadRequest(BeatmapSetInfo set, bool minimiseDownloadSize) => new DownloadBeatmapSetRequest(set, minimiseDownloadSize); - public BeatmapModelDownloader(BeatmapModelManager beatmapModelManager, IAPIProvider api, GameHost host = null) + public BeatmapModelDownloader(IBeatmapModelManager beatmapModelManager, IAPIProvider api, GameHost host = null) : base(beatmapModelManager, api, host) { } diff --git a/osu.Game/Beatmaps/BeatmapModelManager.cs b/osu.Game/Beatmaps/BeatmapModelManager.cs index 9c0fc5ef8a..16cf6193f9 100644 --- a/osu.Game/Beatmaps/BeatmapModelManager.cs +++ b/osu.Game/Beatmaps/BeatmapModelManager.cs @@ -123,15 +123,15 @@ namespace osu.Game.Beatmaps // check if a set already exists with the same online id, delete if it does. if (beatmapSet.OnlineBeatmapSetID != null) { - var existingOnlineId = beatmaps.ConsumableItems.FirstOrDefault(b => b.OnlineBeatmapSetID == beatmapSet.OnlineBeatmapSetID); + var existingSetWithSameOnlineID = beatmaps.ConsumableItems.FirstOrDefault(b => b.OnlineBeatmapSetID == beatmapSet.OnlineBeatmapSetID); - if (existingOnlineId != null) + if (existingSetWithSameOnlineID != null) { - Delete(existingOnlineId); + Delete(existingSetWithSameOnlineID); // in order to avoid a unique key constraint, immediately remove the online ID from the previous set. - existingOnlineId.OnlineBeatmapSetID = null; - foreach (var b in existingOnlineId.Beatmaps) + existingSetWithSameOnlineID.OnlineBeatmapSetID = null; + foreach (var b in existingSetWithSameOnlineID.Beatmaps) b.OnlineBeatmapID = null; LogForModel(beatmapSet, $"Found existing beatmap set with same OnlineBeatmapSetID ({beatmapSet.OnlineBeatmapSetID}). It has been deleted."); @@ -192,6 +192,13 @@ namespace osu.Game.Beatmaps { var setInfo = beatmapInfo.BeatmapSet; + // Difficulty settings must be copied first due to the clone in `Beatmap<>.BeatmapInfo_Set`. + // This should hopefully be temporary, assuming said clone is eventually removed. + beatmapInfo.BaseDifficulty.CopyFrom(beatmapContent.Difficulty); + + // All changes to metadata are made in the provided beatmapInfo, so this should be copied to the `IBeatmap` before encoding. + beatmapContent.BeatmapInfo = beatmapInfo; + using (var stream = new MemoryStream()) { using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) @@ -202,7 +209,6 @@ namespace osu.Game.Beatmaps using (ContextFactory.GetForWrite()) { beatmapInfo = setInfo.Beatmaps.Single(b => b.ID == beatmapInfo.ID); - beatmapInfo.BaseDifficulty.CopyFrom(beatmapContent.Difficulty); var metadata = beatmapInfo.Metadata ?? setInfo.Metadata; diff --git a/osu.Game/Beatmaps/BeatmapOnlineLookupQueue.cs b/osu.Game/Beatmaps/BeatmapOnlineLookupQueue.cs index 1fe120557d..b05ad9a1dd 100644 --- a/osu.Game/Beatmaps/BeatmapOnlineLookupQueue.cs +++ b/osu.Game/Beatmaps/BeatmapOnlineLookupQueue.cs @@ -83,9 +83,9 @@ namespace osu.Game.Beatmaps if (res != null) { beatmapInfo.Status = res.Status; - beatmapInfo.BeatmapSet.Status = res.BeatmapSet.Status; + beatmapInfo.BeatmapSet.Status = res.BeatmapSet?.Status ?? BeatmapSetOnlineStatus.None; beatmapInfo.BeatmapSet.OnlineBeatmapSetID = res.OnlineBeatmapSetID; - beatmapInfo.OnlineBeatmapID = res.OnlineBeatmapID; + beatmapInfo.OnlineBeatmapID = res.OnlineID; if (beatmapInfo.Metadata != null) beatmapInfo.Metadata.AuthorID = res.AuthorID; @@ -93,7 +93,7 @@ namespace osu.Game.Beatmaps if (beatmapInfo.BeatmapSet.Metadata != null) beatmapInfo.BeatmapSet.Metadata.AuthorID = res.AuthorID; - logForModel(set, $"Online retrieval mapped {beatmapInfo} to {res.OnlineBeatmapSetID} / {res.OnlineBeatmapID}."); + logForModel(set, $"Online retrieval mapped {beatmapInfo} to {res.OnlineBeatmapSetID} / {res.OnlineID}."); } } catch (Exception e) diff --git a/osu.Game/Beatmaps/BeatmapSetInfo.cs b/osu.Game/Beatmaps/BeatmapSetInfo.cs index 8b01831b3c..ae32ad000e 100644 --- a/osu.Game/Beatmaps/BeatmapSetInfo.cs +++ b/osu.Game/Beatmaps/BeatmapSetInfo.cs @@ -6,13 +6,15 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; using System.Linq; using JetBrains.Annotations; +using Newtonsoft.Json; using osu.Framework.Testing; using osu.Game.Database; +using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Beatmaps { [ExcludeFromDynamicCompile] - public class BeatmapSetInfo : IHasPrimaryKey, IHasFiles, ISoftDelete, IEquatable, IBeatmapSetInfo + public class BeatmapSetInfo : IHasPrimaryKey, IHasFiles, ISoftDelete, IEquatable, IBeatmapSetInfo, IBeatmapSetOnlineInfo { public int ID { get; set; } @@ -26,8 +28,6 @@ namespace osu.Game.Beatmaps public DateTimeOffset DateAdded { get; set; } - public BeatmapSetOnlineStatus Status { get; set; } = BeatmapSetOnlineStatus.None; - public BeatmapMetadata Metadata { get; set; } public List Beatmaps { get; set; } @@ -36,10 +36,7 @@ namespace osu.Game.Beatmaps public List Files { get; set; } = new List(); [NotMapped] - public BeatmapSetOnlineInfo OnlineInfo { get; set; } - - [NotMapped] - public BeatmapSetMetrics Metrics { get; set; } + public APIBeatmapSet OnlineInfo { get; set; } /// /// The maximum star difficulty of all beatmaps in this set. @@ -91,7 +88,7 @@ namespace osu.Game.Beatmaps #region Implementation of IHasOnlineID - public int? OnlineID => OnlineBeatmapSetID; + public int OnlineID => OnlineBeatmapSetID ?? -1; #endregion @@ -102,5 +99,80 @@ namespace osu.Game.Beatmaps IEnumerable IBeatmapSetInfo.Files => Files; #endregion + + #region Delegation for IBeatmapSetOnlineInfo + + [NotMapped] + [JsonIgnore] + public DateTimeOffset Submitted => OnlineInfo.Submitted; + + [NotMapped] + [JsonIgnore] + public DateTimeOffset? Ranked => OnlineInfo.Ranked; + + [NotMapped] + [JsonIgnore] + public DateTimeOffset? LastUpdated => OnlineInfo.LastUpdated; + + [JsonIgnore] + public BeatmapSetOnlineStatus Status { get; set; } = BeatmapSetOnlineStatus.None; + + [NotMapped] + [JsonIgnore] + public bool HasExplicitContent => OnlineInfo.HasExplicitContent; + + [NotMapped] + [JsonIgnore] + public bool HasVideo => OnlineInfo.HasVideo; + + [NotMapped] + [JsonIgnore] + public bool HasStoryboard => OnlineInfo.HasStoryboard; + + [NotMapped] + [JsonIgnore] + public BeatmapSetOnlineCovers Covers => OnlineInfo.Covers; + + [NotMapped] + [JsonIgnore] + public string Preview => OnlineInfo.Preview; + + [NotMapped] + [JsonIgnore] + public double BPM => OnlineInfo.BPM; + + [NotMapped] + [JsonIgnore] + public int PlayCount => OnlineInfo.PlayCount; + + [NotMapped] + [JsonIgnore] + public int FavouriteCount => OnlineInfo.FavouriteCount; + + [NotMapped] + [JsonIgnore] + public bool HasFavourited => OnlineInfo.HasFavourited; + + [NotMapped] + [JsonIgnore] + public BeatmapSetOnlineAvailability Availability => OnlineInfo.Availability; + + [NotMapped] + [JsonIgnore] + public BeatmapSetOnlineGenre Genre => OnlineInfo.Genre; + + [NotMapped] + [JsonIgnore] + public BeatmapSetOnlineLanguage Language => OnlineInfo.Language; + + [NotMapped] + [JsonIgnore] + public int? TrackId => OnlineInfo?.TrackId; + + [NotMapped] + [JsonIgnore] + public int[] Ratings => OnlineInfo?.Ratings; + + #endregion } } diff --git a/osu.Game/Beatmaps/BeatmapSetMetrics.cs b/osu.Game/Beatmaps/BeatmapSetMetrics.cs deleted file mode 100644 index 51c5de19a6..0000000000 --- a/osu.Game/Beatmaps/BeatmapSetMetrics.cs +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using Newtonsoft.Json; - -namespace osu.Game.Beatmaps -{ - public class BeatmapSetMetrics - { - /// - /// Total vote counts of user ratings on a scale of 0..10 where 0 is unused (probably will be fixed at API?). - /// - [JsonProperty("ratings")] - public int[] Ratings { get; set; } = Array.Empty(); - } -} diff --git a/osu.Game/Beatmaps/BeatmapSetOnlineAvailability.cs b/osu.Game/Beatmaps/BeatmapSetOnlineAvailability.cs new file mode 100644 index 0000000000..14a63f3279 --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapSetOnlineAvailability.cs @@ -0,0 +1,16 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; + +namespace osu.Game.Beatmaps +{ + public struct BeatmapSetOnlineAvailability + { + [JsonProperty(@"download_disabled")] + public bool DownloadDisabled { get; set; } + + [JsonProperty(@"more_information")] + public string ExternalLink { get; set; } + } +} diff --git a/osu.Game/Beatmaps/BeatmapSetOnlineCovers.cs b/osu.Game/Beatmaps/BeatmapSetOnlineCovers.cs new file mode 100644 index 0000000000..aad31befa8 --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapSetOnlineCovers.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; + +namespace osu.Game.Beatmaps +{ + public struct BeatmapSetOnlineCovers + { + public string CoverLowRes { get; set; } + + [JsonProperty(@"cover@2x")] + public string Cover { get; set; } + + public string CardLowRes { get; set; } + + [JsonProperty(@"card@2x")] + public string Card { get; set; } + + public string ListLowRes { get; set; } + + [JsonProperty(@"list@2x")] + public string List { get; set; } + } +} diff --git a/osu.Game/Beatmaps/BeatmapSetOnlineGenre.cs b/osu.Game/Beatmaps/BeatmapSetOnlineGenre.cs new file mode 100644 index 0000000000..e727e2c37f --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapSetOnlineGenre.cs @@ -0,0 +1,11 @@ +// 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.Beatmaps +{ + public struct BeatmapSetOnlineGenre + { + public int Id { get; set; } + public string Name { get; set; } + } +} diff --git a/osu.Game/Beatmaps/BeatmapSetOnlineLanguage.cs b/osu.Game/Beatmaps/BeatmapSetOnlineLanguage.cs new file mode 100644 index 0000000000..658e5a4005 --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapSetOnlineLanguage.cs @@ -0,0 +1,11 @@ +// 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.Beatmaps +{ + public struct BeatmapSetOnlineLanguage + { + public int Id { get; set; } + public string Name { get; set; } + } +} diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs index 8203f2e968..4079a0cd5f 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPoint.cs @@ -15,11 +15,9 @@ namespace osu.Game.Beatmaps.ControlPoints /// The time at which the control point takes effect. /// [JsonIgnore] - public double Time => controlPointGroup?.Time ?? 0; + public double Time { get; set; } - private ControlPointGroup controlPointGroup; - - public void AttachGroup(ControlPointGroup pointGroup) => controlPointGroup = pointGroup; + public void AttachGroup(ControlPointGroup pointGroup) => Time = pointGroup.Time; public int CompareTo(ControlPoint other) => Time.CompareTo(other.Time); @@ -46,6 +44,7 @@ namespace osu.Game.Beatmaps.ControlPoints public virtual void CopyFrom(ControlPoint other) { + Time = other.Time; } } } diff --git a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs index 3ff40fe194..9d738ecbfb 100644 --- a/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs +++ b/osu.Game/Beatmaps/ControlPoints/ControlPointInfo.cs @@ -33,14 +33,6 @@ namespace osu.Game.Beatmaps.ControlPoints private readonly SortedList timingPoints = new SortedList(Comparer.Default); - /// - /// All difficulty points. - /// - [JsonProperty] - public IReadOnlyList DifficultyPoints => difficultyPoints; - - private readonly SortedList difficultyPoints = new SortedList(Comparer.Default); - /// /// All effect points. /// @@ -55,14 +47,6 @@ namespace osu.Game.Beatmaps.ControlPoints [JsonIgnore] public IEnumerable AllControlPoints => Groups.SelectMany(g => g.ControlPoints).ToArray(); - /// - /// Finds the difficulty control point that is active at . - /// - /// The time to find the difficulty control point at. - /// The difficulty control point. - [NotNull] - public DifficultyControlPoint DifficultyPointAt(double time) => BinarySearchWithFallback(DifficultyPoints, time, DifficultyControlPoint.DEFAULT); - /// /// Finds the effect control point that is active at . /// @@ -100,7 +84,6 @@ namespace osu.Game.Beatmaps.ControlPoints { groups.Clear(); timingPoints.Clear(); - difficultyPoints.Clear(); effectPoints.Clear(); } @@ -277,10 +260,6 @@ namespace osu.Game.Beatmaps.ControlPoints case EffectControlPoint _: existing = EffectPointAt(time); break; - - case DifficultyControlPoint _: - existing = DifficultyPointAt(time); - break; } return newPoint?.IsRedundant(existing) == true; @@ -298,9 +277,8 @@ namespace osu.Game.Beatmaps.ControlPoints effectPoints.Add(typed); break; - case DifficultyControlPoint typed: - difficultyPoints.Add(typed); - break; + default: + throw new ArgumentException($"A control point of unexpected type {controlPoint.GetType()} was added to this {nameof(ControlPointInfo)}"); } } @@ -315,10 +293,6 @@ namespace osu.Game.Beatmaps.ControlPoints case EffectControlPoint typed: effectPoints.Remove(typed); break; - - case DifficultyControlPoint typed: - difficultyPoints.Remove(typed); - break; } } diff --git a/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs index 8a6cfaf688..bf7ed8e6f5 100644 --- a/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/DifficultyControlPoint.cs @@ -7,17 +7,20 @@ using osuTK.Graphics; namespace osu.Game.Beatmaps.ControlPoints { + /// + /// Note that going forward, this control point type should always be assigned directly to HitObjects. + /// public class DifficultyControlPoint : ControlPoint { public static readonly DifficultyControlPoint DEFAULT = new DifficultyControlPoint { - SpeedMultiplierBindable = { Disabled = true }, + SliderVelocityBindable = { Disabled = true }, }; /// - /// The speed multiplier at this control point. + /// The slider velocity at this control point. /// - public readonly BindableDouble SpeedMultiplierBindable = new BindableDouble(1) + public readonly BindableDouble SliderVelocityBindable = new BindableDouble(1) { Precision = 0.01, Default = 1, @@ -28,21 +31,21 @@ namespace osu.Game.Beatmaps.ControlPoints public override Color4 GetRepresentingColour(OsuColour colours) => colours.Lime1; /// - /// The speed multiplier at this control point. + /// The slider velocity at this control point. /// - public double SpeedMultiplier + public double SliderVelocity { - get => SpeedMultiplierBindable.Value; - set => SpeedMultiplierBindable.Value = value; + get => SliderVelocityBindable.Value; + set => SliderVelocityBindable.Value = value; } public override bool IsRedundant(ControlPoint existing) => existing is DifficultyControlPoint existingDifficulty - && SpeedMultiplier == existingDifficulty.SpeedMultiplier; + && SliderVelocity == existingDifficulty.SliderVelocity; public override void CopyFrom(ControlPoint other) { - SpeedMultiplier = ((DifficultyControlPoint)other).SpeedMultiplier; + SliderVelocity = ((DifficultyControlPoint)other).SliderVelocity; base.CopyFrom(other); } diff --git a/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs index 79bc88e773..7f550a52fc 100644 --- a/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/EffectControlPoint.cs @@ -12,7 +12,8 @@ namespace osu.Game.Beatmaps.ControlPoints public static readonly EffectControlPoint DEFAULT = new EffectControlPoint { KiaiModeBindable = { Disabled = true }, - OmitFirstBarLineBindable = { Disabled = true } + OmitFirstBarLineBindable = { Disabled = true }, + ScrollSpeedBindable = { Disabled = true } }; /// @@ -20,6 +21,26 @@ namespace osu.Game.Beatmaps.ControlPoints /// public readonly BindableBool OmitFirstBarLineBindable = new BindableBool(); + /// + /// The relative scroll speed at this control point. + /// + public readonly BindableDouble ScrollSpeedBindable = new BindableDouble(1) + { + Precision = 0.01, + Default = 1, + MinValue = 0.01, + MaxValue = 10 + }; + + /// + /// The relative scroll speed. + /// + public double ScrollSpeed + { + get => ScrollSpeedBindable.Value; + set => ScrollSpeedBindable.Value = value; + } + public override Color4 GetRepresentingColour(OsuColour colours) => colours.Purple; /// @@ -49,12 +70,14 @@ namespace osu.Game.Beatmaps.ControlPoints => !OmitFirstBarLine && existing is EffectControlPoint existingEffect && KiaiMode == existingEffect.KiaiMode - && OmitFirstBarLine == existingEffect.OmitFirstBarLine; + && OmitFirstBarLine == existingEffect.OmitFirstBarLine + && ScrollSpeed == existingEffect.ScrollSpeed; public override void CopyFrom(ControlPoint other) { KiaiMode = ((EffectControlPoint)other).KiaiMode; OmitFirstBarLine = ((EffectControlPoint)other).OmitFirstBarLine; + ScrollSpeed = ((EffectControlPoint)other).ScrollSpeed; base.CopyFrom(other); } diff --git a/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs b/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs index 4aa6a3d6e9..fb489f73b1 100644 --- a/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs +++ b/osu.Game/Beatmaps/ControlPoints/SampleControlPoint.cs @@ -8,6 +8,9 @@ using osuTK.Graphics; namespace osu.Game.Beatmaps.ControlPoints { + /// + /// Note that going forward, this control point type should always be assigned directly to HitObjects. + /// public class SampleControlPoint : ControlPoint { public const string DEFAULT_BANK = "normal"; diff --git a/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs b/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs index ffc010b3a3..ec098f4ca2 100644 --- a/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs +++ b/osu.Game/Beatmaps/Drawables/BeatmapSetOnlineStatusPill.cs @@ -1,6 +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 enable + +using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; @@ -8,15 +11,13 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; using osuTK.Graphics; namespace osu.Game.Beatmaps.Drawables { public class BeatmapSetOnlineStatusPill : CircularContainer { - private readonly OsuSpriteText statusText; - private readonly Box background; - private BeatmapSetOnlineStatus status; public BeatmapSetOnlineStatus Status @@ -29,8 +30,8 @@ namespace osu.Game.Beatmaps.Drawables status = value; - Alpha = value == BeatmapSetOnlineStatus.None ? 0 : 1; - statusText.Text = value.GetLocalisableDescription().ToUpper(); + if (IsLoaded) + updateState(); } } @@ -46,15 +47,17 @@ namespace osu.Game.Beatmaps.Drawables set => statusText.Padding = value; } - public Color4 BackgroundColour - { - get => background.Colour; - set => background.Colour = value; - } + private readonly OsuSpriteText statusText; + private readonly Box background; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved(CanBeNull = true)] + private OverlayColourProvider? colourProvider { get; set; } public BeatmapSetOnlineStatusPill() { - AutoSizeAxes = Axes.Both; Masking = true; Children = new Drawable[] @@ -63,7 +66,6 @@ namespace osu.Game.Beatmaps.Drawables { RelativeSizeAxes = Axes.Both, Colour = Color4.Black, - Alpha = 0.5f, }, statusText = new OsuSpriteText { @@ -74,6 +76,27 @@ namespace osu.Game.Beatmaps.Drawables }; Status = BeatmapSetOnlineStatus.None; + TextPadding = new MarginPadding { Horizontal = 5, Bottom = 1 }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateState(); + } + + private void updateState() + { + Alpha = Status == BeatmapSetOnlineStatus.None ? 0 : 1; + + statusText.Text = Status.GetLocalisableDescription().ToUpper(); + + if (colourProvider != null) + statusText.Colour = status == BeatmapSetOnlineStatus.Graveyard ? colourProvider.Background1 : colourProvider.Background3; + else + statusText.Colour = status == BeatmapSetOnlineStatus.Graveyard ? colours.GreySeafoamLight : Color4.Black; + + background.Colour = OsuColour.ForBeatmapSetOnlineStatus(Status) ?? colourProvider?.Light1 ?? colours.GreySeafoamLighter; } } } diff --git a/osu.Game/Beatmaps/Drawables/BeatmapSetCover.cs b/osu.Game/Beatmaps/Drawables/OnlineBeatmapSetCover.cs similarity index 76% rename from osu.Game/Beatmaps/Drawables/BeatmapSetCover.cs rename to osu.Game/Beatmaps/Drawables/OnlineBeatmapSetCover.cs index 5245bc319d..0b19c27022 100644 --- a/osu.Game/Beatmaps/Drawables/BeatmapSetCover.cs +++ b/osu.Game/Beatmaps/Drawables/OnlineBeatmapSetCover.cs @@ -9,12 +9,12 @@ using osu.Framework.Graphics.Textures; namespace osu.Game.Beatmaps.Drawables { [LongRunningLoad] - public class BeatmapSetCover : Sprite + public class OnlineBeatmapSetCover : Sprite { - private readonly BeatmapSetInfo set; + private readonly IBeatmapSetOnlineInfo set; private readonly BeatmapSetCoverType type; - public BeatmapSetCover(BeatmapSetInfo set, BeatmapSetCoverType type = BeatmapSetCoverType.Cover) + public OnlineBeatmapSetCover(IBeatmapSetOnlineInfo set, BeatmapSetCoverType type = BeatmapSetCoverType.Cover) { if (set == null) throw new ArgumentNullException(nameof(set)); @@ -31,15 +31,15 @@ namespace osu.Game.Beatmaps.Drawables switch (type) { case BeatmapSetCoverType.Cover: - resource = set.OnlineInfo.Covers.Cover; + resource = set.Covers.Cover; break; case BeatmapSetCoverType.Card: - resource = set.OnlineInfo.Covers.Card; + resource = set.Covers.Card; break; case BeatmapSetCoverType.List: - resource = set.OnlineInfo.Covers.List; + resource = set.Covers.List; break; } diff --git a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs b/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs index 3206f7b3ab..8943ad350e 100644 --- a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs +++ b/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs @@ -54,7 +54,7 @@ namespace osu.Game.Beatmaps.Drawables { // prefer online cover where available. if (model?.BeatmapSet?.OnlineInfo != null) - return new BeatmapSetCover(model.BeatmapSet, beatmapSetCoverType); + return new OnlineBeatmapSetCover(model.BeatmapSet, beatmapSetCoverType); return model?.ID > 0 ? new BeatmapBackgroundSprite(beatmaps.GetWorkingBeatmap(model)) diff --git a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapSetCover.cs b/osu.Game/Beatmaps/Drawables/UpdateableOnlineBeatmapSetCover.cs similarity index 78% rename from osu.Game/Beatmaps/Drawables/UpdateableBeatmapSetCover.cs rename to osu.Game/Beatmaps/Drawables/UpdateableOnlineBeatmapSetCover.cs index 7248c9213c..73f87beb58 100644 --- a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapSetCover.cs +++ b/osu.Game/Beatmaps/Drawables/UpdateableOnlineBeatmapSetCover.cs @@ -9,11 +9,11 @@ using osu.Game.Graphics; namespace osu.Game.Beatmaps.Drawables { - public class UpdateableBeatmapSetCover : ModelBackedDrawable + public class UpdateableOnlineBeatmapSetCover : ModelBackedDrawable { private readonly BeatmapSetCoverType coverType; - public BeatmapSetInfo BeatmapSet + public IBeatmapSetOnlineInfo BeatmapSet { get => Model; set => Model = value; @@ -25,7 +25,7 @@ namespace osu.Game.Beatmaps.Drawables set => base.Masking = value; } - public UpdateableBeatmapSetCover(BeatmapSetCoverType coverType = BeatmapSetCoverType.Cover) + public UpdateableOnlineBeatmapSetCover(BeatmapSetCoverType coverType = BeatmapSetCoverType.Cover) { this.coverType = coverType; @@ -43,12 +43,12 @@ namespace osu.Game.Beatmaps.Drawables protected override DelayedLoadWrapper CreateDelayedLoadWrapper(Func createContentFunc, double timeBeforeLoad) => new DelayedLoadUnloadWrapper(createContentFunc, timeBeforeLoad); - protected override Drawable CreateDrawable(BeatmapSetInfo model) + protected override Drawable CreateDrawable(IBeatmapSetOnlineInfo model) { if (model == null) return null; - return new BeatmapSetCover(model, coverType) + return new OnlineBeatmapSetCover(model, coverType) { RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs index f71b148008..bef2d78f21 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapDecoder.cs @@ -384,14 +384,21 @@ namespace osu.Game.Beatmaps.Formats addControlPoint(time, new LegacyDifficultyControlPoint(beatLength) #pragma warning restore 618 { - SpeedMultiplier = speedMultiplier, + SliderVelocity = speedMultiplier, }, timingChange); - addControlPoint(time, new EffectControlPoint + var effectPoint = new EffectControlPoint { KiaiMode = kiaiMode, OmitFirstBarLine = omitFirstBarSignature, - }, timingChange); + }; + + bool isOsuRuleset = beatmap.BeatmapInfo.RulesetID == 0; + // scrolling rulesets use effect points rather than difficulty points for scroll speed adjustments. + if (!isOsuRuleset) + effectPoint.ScrollSpeed = speedMultiplier; + + addControlPoint(time, effectPoint, timingChange); addControlPoint(time, new LegacySampleControlPoint { diff --git a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs index 74b3c178cd..7cd4244cd0 100644 --- a/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyBeatmapEncoder.cs @@ -170,33 +170,30 @@ namespace osu.Game.Beatmaps.Formats if (beatmap.ControlPointInfo.Groups.Count == 0) return; + var legacyControlPoints = new LegacyControlPointInfo(); + foreach (var point in beatmap.ControlPointInfo.AllControlPoints) + legacyControlPoints.Add(point.Time, point.DeepClone()); + writer.WriteLine("[TimingPoints]"); - if (!(beatmap.ControlPointInfo is LegacyControlPointInfo)) + SampleControlPoint lastRelevantSamplePoint = null; + DifficultyControlPoint lastRelevantDifficultyPoint = null; + + bool isOsuRuleset = beatmap.BeatmapInfo.RulesetID == 0; + + // iterate over hitobjects and pull out all required sample and difficulty changes + extractDifficultyControlPoints(beatmap.HitObjects); + extractSampleControlPoints(beatmap.HitObjects); + + // handle scroll speed, which is stored as "slider velocity" in legacy formats. + // this is relevant for scrolling ruleset beatmaps. + if (!isOsuRuleset) { - var legacyControlPoints = new LegacyControlPointInfo(); - - foreach (var point in beatmap.ControlPointInfo.AllControlPoints) - legacyControlPoints.Add(point.Time, point.DeepClone()); - - beatmap.ControlPointInfo = legacyControlPoints; - - SampleControlPoint lastRelevantSamplePoint = null; - - // iterate over hitobjects and pull out all required sample changes - foreach (var h in beatmap.HitObjects) - { - var hSamplePoint = h.SampleControlPoint; - - if (!hSamplePoint.IsRedundant(lastRelevantSamplePoint)) - { - legacyControlPoints.Add(hSamplePoint.Time, hSamplePoint); - lastRelevantSamplePoint = hSamplePoint; - } - } + foreach (var point in legacyControlPoints.EffectPoints) + legacyControlPoints.Add(point.Time, new DifficultyControlPoint { SliderVelocity = point.ScrollSpeed }); } - foreach (var group in beatmap.ControlPointInfo.Groups) + foreach (var group in legacyControlPoints.Groups) { var groupTimingPoint = group.ControlPoints.OfType().FirstOrDefault(); @@ -209,16 +206,16 @@ namespace osu.Game.Beatmaps.Formats } // Output any remaining effects as secondary non-timing control point. - var difficultyPoint = beatmap.ControlPointInfo.DifficultyPointAt(group.Time); + var difficultyPoint = legacyControlPoints.DifficultyPointAt(group.Time); writer.Write(FormattableString.Invariant($"{group.Time},")); - writer.Write(FormattableString.Invariant($"{-100 / difficultyPoint.SpeedMultiplier},")); + writer.Write(FormattableString.Invariant($"{-100 / difficultyPoint.SliderVelocity},")); outputControlPointAt(group.Time, false); } void outputControlPointAt(double time, bool isTimingPoint) { - var samplePoint = ((LegacyControlPointInfo)beatmap.ControlPointInfo).SamplePointAt(time); - var effectPoint = beatmap.ControlPointInfo.EffectPointAt(time); + var samplePoint = legacyControlPoints.SamplePointAt(time); + var effectPoint = legacyControlPoints.EffectPointAt(time); // Apply the control point to a hit sample to uncover legacy properties (e.g. suffix) HitSampleInfo tempHitSample = samplePoint.ApplyTo(new ConvertHitObjectParser.LegacyHitSampleInfo(string.Empty)); @@ -230,7 +227,7 @@ namespace osu.Game.Beatmaps.Formats if (effectPoint.OmitFirstBarLine) effectFlags |= LegacyEffectFlags.OmitFirstBarLine; - writer.Write(FormattableString.Invariant($"{(int)beatmap.ControlPointInfo.TimingPointAt(time).TimeSignature},")); + writer.Write(FormattableString.Invariant($"{(int)legacyControlPoints.TimingPointAt(time).TimeSignature},")); writer.Write(FormattableString.Invariant($"{(int)toLegacySampleBank(tempHitSample.Bank)},")); writer.Write(FormattableString.Invariant($"{toLegacyCustomSampleBank(tempHitSample)},")); writer.Write(FormattableString.Invariant($"{tempHitSample.Volume},")); @@ -238,6 +235,55 @@ namespace osu.Game.Beatmaps.Formats writer.Write(FormattableString.Invariant($"{(int)effectFlags}")); writer.WriteLine(); } + + IEnumerable collectDifficultyControlPoints(IEnumerable hitObjects) + { + if (!isOsuRuleset) + yield break; + + foreach (var hitObject in hitObjects) + { + yield return hitObject.DifficultyControlPoint; + + foreach (var nested in collectDifficultyControlPoints(hitObject.NestedHitObjects)) + yield return nested; + } + } + + void extractDifficultyControlPoints(IEnumerable hitObjects) + { + foreach (var hDifficultyPoint in collectDifficultyControlPoints(hitObjects).OrderBy(dp => dp.Time)) + { + if (!hDifficultyPoint.IsRedundant(lastRelevantDifficultyPoint)) + { + legacyControlPoints.Add(hDifficultyPoint.Time, hDifficultyPoint); + lastRelevantDifficultyPoint = hDifficultyPoint; + } + } + } + + IEnumerable collectSampleControlPoints(IEnumerable hitObjects) + { + foreach (var hitObject in hitObjects) + { + yield return hitObject.SampleControlPoint; + + foreach (var nested in collectSampleControlPoints(hitObject.NestedHitObjects)) + yield return nested; + } + } + + void extractSampleControlPoints(IEnumerable hitObject) + { + foreach (var hSamplePoint in collectSampleControlPoints(hitObject).OrderBy(sp => sp.Time)) + { + if (!hSamplePoint.IsRedundant(lastRelevantSamplePoint)) + { + legacyControlPoints.Add(hSamplePoint.Time, hSamplePoint); + lastRelevantSamplePoint = hSamplePoint; + } + } + } } private void handleColours(TextWriter writer) @@ -414,7 +460,7 @@ namespace osu.Game.Beatmaps.Formats var curveData = pathData as IHasPathWithRepeats; writer.Write(FormattableString.Invariant($"{(curveData?.RepeatCount ?? 0) + 1},")); - writer.Write(FormattableString.Invariant($"{pathData.Path.Distance},")); + writer.Write(FormattableString.Invariant($"{pathData.Path.ExpectedDistance.Value ?? pathData.Path.Distance},")); if (curveData != null) { diff --git a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs index 20080308f9..cf6c827af5 100644 --- a/osu.Game/Beatmaps/Formats/LegacyDecoder.cs +++ b/osu.Game/Beatmaps/Formats/LegacyDecoder.cs @@ -181,7 +181,7 @@ namespace osu.Game.Beatmaps.Formats public LegacyDifficultyControlPoint() { - SpeedMultiplierBindable.Precision = double.Epsilon; + SliderVelocityBindable.Precision = double.Epsilon; } public override void CopyFrom(ControlPoint other) diff --git a/osu.Game/Beatmaps/BeatmapOnlineInfo.cs b/osu.Game/Beatmaps/IBeatmapOnlineInfo.cs similarity index 51% rename from osu.Game/Beatmaps/BeatmapOnlineInfo.cs rename to osu.Game/Beatmaps/IBeatmapOnlineInfo.cs index bfeacd9bfc..385646eeaa 100644 --- a/osu.Game/Beatmaps/BeatmapOnlineInfo.cs +++ b/osu.Game/Beatmaps/IBeatmapOnlineInfo.cs @@ -1,31 +1,40 @@ -// 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. +#nullable enable + namespace osu.Game.Beatmaps { /// - /// Beatmap info retrieved for previewing locally without having the beatmap downloaded. + /// Beatmap info retrieved for previewing locally. /// - public class BeatmapOnlineInfo + public interface IBeatmapOnlineInfo { + /// + /// The max combo of this beatmap. + /// + int? MaxCombo { get; } + /// /// The amount of circles in this beatmap. /// - public int CircleCount { get; set; } + public int CircleCount { get; } /// /// The amount of sliders in this beatmap. /// - public int SliderCount { get; set; } + public int SliderCount { get; } /// /// The amount of plays this beatmap has. /// - public int PlayCount { get; set; } + public int PlayCount { get; } /// /// The amount of passes this beatmap has. /// - public int PassCount { get; set; } + public int PassCount { get; } + + APIFailTimes? FailTimes { get; } } } diff --git a/osu.Game/Beatmaps/BeatmapSetOnlineInfo.cs b/osu.Game/Beatmaps/IBeatmapSetOnlineInfo.cs similarity index 52% rename from osu.Game/Beatmaps/BeatmapSetOnlineInfo.cs rename to osu.Game/Beatmaps/IBeatmapSetOnlineInfo.cs index 3658dbab83..6def6ec21d 100644 --- a/osu.Game/Beatmaps/BeatmapSetOnlineInfo.cs +++ b/osu.Game/Beatmaps/IBeatmapSetOnlineInfo.cs @@ -1,139 +1,106 @@ -// 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 System; -using Newtonsoft.Json; + +#nullable enable namespace osu.Game.Beatmaps { /// /// Beatmap set info retrieved for previewing locally without having the set downloaded. /// - public class BeatmapSetOnlineInfo + public interface IBeatmapSetOnlineInfo { /// /// The date this beatmap set was submitted to the online listing. /// - public DateTimeOffset Submitted { get; set; } + DateTimeOffset Submitted { get; } /// /// The date this beatmap set was ranked. /// - public DateTimeOffset? Ranked { get; set; } + DateTimeOffset? Ranked { get; } /// /// The date this beatmap set was last updated. /// - public DateTimeOffset? LastUpdated { get; set; } + DateTimeOffset? LastUpdated { get; } /// /// The status of this beatmap set. /// - public BeatmapSetOnlineStatus Status { get; set; } + BeatmapSetOnlineStatus Status { get; } /// /// Whether or not this beatmap set has explicit content. /// - public bool HasExplicitContent { get; set; } + bool HasExplicitContent { get; } /// /// Whether or not this beatmap set has a background video. /// - public bool HasVideo { get; set; } + bool HasVideo { get; } /// /// Whether or not this beatmap set has a storyboard. /// - public bool HasStoryboard { get; set; } + bool HasStoryboard { get; } /// /// The different sizes of cover art for this beatmap set. /// - public BeatmapSetOnlineCovers Covers { get; set; } + BeatmapSetOnlineCovers Covers { get; } /// /// A small sample clip of this beatmap set's song. /// - public string Preview { get; set; } + string Preview { get; } /// /// The beats per minute of this beatmap set's song. /// - public double BPM { get; set; } + double BPM { get; } /// /// The amount of plays this beatmap set has. /// - public int PlayCount { get; set; } + int PlayCount { get; } /// /// The amount of people who have favourited this beatmap set. /// - public int FavouriteCount { get; set; } + int FavouriteCount { get; } /// /// Whether this beatmap set has been favourited by the current user. /// - public bool HasFavourited { get; set; } + bool HasFavourited { get; } /// /// The availability of this beatmap set. /// - public BeatmapSetOnlineAvailability Availability { get; set; } + BeatmapSetOnlineAvailability Availability { get; } /// /// The song genre of this beatmap set. /// - public BeatmapSetOnlineGenre Genre { get; set; } + BeatmapSetOnlineGenre Genre { get; } /// /// The song language of this beatmap set. /// - public BeatmapSetOnlineLanguage Language { get; set; } + BeatmapSetOnlineLanguage Language { get; } /// /// The track ID of this beatmap set. /// Non-null only if the track is linked to a featured artist track entry. /// - public int? TrackId { get; set; } - } + int? TrackId { get; } - public class BeatmapSetOnlineGenre - { - public int Id { get; set; } - public string Name { get; set; } - } - - public class BeatmapSetOnlineLanguage - { - public int Id { get; set; } - public string Name { get; set; } - } - - public class BeatmapSetOnlineCovers - { - public string CoverLowRes { get; set; } - - [JsonProperty(@"cover@2x")] - public string Cover { get; set; } - - public string CardLowRes { get; set; } - - [JsonProperty(@"card@2x")] - public string Card { get; set; } - - public string ListLowRes { get; set; } - - [JsonProperty(@"list@2x")] - public string List { get; set; } - } - - public class BeatmapSetOnlineAvailability - { - [JsonProperty(@"download_disabled")] - public bool DownloadDisabled { get; set; } - - [JsonProperty(@"more_information")] - public string ExternalLink { get; set; } + /// + /// Total vote counts of user ratings on a scale of 0..10 where 0 is unused (probably will be fixed at API?). + /// + int[]? Ratings { get; } } } diff --git a/osu.Game/Beatmaps/Legacy/LegacyControlPointInfo.cs b/osu.Game/Beatmaps/Legacy/LegacyControlPointInfo.cs index ff0ca5ebe1..2b0a2e7a4d 100644 --- a/osu.Game/Beatmaps/Legacy/LegacyControlPointInfo.cs +++ b/osu.Game/Beatmaps/Legacy/LegacyControlPointInfo.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. +using System.Collections.Generic; using JetBrains.Annotations; using Newtonsoft.Json; -using osu.Framework.Bindables; +using osu.Framework.Lists; using osu.Game.Beatmaps.ControlPoints; namespace osu.Game.Beatmaps.Legacy @@ -14,9 +15,9 @@ namespace osu.Game.Beatmaps.Legacy /// All sound points. /// [JsonProperty] - public IBindableList SamplePoints => samplePoints; + public IReadOnlyList SamplePoints => samplePoints; - private readonly BindableList samplePoints = new BindableList(); + private readonly SortedList samplePoints = new SortedList(Comparer.Default); /// /// Finds the sound control point that is active at . @@ -26,35 +27,76 @@ namespace osu.Game.Beatmaps.Legacy [NotNull] public SampleControlPoint SamplePointAt(double time) => BinarySearchWithFallback(SamplePoints, time, SamplePoints.Count > 0 ? SamplePoints[0] : SampleControlPoint.DEFAULT); + /// + /// All difficulty points. + /// + [JsonProperty] + public IReadOnlyList DifficultyPoints => difficultyPoints; + + private readonly SortedList difficultyPoints = new SortedList(Comparer.Default); + + /// + /// Finds the difficulty control point that is active at . + /// + /// The time to find the difficulty control point at. + /// The difficulty control point. + [NotNull] + public DifficultyControlPoint DifficultyPointAt(double time) => BinarySearchWithFallback(DifficultyPoints, time, DifficultyControlPoint.DEFAULT); + public override void Clear() { base.Clear(); samplePoints.Clear(); + difficultyPoints.Clear(); } protected override bool CheckAlreadyExisting(double time, ControlPoint newPoint) { - if (newPoint is SampleControlPoint) + switch (newPoint) { - var existing = BinarySearch(SamplePoints, time); - return newPoint.IsRedundant(existing); - } + case SampleControlPoint _: + // intentionally don't use SamplePointAt (we always need to consider the first sample point). + var existing = BinarySearch(SamplePoints, time); + return newPoint.IsRedundant(existing); - return base.CheckAlreadyExisting(time, newPoint); + case DifficultyControlPoint _: + return newPoint.IsRedundant(DifficultyPointAt(time)); + + default: + return base.CheckAlreadyExisting(time, newPoint); + } } protected override void GroupItemAdded(ControlPoint controlPoint) { - if (controlPoint is SampleControlPoint typed) - samplePoints.Add(typed); + switch (controlPoint) + { + case SampleControlPoint typed: + samplePoints.Add(typed); + return; - base.GroupItemAdded(controlPoint); + case DifficultyControlPoint typed: + difficultyPoints.Add(typed); + return; + + default: + base.GroupItemAdded(controlPoint); + break; + } } protected override void GroupItemRemoved(ControlPoint controlPoint) { - if (controlPoint is SampleControlPoint typed) - samplePoints.Remove(typed); + switch (controlPoint) + { + case SampleControlPoint typed: + samplePoints.Remove(typed); + break; + + case DifficultyControlPoint typed: + difficultyPoints.Remove(typed); + break; + } base.GroupItemRemoved(controlPoint); } diff --git a/osu.Game/Beatmaps/WorkingBeatmap.cs b/osu.Game/Beatmaps/WorkingBeatmap.cs index 18adecb7aa..d2c0f7de0f 100644 --- a/osu.Game/Beatmaps/WorkingBeatmap.cs +++ b/osu.Game/Beatmaps/WorkingBeatmap.cs @@ -189,11 +189,14 @@ namespace osu.Game.Beatmaps /// public void CancelAsyncLoad() { - loadCancellation?.Cancel(); - loadCancellation = new CancellationTokenSource(); + lock (beatmapFetchLock) + { + loadCancellation?.Cancel(); + loadCancellation = new CancellationTokenSource(); - if (beatmapLoadTask?.IsCompleted != true) - beatmapLoadTask = null; + if (beatmapLoadTask?.IsCompleted != true) + beatmapLoadTask = null; + } } private CancellationTokenSource createCancellationTokenSource(TimeSpan? timeout) @@ -205,19 +208,27 @@ namespace osu.Game.Beatmaps return new CancellationTokenSource(timeout ?? TimeSpan.FromSeconds(10)); } - private Task loadBeatmapAsync() => beatmapLoadTask ??= Task.Factory.StartNew(() => + private readonly object beatmapFetchLock = new object(); + + private Task loadBeatmapAsync() { - // Todo: Handle cancellation during beatmap parsing - var b = GetBeatmap() ?? new Beatmap(); + lock (beatmapFetchLock) + { + return beatmapLoadTask ??= Task.Factory.StartNew(() => + { + // Todo: Handle cancellation during beatmap parsing + var b = GetBeatmap() ?? new Beatmap(); - // The original beatmap version needs to be preserved as the database doesn't contain it - BeatmapInfo.BeatmapVersion = b.BeatmapInfo.BeatmapVersion; + // The original beatmap version needs to be preserved as the database doesn't contain it + BeatmapInfo.BeatmapVersion = b.BeatmapInfo.BeatmapVersion; - // Use the database-backed info for more up-to-date values (beatmap id, ranked status, etc) - b.BeatmapInfo = BeatmapInfo; + // Use the database-backed info for more up-to-date values (beatmap id, ranked status, etc) + b.BeatmapInfo = BeatmapInfo; - return b; - }, loadCancellation.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); + return b; + }, loadCancellation.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); + } + } public override string ToString() => BeatmapInfo.ToString(); diff --git a/osu.Game/Beatmaps/WorkingBeatmapCache.cs b/osu.Game/Beatmaps/WorkingBeatmapCache.cs index ad3e890b3a..cf83345e2a 100644 --- a/osu.Game/Beatmaps/WorkingBeatmapCache.cs +++ b/osu.Game/Beatmaps/WorkingBeatmapCache.cs @@ -66,8 +66,12 @@ namespace osu.Game.Beatmaps lock (workingCache) { var working = workingCache.FirstOrDefault(w => w.BeatmapInfo?.ID == info.ID); + if (working != null) + { + Logger.Log($"Invalidating working beatmap cache for {info}"); workingCache.Remove(working); + } } } @@ -86,6 +90,7 @@ namespace osu.Game.Beatmaps lock (workingCache) { var working = workingCache.FirstOrDefault(w => w.BeatmapInfo?.ID == beatmapInfo.ID); + if (working != null) return working; diff --git a/osu.Game/Collections/CollectionFilterDropdown.cs b/osu.Game/Collections/CollectionFilterDropdown.cs index 1eceb56e33..7067f82fd3 100644 --- a/osu.Game/Collections/CollectionFilterDropdown.cs +++ b/osu.Game/Collections/CollectionFilterDropdown.cs @@ -181,7 +181,11 @@ namespace osu.Game.Collections MaxHeight = 200; } - protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new CollectionDropdownMenuItem(item); + protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new CollectionDropdownMenuItem(item) + { + BackgroundColourHover = HoverColour, + BackgroundColourSelected = SelectionColour + }; } protected class CollectionDropdownMenuItem : OsuDropdownMenu.DrawableOsuDropdownMenuItem diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 9b0d7f51da..6d37f68473 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -6,10 +6,13 @@ using System.Diagnostics; using osu.Framework.Configuration; using osu.Framework.Configuration.Tracking; using osu.Framework.Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Input; using osu.Game.Input.Bindings; +using osu.Game.Localisation; using osu.Game.Overlays; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Select; @@ -185,20 +188,54 @@ namespace osu.Game.Configuration return new TrackedSettings { - new TrackedSetting(OsuSetting.MouseDisableButtons, v => new SettingDescription(!v, "gameplay mouse buttons", v ? "disabled" : "enabled", LookupKeyBindings(GlobalAction.ToggleGameplayMouseButtons))), - new TrackedSetting(OsuSetting.HUDVisibilityMode, m => new SettingDescription(m, "HUD Visibility", m.GetDescription(), $"cycle: {LookupKeyBindings(GlobalAction.ToggleInGameInterface)} quick view: {LookupKeyBindings(GlobalAction.HoldForHUD)}")), - new TrackedSetting(OsuSetting.Scaling, m => new SettingDescription(m, "scaling", m.GetDescription())), - new TrackedSetting(OsuSetting.Skin, m => + new TrackedSetting(OsuSetting.MouseDisableButtons, disabledState => new SettingDescription( + rawValue: !disabledState, + name: GlobalActionKeyBindingStrings.ToggleGameplayMouseButtons, + value: disabledState ? CommonStrings.Disabled.ToLower() : CommonStrings.Enabled.ToLower(), + shortcut: LookupKeyBindings(GlobalAction.ToggleGameplayMouseButtons)) + ), + new TrackedSetting(OsuSetting.HUDVisibilityMode, visibilityMode => new SettingDescription( + rawValue: visibilityMode, + name: GameplaySettingsStrings.HUDVisibilityMode, + value: visibilityMode.GetLocalisableDescription(), + shortcut: new TranslatableString(@"_", @"{0}: {1} {2}: {3}", + GlobalActionKeyBindingStrings.ToggleInGameInterface, + LookupKeyBindings(GlobalAction.ToggleInGameInterface), + GlobalActionKeyBindingStrings.HoldForHUD, + LookupKeyBindings(GlobalAction.HoldForHUD))) + ), + new TrackedSetting(OsuSetting.Scaling, scalingMode => new SettingDescription( + rawValue: scalingMode, + name: GraphicsSettingsStrings.ScreenScaling, + value: scalingMode.GetLocalisableDescription() + ) + ), + new TrackedSetting(OsuSetting.Skin, skin => { - string skinName = LookupSkinName(m) ?? string.Empty; - return new SettingDescription(skinName, "skin", skinName, $"random: {LookupKeyBindings(GlobalAction.RandomSkin)}"); - }) + string skinName = LookupSkinName(skin) ?? string.Empty; + + return new SettingDescription( + rawValue: skinName, + name: SkinSettingsStrings.SkinSectionHeader, + value: skinName, + shortcut: new TranslatableString(@"_", @"{0}: {1}", + GlobalActionKeyBindingStrings.RandomSkin, + LookupKeyBindings(GlobalAction.RandomSkin)) + ); + }), + new TrackedSetting(OsuSetting.UIScale, scale => new SettingDescription( + rawValue: scale, + name: GraphicsSettingsStrings.UIScaling, + value: $"{scale:N2}x" + // TODO: implement lookup for framework platform key bindings + ) + ), }; } public Func LookupSkinName { private get; set; } - public Func LookupKeyBindings { get; set; } + public Func LookupKeyBindings { get; set; } } // IMPORTANT: These are used in user configuration files. diff --git a/osu.Game/Configuration/RandomSelectAlgorithm.cs b/osu.Game/Configuration/RandomSelectAlgorithm.cs index 8d0c87374f..b22f2ae485 100644 --- a/osu.Game/Configuration/RandomSelectAlgorithm.cs +++ b/osu.Game/Configuration/RandomSelectAlgorithm.cs @@ -10,7 +10,7 @@ namespace osu.Game.Configuration [Description("Never repeat")] RandomPermutation, - [Description("Random")] + [Description("True Random")] Random } } diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs index ee1a7e2900..f3ed2d735b 100644 --- a/osu.Game/Database/ArchiveModelManager.cs +++ b/osu.Game/Database/ArchiveModelManager.cs @@ -30,7 +30,7 @@ namespace osu.Game.Database /// /// The model type. /// The associated file join type. - public abstract class ArchiveModelManager : ICanAcceptFiles, IModelManager, IModelFileManager, IPostImports + public abstract class ArchiveModelManager : IModelManager, IModelFileManager where TModel : class, IHasFiles, IHasPrimaryKey, ISoftDelete where TFileModel : class, INamedFileInfo, new() { @@ -116,7 +116,7 @@ namespace osu.Game.Database /// One or more archive locations on disk. public Task Import(params string[] paths) { - var notification = new ProgressNotification { State = ProgressNotificationState.Active }; + var notification = new ImportProgressNotification(); PostNotification?.Invoke(notification); @@ -125,7 +125,7 @@ namespace osu.Game.Database public Task Import(params ImportTask[] tasks) { - var notification = new ProgressNotification { State = ProgressNotificationState.Active }; + var notification = new ImportProgressNotification(); PostNotification?.Invoke(notification); @@ -462,10 +462,12 @@ namespace osu.Game.Database if (retrievedItem == null) throw new ArgumentException(@"Specified model could not be found", nameof(item)); - using (var outputStream = exportStorage.GetStream($"{getValidFilename(item.ToString())}{HandledExtensions.First()}", FileAccess.Write, FileMode.Create)) - ExportModelTo(retrievedItem, outputStream); + string filename = $"{getValidFilename(item.ToString())}{HandledExtensions.First()}"; - exportStorage.OpenInNativeExplorer(); + using (var stream = exportStorage.GetStream(filename, FileAccess.Write, FileMode.Create)) + ExportModelTo(retrievedItem, stream); + + exportStorage.PresentFileExternally(filename); } /// diff --git a/osu.Game/Database/IHasOnlineID.cs b/osu.Game/Database/IHasOnlineID.cs index c55c461d2d..6e2be7e1f9 100644 --- a/osu.Game/Database/IHasOnlineID.cs +++ b/osu.Game/Database/IHasOnlineID.cs @@ -8,8 +8,12 @@ namespace osu.Game.Database public interface IHasOnlineID { /// - /// The server-side ID representing this instance, if one exists. + /// The server-side ID representing this instance, if one exists. Any value 0 or less denotes a missing ID. /// - int? OnlineID { get; } + /// + /// Generally we use -1 when specifying "missing" in code, but values of 0 are also considered missing as the online source + /// is generally a MySQL autoincrement value, which can never be 0. + /// + int OnlineID { get; } } } diff --git a/osu.Game/Database/IHasRealmFiles.cs b/osu.Game/Database/IHasRealmFiles.cs new file mode 100644 index 0000000000..024d9f2a89 --- /dev/null +++ b/osu.Game/Database/IHasRealmFiles.cs @@ -0,0 +1,20 @@ +// 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.Game.Models; + +#nullable enable + +namespace osu.Game.Database +{ + /// + /// A model that contains a list of files it is responsible for. + /// + public interface IHasRealmFiles + { + IList Files { get; } + + string Hash { get; set; } + } +} diff --git a/osu.Game/Database/IModelImporter.cs b/osu.Game/Database/IModelImporter.cs index e94af01772..5d0a044578 100644 --- a/osu.Game/Database/IModelImporter.cs +++ b/osu.Game/Database/IModelImporter.cs @@ -10,24 +10,12 @@ using osu.Game.Overlays.Notifications; namespace osu.Game.Database { /// - /// A class which handles importing of asociated models to the game store. + /// A class which handles importing of associated models to the game store. /// /// The model type. - public interface IModelImporter : IPostNotifications + public interface IModelImporter : IPostNotifications, IPostImports, ICanAcceptFiles where TModel : class { - /// - /// Import one or more items from filesystem . - /// - /// - /// This will be treated as a low priority import if more than one path is specified; use to always import at standard priority. - /// This will post notifications tracking progress. - /// - /// One or more archive locations on disk. - Task Import(params string[] paths); - - Task Import(params ImportTask[] tasks); - Task>> Import(ProgressNotification notification, params ImportTask[] tasks); /// diff --git a/osu.Game/Database/INamedFile.cs b/osu.Game/Database/INamedFile.cs new file mode 100644 index 0000000000..2bd45d4e42 --- /dev/null +++ b/osu.Game/Database/INamedFile.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Models; + +#nullable enable + +namespace osu.Game.Database +{ + /// + /// Represents a join model which gives a filename and scope to a . + /// + public interface INamedFile + { + string Filename { get; set; } + + RealmFile File { get; set; } + } +} diff --git a/osu.Game/Database/IPostImports.cs b/osu.Game/Database/IPostImports.cs index f09285089a..b3b83f23ef 100644 --- a/osu.Game/Database/IPostImports.cs +++ b/osu.Game/Database/IPostImports.cs @@ -4,6 +4,8 @@ using System; using System.Collections.Generic; +#nullable enable + namespace osu.Game.Database { public interface IPostImports @@ -12,6 +14,6 @@ namespace osu.Game.Database /// /// Fired when the user requests to view the resulting import. /// - public Action>> PostImport { set; } + public Action>>? PostImport { set; } } } diff --git a/osu.Game/Database/ImportProgressNotification.cs b/osu.Game/Database/ImportProgressNotification.cs new file mode 100644 index 0000000000..aaee3e117f --- /dev/null +++ b/osu.Game/Database/ImportProgressNotification.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. + +using osu.Game.Overlays.Notifications; + +namespace osu.Game.Database +{ + public class ImportProgressNotification : ProgressNotification + { + public ImportProgressNotification() + { + State = ProgressNotificationState.Active; + } + } +} diff --git a/osu.Game/Database/RealmContextFactory.cs b/osu.Game/Database/RealmContextFactory.cs index 0ff902a8bc..3d0bb34dc1 100644 --- a/osu.Game/Database/RealmContextFactory.cs +++ b/osu.Game/Database/RealmContextFactory.cs @@ -2,13 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using System.Threading; using osu.Framework.Allocation; using osu.Framework.Development; -using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Statistics; +using osu.Game.Models; using Realms; #nullable enable @@ -18,7 +19,7 @@ namespace osu.Game.Database /// /// A factory which provides both the main (update thread bound) realm context and creates contexts for async usage. /// - public class RealmContextFactory : Component, IRealmFactory + public class RealmContextFactory : IDisposable, IRealmFactory { private readonly Storage storage; @@ -27,7 +28,12 @@ namespace osu.Game.Database /// public readonly string Filename; - private const int schema_version = 6; + /// + /// Version history: + /// 6 First tracked version (~20211018) + /// 7 Changed OnlineID fields to non-nullable to add indexing support (20211018) + /// + private const int schema_version = 7; /// /// Lock object which is held during sections, blocking context creation during blocking periods. @@ -71,6 +77,27 @@ namespace osu.Game.Database if (!Filename.EndsWith(realm_extension, StringComparison.Ordinal)) Filename += realm_extension; + + cleanupPendingDeletions(); + } + + private void cleanupPendingDeletions() + { + using (var realm = CreateContext()) + using (var transaction = realm.BeginWrite()) + { + var pendingDeleteSets = realm.All().Where(s => s.DeletePending); + + foreach (var s in pendingDeleteSets) + { + foreach (var b in s.Beatmaps) + realm.Remove(b); + + realm.Remove(s); + } + + transaction.Commit(); + } } /// @@ -79,10 +106,11 @@ namespace osu.Game.Database /// public bool Compact() => Realm.Compact(getConfiguration()); - protected override void Update() + /// + /// Perform a blocking refresh on the main realm context. + /// + public void Refresh() { - base.Update(); - lock (contextLock) { if (context?.Refresh() == true) @@ -92,7 +120,7 @@ namespace osu.Game.Database public Realm CreateContext() { - if (IsDisposed) + if (isDisposed) throw new ObjectDisposedException(nameof(RealmContextFactory)); try @@ -120,6 +148,36 @@ namespace osu.Game.Database private void onMigration(Migration migration, ulong lastSchemaVersion) { + if (lastSchemaVersion < 7) + { + convertOnlineIDs(); + convertOnlineIDs(); + convertOnlineIDs(); + + void convertOnlineIDs() where T : RealmObject + { + var className = typeof(T).Name.Replace(@"Realm", string.Empty); + + // version was not bumped when the beatmap/ruleset models were added + // therefore we must manually check for their presence to avoid throwing on the `DynamicApi` calls. + if (!migration.OldRealm.Schema.TryFindObjectSchema(className, out _)) + return; + + var oldItems = migration.OldRealm.DynamicApi.All(className); + var newItems = migration.NewRealm.DynamicApi.All(className); + + int itemCount = newItems.Count(); + + for (int i = 0; i < itemCount; i++) + { + var oldItem = oldItems.ElementAt(i); + var newItem = newItems.ElementAt(i); + + long? nullableOnlineID = oldItem?.OnlineID; + newItem.OnlineID = (int)(nullableOnlineID ?? -1); + } + } + } } /// @@ -132,12 +190,11 @@ namespace osu.Game.Database /// An which should be disposed to end the blocking section. public IDisposable BlockAllOperations() { - if (IsDisposed) + if (isDisposed) throw new ObjectDisposedException(nameof(RealmContextFactory)); - // TODO: this can be added for safety once we figure how to bypass in test - // if (!ThreadSafety.IsUpdateThread) - // throw new InvalidOperationException($"{nameof(BlockAllOperations)} must be called from the update thread."); + if (!ThreadSafety.IsUpdateThread) + throw new InvalidOperationException($"{nameof(BlockAllOperations)} must be called from the update thread."); Logger.Log(@"Blocking realm operations.", LoggingTarget.Database); @@ -177,21 +234,23 @@ namespace osu.Game.Database }); } - protected override void Dispose(bool isDisposing) + private bool isDisposed; + + public void Dispose() { lock (contextLock) { context?.Dispose(); } - if (!IsDisposed) + if (!isDisposed) { // intentionally block context creation indefinitely. this ensures that nothing can start consuming a new context after disposal. contextCreationLock.Wait(); contextCreationLock.Dispose(); - } - base.Dispose(isDisposing); + isDisposed = true; + } } } } diff --git a/osu.Game/Database/RealmLive.cs b/osu.Game/Database/RealmLive.cs new file mode 100644 index 0000000000..abb69644d6 --- /dev/null +++ b/osu.Game/Database/RealmLive.cs @@ -0,0 +1,111 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Threading; +using Realms; + +#nullable enable + +namespace osu.Game.Database +{ + /// + /// Provides a method of working with realm objects over longer application lifetimes. + /// + /// The underlying object type. + public class RealmLive : ILive where T : RealmObject, IHasGuidPrimaryKey + { + public Guid ID { get; } + + private readonly SynchronizationContext? fetchedContext; + private readonly int fetchedThreadId; + + /// + /// The original live data used to create this instance. + /// + private readonly T data; + + /// + /// Construct a new instance of live realm data. + /// + /// The realm data. + public RealmLive(T data) + { + this.data = data; + + fetchedContext = SynchronizationContext.Current; + fetchedThreadId = Thread.CurrentThread.ManagedThreadId; + + ID = data.ID; + } + + /// + /// Perform a read operation on this live object. + /// + /// The action to perform. + public void PerformRead(Action perform) + { + if (originalDataValid) + { + perform(data); + return; + } + + using (var realm = Realm.GetInstance(data.Realm.Config)) + perform(realm.Find(ID)); + } + + /// + /// Perform a read operation on this live object. + /// + /// The action to perform. + public TReturn PerformRead(Func perform) + { + if (typeof(RealmObjectBase).IsAssignableFrom(typeof(TReturn))) + throw new InvalidOperationException($"Realm live objects should not exit the scope of {nameof(PerformRead)}."); + + if (originalDataValid) + return perform(data); + + using (var realm = Realm.GetInstance(data.Realm.Config)) + return perform(realm.Find(ID)); + } + + /// + /// Perform a write operation on this live object. + /// + /// The action to perform. + public void PerformWrite(Action perform) => + PerformRead(t => + { + var transaction = t.Realm.BeginWrite(); + perform(t); + transaction.Commit(); + }); + + public T Value + { + get + { + if (originalDataValid) + return data; + + T retrieved; + + using (var realm = Realm.GetInstance(data.Realm.Config)) + retrieved = realm.Find(ID); + + if (!retrieved.IsValid) + throw new InvalidOperationException("Attempted to access value without an open context"); + + return retrieved; + } + } + + private bool originalDataValid => isCorrectThread && data.IsValid; + + // this matches realm's internal thread validation (see https://github.com/realm/realm-dotnet/blob/903b4d0b304f887e37e2d905384fb572a6496e70/Realm/Realm/Native/SynchronizationContextScheduler.cs#L72) + private bool isCorrectThread + => (fetchedContext != null && SynchronizationContext.Current == fetchedContext) || fetchedThreadId == Thread.CurrentThread.ManagedThreadId; + } +} diff --git a/osu.Game/Database/RealmObjectExtensions.cs b/osu.Game/Database/RealmObjectExtensions.cs index c5aa1399a3..18a926fa8c 100644 --- a/osu.Game/Database/RealmObjectExtensions.cs +++ b/osu.Game/Database/RealmObjectExtensions.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Linq; using AutoMapper; using osu.Game.Input.Bindings; using Realms; @@ -47,5 +48,17 @@ namespace osu.Game.Database return mapper.Map(item); } + + public static List> ToLive(this IEnumerable realmList) + where T : RealmObject, IHasGuidPrimaryKey + { + return realmList.Select(l => new RealmLive(l)).ToList(); + } + + public static RealmLive ToLive(this T realmObject) + where T : RealmObject, IHasGuidPrimaryKey + { + return new RealmLive(realmObject); + } } } diff --git a/osu.Game/Graphics/Containers/OsuScrollContainer.cs b/osu.Game/Graphics/Containers/OsuScrollContainer.cs index aaad72f65c..017ea6ec32 100644 --- a/osu.Game/Graphics/Containers/OsuScrollContainer.cs +++ b/osu.Game/Graphics/Containers/OsuScrollContainer.cs @@ -1,11 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Game.Overlays; using osuTK; using osuTK.Graphics; using osuTK.Input; @@ -141,12 +144,12 @@ namespace osu.Game.Graphics.Containers Child = box = new Box { RelativeSizeAxes = Axes.Both }; } - [BackgroundDependencyLoader] - private void load(OsuColour colours) + [BackgroundDependencyLoader(true)] + private void load(OverlayColourProvider? colourProvider, OsuColour colours) { Colour = defaultColour = colours.Gray8; hoverColour = colours.GrayF; - highlightColour = colours.Green; + highlightColour = colourProvider?.Highlight1 ?? colours.Green; } public override void ResizeTo(float val, int duration = 0, Easing easing = Easing.None) diff --git a/osu.Game/Graphics/OsuColour.cs b/osu.Game/Graphics/OsuColour.cs index d7cfc4094c..3aa4dbf1d8 100644 --- a/osu.Game/Graphics/OsuColour.cs +++ b/osu.Game/Graphics/OsuColour.cs @@ -118,6 +118,42 @@ namespace osu.Game.Graphics } } + /// + /// Retrieves a colour for the given . + /// A value indicates that a "background" shade from the local + /// (or another fallback colour) should be used. + /// + /// + /// Sourced from web: https://github.com/ppy/osu-web/blob/007eebb1916ed5cb6a7866d82d8011b1060a945e/resources/assets/less/layout.less#L36-L50 + /// + public static Color4? ForBeatmapSetOnlineStatus(BeatmapSetOnlineStatus status) + { + switch (status) + { + case BeatmapSetOnlineStatus.Ranked: + case BeatmapSetOnlineStatus.Approved: + return Color4Extensions.FromHex(@"b3ff66"); + + case BeatmapSetOnlineStatus.Loved: + return Color4Extensions.FromHex(@"ff66ab"); + + case BeatmapSetOnlineStatus.Qualified: + return Color4Extensions.FromHex(@"66ccff"); + + case BeatmapSetOnlineStatus.Pending: + return Color4Extensions.FromHex(@"ffd966"); + + case BeatmapSetOnlineStatus.WIP: + return Color4Extensions.FromHex(@"ff9966"); + + case BeatmapSetOnlineStatus.Graveyard: + return Color4.Black; + + default: + return null; + } + } + /// /// Returns a foreground text colour that is supposed to contrast well with /// the supplied . @@ -225,11 +261,28 @@ namespace osu.Game.Graphics public readonly Color4 GrayE = Color4Extensions.FromHex(@"eee"); public readonly Color4 GrayF = Color4Extensions.FromHex(@"fff"); + /// + /// Equivalent to 's . + /// + public readonly Color4 Pink3 = Color4Extensions.FromHex(@"cc3378"); + + /// + /// Equivalent to 's . + /// + public readonly Color4 Blue3 = Color4Extensions.FromHex(@"3399cc"); + + public readonly Color4 Lime0 = Color4Extensions.FromHex(@"ccff99"); + /// /// Equivalent to 's . /// public readonly Color4 Lime1 = Color4Extensions.FromHex(@"b2ff66"); + /// + /// Equivalent to 's . + /// + public readonly Color4 Lime3 = Color4Extensions.FromHex(@"7fcc33"); + /// /// Equivalent to 's . /// diff --git a/osu.Game/Graphics/ScreenshotManager.cs b/osu.Game/Graphics/ScreenshotManager.cs index 9cd403f409..e652f07239 100644 --- a/osu.Game/Graphics/ScreenshotManager.cs +++ b/osu.Game/Graphics/ScreenshotManager.cs @@ -109,40 +109,42 @@ namespace osu.Game.Graphics if (Interlocked.Decrement(ref screenShotTasks) == 0 && cursorVisibility.Value == false) cursorVisibility.Value = true; - var fileName = getFileName(); - if (fileName == null) return; + string filename = getFilename(); - var stream = storage.GetStream(fileName, FileAccess.Write); + if (filename == null) return; - switch (screenshotFormat.Value) + using (var stream = storage.GetStream(filename, FileAccess.Write)) { - case ScreenshotFormat.Png: - await image.SaveAsPngAsync(stream).ConfigureAwait(false); - break; + switch (screenshotFormat.Value) + { + case ScreenshotFormat.Png: + await image.SaveAsPngAsync(stream).ConfigureAwait(false); + break; - case ScreenshotFormat.Jpg: - const int jpeg_quality = 92; + case ScreenshotFormat.Jpg: + const int jpeg_quality = 92; - await image.SaveAsJpegAsync(stream, new JpegEncoder { Quality = jpeg_quality }).ConfigureAwait(false); - break; + await image.SaveAsJpegAsync(stream, new JpegEncoder { Quality = jpeg_quality }).ConfigureAwait(false); + break; - default: - throw new InvalidOperationException($"Unknown enum member {nameof(ScreenshotFormat)} {screenshotFormat.Value}."); + default: + throw new InvalidOperationException($"Unknown enum member {nameof(ScreenshotFormat)} {screenshotFormat.Value}."); + } } notificationOverlay.Post(new SimpleNotification { - Text = $"{fileName} saved!", + Text = $"{filename} saved!", Activated = () => { - storage.OpenInNativeExplorer(); + storage.PresentFileExternally(filename); return true; } }); } }); - private string getFileName() + private string getFilename() { var dt = DateTime.Now; var fileExt = screenshotFormat.ToString().ToLowerInvariant(); diff --git a/osu.Game/Graphics/UserInterface/CommaSeparatedScoreCounter.cs b/osu.Game/Graphics/UserInterface/CommaSeparatedScoreCounter.cs new file mode 100644 index 0000000000..4e1c612f09 --- /dev/null +++ b/osu.Game/Graphics/UserInterface/CommaSeparatedScoreCounter.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Graphics.Sprites; + +namespace osu.Game.Graphics.UserInterface +{ + public abstract class CommaSeparatedScoreCounter : RollingCounter + { + protected override double RollingDuration => 1000; + protected override Easing RollingEasing => Easing.Out; + + protected override double GetProportionalDuration(double currentValue, double newValue) => + currentValue > newValue ? currentValue - newValue : newValue - currentValue; + + protected override LocalisableString FormatCount(double count) => ((long)count).ToLocalisableString(@"N0"); + + protected override OsuSpriteText CreateSpriteText() + => base.CreateSpriteText().With(s => s.Font = s.Font.With(fixedWidth: true)); + } +} diff --git a/osu.Game/Graphics/UserInterface/FocusedTextBox.cs b/osu.Game/Graphics/UserInterface/FocusedTextBox.cs index ceea9620c8..88608bf43c 100644 --- a/osu.Game/Graphics/UserInterface/FocusedTextBox.cs +++ b/osu.Game/Graphics/UserInterface/FocusedTextBox.cs @@ -1,6 +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 enable + using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Input.Events; @@ -8,6 +10,7 @@ using osu.Framework.Platform; using osu.Game.Input.Bindings; using osuTK.Input; using osu.Framework.Input.Bindings; +using osu.Game.Overlays; namespace osu.Game.Graphics.UserInterface { @@ -42,13 +45,13 @@ namespace osu.Game.Graphics.UserInterface } [Resolved] - private GameHost host { get; set; } + private GameHost? host { get; set; } - [BackgroundDependencyLoader] - private void load() + [BackgroundDependencyLoader(true)] + private void load(OverlayColourProvider? colourProvider) { - BackgroundUnfocused = new Color4(10, 10, 10, 255); - BackgroundFocused = new Color4(10, 10, 10, 255); + BackgroundUnfocused = colourProvider?.Background5 ?? new Color4(10, 10, 10, 255); + BackgroundFocused = colourProvider?.Background5 ?? new Color4(10, 10, 10, 255); } // We may not be focused yet, but we need to handle keyboard input to be able to request focus diff --git a/osu.Game/Graphics/UserInterface/Nub.cs b/osu.Game/Graphics/UserInterface/Nub.cs index 6807d007bb..8f0fed580f 100644 --- a/osu.Game/Graphics/UserInterface/Nub.cs +++ b/osu.Game/Graphics/UserInterface/Nub.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using JetBrains.Annotations; using osuTK; using osuTK.Graphics; using osu.Framework.Allocation; @@ -12,63 +13,74 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; +using osu.Game.Overlays; namespace osu.Game.Graphics.UserInterface { - public class Nub : CircularContainer, IHasCurrentValue, IHasAccentColour + public class Nub : CompositeDrawable, IHasCurrentValue, IHasAccentColour { - public const float COLLAPSED_SIZE = 20; - public const float EXPANDED_SIZE = 40; + public const float HEIGHT = 15; + + public const float EXPANDED_SIZE = 50; private const float border_width = 3; - private const double animate_in_duration = 150; + private const double animate_in_duration = 200; private const double animate_out_duration = 500; + private readonly Box fill; + private readonly Container main; + public Nub() { - Box fill; + Size = new Vector2(EXPANDED_SIZE, HEIGHT); - Size = new Vector2(COLLAPSED_SIZE, 12); - - BorderColour = Color4.White; - BorderThickness = border_width; - - Masking = true; - - Children = new[] + InternalChildren = new[] { - fill = new Box + main = new CircularContainer { + BorderColour = Color4.White, + BorderThickness = border_width, + Masking = true, RelativeSizeAxes = Axes.Both, - Alpha = 0, - AlwaysPresent = true, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Children = new Drawable[] + { + fill = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true, + }, + } }, }; - - Current.ValueChanged += filled => - { - fill.FadeTo(filled.NewValue ? 1 : 0, 200, Easing.OutQuint); - this.TransformTo(nameof(BorderThickness), filled.NewValue ? 8.5f : border_width, 200, Easing.OutQuint); - }; } - [BackgroundDependencyLoader] - private void load(OsuColour colours) + [BackgroundDependencyLoader(true)] + private void load([CanBeNull] OverlayColourProvider colourProvider, OsuColour colours) { - AccentColour = colours.Pink; - GlowingAccentColour = colours.PinkLighter; - GlowColour = colours.PinkDarker; + AccentColour = colourProvider?.Highlight1 ?? colours.Pink; + GlowingAccentColour = colourProvider?.Highlight1.Lighten(0.2f) ?? colours.PinkLighter; + GlowColour = colourProvider?.Highlight1 ?? colours.PinkLighter; - EdgeEffect = new EdgeEffectParameters + main.EdgeEffect = new EdgeEffectParameters { Colour = GlowColour.Opacity(0), Type = EdgeEffectType.Glow, - Radius = 10, - Roundness = 8, + Radius = 8, + Roundness = 5, }; } + protected override void LoadComplete() + { + base.LoadComplete(); + + Current.BindValueChanged(onCurrentValueChanged, true); + } + private bool glowing; public bool Glowing @@ -80,28 +92,17 @@ namespace osu.Game.Graphics.UserInterface if (value) { - this.FadeColour(GlowingAccentColour, animate_in_duration, Easing.OutQuint); - FadeEdgeEffectTo(1, animate_in_duration, Easing.OutQuint); + main.FadeColour(GlowingAccentColour, animate_in_duration, Easing.OutQuint); + main.FadeEdgeEffectTo(0.2f, animate_in_duration, Easing.OutQuint); } else { - FadeEdgeEffectTo(0, animate_out_duration); - this.FadeColour(AccentColour, animate_out_duration); + main.FadeEdgeEffectTo(0, animate_out_duration, Easing.OutQuint); + main.FadeColour(AccentColour, animate_out_duration, Easing.OutQuint); } } } - public bool Expanded - { - set - { - if (value) - this.ResizeTo(new Vector2(EXPANDED_SIZE, 12), animate_in_duration, Easing.OutQuint); - else - this.ResizeTo(new Vector2(COLLAPSED_SIZE, 12), animate_out_duration, Easing.OutQuint); - } - } - private readonly Bindable current = new Bindable(); public Bindable Current @@ -126,7 +127,7 @@ namespace osu.Game.Graphics.UserInterface { accentColour = value; if (!Glowing) - Colour = value; + main.Colour = value; } } @@ -139,7 +140,7 @@ namespace osu.Game.Graphics.UserInterface { glowingAccentColour = value; if (Glowing) - Colour = value; + main.Colour = value; } } @@ -152,10 +153,22 @@ namespace osu.Game.Graphics.UserInterface { glowColour = value; - var effect = EdgeEffect; + var effect = main.EdgeEffect; effect.Colour = Glowing ? value : value.Opacity(0); - EdgeEffect = effect; + main.EdgeEffect = effect; } } + + private void onCurrentValueChanged(ValueChangedEvent filled) + { + fill.FadeTo(filled.NewValue ? 1 : 0, 200, Easing.OutQuint); + + if (filled.NewValue) + main.ResizeWidthTo(1, animate_in_duration, Easing.OutElasticHalf); + else + main.ResizeWidthTo(0.9f, animate_out_duration, Easing.OutElastic); + + main.TransformTo(nameof(BorderThickness), filled.NewValue ? 8.5f : border_width, 200, Easing.OutQuint); + } } } diff --git a/osu.Game/Graphics/UserInterface/OsuCheckbox.cs b/osu.Game/Graphics/UserInterface/OsuCheckbox.cs index 5f2d884cd7..e8f80dec57 100644 --- a/osu.Game/Graphics/UserInterface/OsuCheckbox.cs +++ b/osu.Game/Graphics/UserInterface/OsuCheckbox.cs @@ -9,16 +9,11 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; -using osuTK.Graphics; namespace osu.Game.Graphics.UserInterface { public class OsuCheckbox : Checkbox { - public Color4 CheckedColor { get; set; } = Color4.Cyan; - public Color4 UncheckedColor { get; set; } = Color4.White; - public int FadeDuration { get; set; } - /// /// Whether to play sounds when the state changes as a result of user interaction. /// @@ -104,14 +99,12 @@ namespace osu.Game.Graphics.UserInterface protected override bool OnHover(HoverEvent e) { Nub.Glowing = true; - Nub.Expanded = true; return base.OnHover(e); } protected override void OnHoverLost(HoverLostEvent e) { Nub.Glowing = false; - Nub.Expanded = false; base.OnHoverLost(e); } diff --git a/osu.Game/Graphics/UserInterface/OsuDropdown.cs b/osu.Game/Graphics/UserInterface/OsuDropdown.cs index fe88e6f78a..b1d4691938 100644 --- a/osu.Game/Graphics/UserInterface/OsuDropdown.cs +++ b/osu.Game/Graphics/UserInterface/OsuDropdown.cs @@ -1,8 +1,9 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + using System.Linq; -using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; @@ -14,40 +15,15 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Localisation; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; +using osu.Game.Overlays; using osuTK; +using osuTK.Graphics; namespace osu.Game.Graphics.UserInterface { - public class OsuDropdown : Dropdown, IHasAccentColour + public class OsuDropdown : Dropdown { - private const float corner_radius = 4; - - private Color4 accentColour; - - public Color4 AccentColour - { - get => accentColour; - set - { - accentColour = value; - updateAccentColour(); - } - } - - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - if (accentColour == default) - accentColour = colours.PinkDarker; - updateAccentColour(); - } - - private void updateAccentColour() - { - if (Header is IHasAccentColour header) header.AccentColour = accentColour; - - if (Menu is IHasAccentColour menu) menu.AccentColour = accentColour; - } + private const float corner_radius = 5; protected override DropdownHeader CreateHeader() => new OsuDropdownHeader(); @@ -55,18 +31,17 @@ namespace osu.Game.Graphics.UserInterface #region OsuDropdownMenu - protected class OsuDropdownMenu : DropdownMenu, IHasAccentColour + protected class OsuDropdownMenu : DropdownMenu { public override bool HandleNonPositionalInput => State == MenuState.Open; - private Sample sampleOpen; - private Sample sampleClose; + private Sample? sampleOpen; + private Sample? sampleClose; // todo: this uses the same styling as OsuMenu. hopefully we can just use OsuMenu in the future with some refactoring public OsuDropdownMenu() { CornerRadius = corner_radius; - BackgroundColour = Color4.Black.Opacity(0.5f); MaskingContainer.CornerRadius = corner_radius; Alpha = 0; @@ -75,9 +50,13 @@ namespace osu.Game.Graphics.UserInterface ItemsContainer.Padding = new MarginPadding(5); } - [BackgroundDependencyLoader] - private void load(AudioManager audio) + [BackgroundDependencyLoader(true)] + private void load(OverlayColourProvider? colourProvider, OsuColour colours, AudioManager audio) { + BackgroundColour = colourProvider?.Background5 ?? Color4.Black.Opacity(0.5f); + HoverColour = colourProvider?.Light4 ?? colours.PinkDarker; + SelectionColour = colourProvider?.Background3 ?? colours.PinkDarker.Opacity(0.5f); + sampleOpen = audio.Samples.Get(@"UI/dropdown-open"); sampleClose = audio.Samples.Get(@"UI/dropdown-close"); } @@ -117,55 +96,77 @@ namespace osu.Game.Graphics.UserInterface } } - private Color4 accentColour; + private Color4 hoverColour; - public Color4 AccentColour + public Color4 HoverColour { - get => accentColour; + get => hoverColour; set { - accentColour = value; - foreach (var c in Children.OfType()) - c.AccentColour = value; + hoverColour = value; + foreach (var c in Children.OfType()) + c.BackgroundColourHover = value; + } + } + + private Color4 selectionColour; + + public Color4 SelectionColour + { + get => selectionColour; + set + { + selectionColour = value; + foreach (var c in Children.OfType()) + c.BackgroundColourSelected = value; } } protected override Menu CreateSubMenu() => new OsuMenu(Direction.Vertical); - protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new DrawableOsuDropdownMenuItem(item) { AccentColour = accentColour }; + protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new DrawableOsuDropdownMenuItem(item) + { + BackgroundColourHover = HoverColour, + BackgroundColourSelected = SelectionColour + }; protected override ScrollContainer CreateScrollContainer(Direction direction) => new OsuScrollContainer(direction); #region DrawableOsuDropdownMenuItem - public class DrawableOsuDropdownMenuItem : DrawableDropdownMenuItem, IHasAccentColour + public class DrawableOsuDropdownMenuItem : DrawableDropdownMenuItem { // IsHovered is used public override bool HandlePositionalInput => true; - private Color4? accentColour; - - public Color4 AccentColour + public new Color4 BackgroundColourHover { - get => accentColour ?? nonAccentSelectedColour; + get => base.BackgroundColourHover; set { - accentColour = value; + base.BackgroundColourHover = value; + updateColours(); + } + } + + public new Color4 BackgroundColourSelected + { + get => base.BackgroundColourSelected; + set + { + base.BackgroundColourSelected = value; updateColours(); } } private void updateColours() { - BackgroundColourHover = accentColour ?? nonAccentHoverColour; - BackgroundColourSelected = accentColour ?? nonAccentSelectedColour; + BackgroundColour = BackgroundColourHover.Opacity(0); + UpdateBackgroundColour(); UpdateForegroundColour(); } - private Color4 nonAccentHoverColour; - private Color4 nonAccentSelectedColour; - public DrawableOsuDropdownMenuItem(MenuItem item) : base(item) { @@ -176,27 +177,34 @@ namespace osu.Game.Graphics.UserInterface } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { - BackgroundColour = Color4.Transparent; - - nonAccentHoverColour = colours.PinkDarker; - nonAccentSelectedColour = Color4.Black.Opacity(0.5f); - updateColours(); - AddInternal(new HoverSounds()); } + protected override void UpdateBackgroundColour() + { + if (!IsPreSelected && !IsSelected) + { + Background.FadeOut(600, Easing.OutQuint); + return; + } + + Background.FadeIn(100, Easing.OutQuint); + Background.FadeColour(IsPreSelected ? BackgroundColourHover : BackgroundColourSelected, 100, Easing.OutQuint); + } + protected override void UpdateForegroundColour() { base.UpdateForegroundColour(); - if (Foreground.Children.FirstOrDefault() is Content content) content.Chevron.Alpha = IsHovered ? 1 : 0; + if (Foreground.Children.FirstOrDefault() is Content content) + content.Hovering = IsHovered; } protected override Drawable CreateContent() => new Content(); - protected new class Content : FillFlowContainer, IHasText + protected new class Content : CompositeDrawable, IHasText { public LocalisableString Text { @@ -207,32 +215,64 @@ namespace osu.Game.Graphics.UserInterface public readonly OsuSpriteText Label; public readonly SpriteIcon Chevron; + private const float chevron_offset = -3; + public Content() { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - Direction = FillDirection.Horizontal; - Children = new Drawable[] + InternalChildren = new Drawable[] { Chevron = new SpriteIcon { - AlwaysPresent = true, Icon = FontAwesome.Solid.ChevronRight, - Colour = Color4.Black, - Alpha = 0.5f, Size = new Vector2(8), + Alpha = 0, + X = chevron_offset, Margin = new MarginPadding { Left = 3, Right = 3 }, Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, }, Label = new OsuSpriteText { + X = 15, Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, }, }; } + + [BackgroundDependencyLoader(true)] + private void load(OverlayColourProvider? colourProvider) + { + Chevron.Colour = colourProvider?.Background5 ?? Color4.Black; + } + + private bool hovering; + + public bool Hovering + { + get => hovering; + set + { + if (value == hovering) + return; + + hovering = value; + + if (hovering) + { + Chevron.FadeIn(400, Easing.OutQuint); + Chevron.MoveToX(0, 400, Easing.OutQuint); + } + else + { + Chevron.FadeOut(200); + Chevron.MoveToX(chevron_offset, 200, Easing.In); + } + } + } } } @@ -241,7 +281,7 @@ namespace osu.Game.Graphics.UserInterface #endregion - public class OsuDropdownHeader : DropdownHeader, IHasAccentColour + public class OsuDropdownHeader : DropdownHeader { protected readonly SpriteText Text; @@ -253,21 +293,9 @@ namespace osu.Game.Graphics.UserInterface protected readonly SpriteIcon Icon; - private Color4 accentColour; - - public virtual Color4 AccentColour - { - get => accentColour; - set - { - accentColour = value; - BackgroundColourHover = accentColour; - } - } - public OsuDropdownHeader() { - Foreground.Padding = new MarginPadding(4); + Foreground.Padding = new MarginPadding(10); AutoSizeAxes = Axes.None; Margin = new MarginPadding { Bottom = 4 }; @@ -303,8 +331,7 @@ namespace osu.Game.Graphics.UserInterface Icon = FontAwesome.Solid.ChevronDown, Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, - Margin = new MarginPadding { Horizontal = 5 }, - Size = new Vector2(12), + Size = new Vector2(16), }, } } @@ -313,11 +340,11 @@ namespace osu.Game.Graphics.UserInterface AddInternal(new HoverClickSounds()); } - [BackgroundDependencyLoader] - private void load(OsuColour colours) + [BackgroundDependencyLoader(true)] + private void load(OverlayColourProvider? colourProvider, OsuColour colours) { - BackgroundColour = Color4.Black.Opacity(0.5f); - BackgroundColourHover = colours.PinkDarker; + BackgroundColour = colourProvider?.Background5 ?? Color4.Black.Opacity(0.5f); + BackgroundColourHover = colourProvider?.Light4 ?? colours.PinkDarker; } } } diff --git a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs index f85f9327fa..6963f7335e 100644 --- a/osu.Game/Graphics/UserInterface/OsuSliderBar.cs +++ b/osu.Game/Graphics/UserInterface/OsuSliderBar.cs @@ -3,11 +3,13 @@ using System; using System.Globalization; +using JetBrains.Annotations; using osuTK; using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; +using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; @@ -16,6 +18,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Framework.Utils; +using osu.Game.Overlays; namespace osu.Game.Graphics.UserInterface { @@ -52,34 +55,63 @@ namespace osu.Game.Graphics.UserInterface { accentColour = value; leftBox.Colour = value; + } + } + + private Colour4 backgroundColour; + + public Color4 BackgroundColour + { + get => backgroundColour; + set + { + backgroundColour = value; rightBox.Colour = value; } } public OsuSliderBar() { - Height = 12; - RangePadding = 20; + Height = Nub.HEIGHT; + RangePadding = Nub.EXPANDED_SIZE / 2; Children = new Drawable[] { - leftBox = new Box + new Container { - Height = 2, - EdgeSmoothness = new Vector2(0, 0.5f), - Position = new Vector2(2, 0), - RelativeSizeAxes = Axes.None, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - }, - rightBox = new Box - { - Height = 2, - EdgeSmoothness = new Vector2(0, 0.5f), - Position = new Vector2(-2, 0), - RelativeSizeAxes = Axes.None, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - Alpha = 0.5f, + Padding = new MarginPadding { Horizontal = 2 }, + Child = new CircularContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Masking = true, + CornerRadius = 5f, + Children = new Drawable[] + { + leftBox = new Box + { + Height = 5, + EdgeSmoothness = new Vector2(0, 0.5f), + RelativeSizeAxes = Axes.None, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + rightBox = new Box + { + Height = 5, + EdgeSmoothness = new Vector2(0, 0.5f), + RelativeSizeAxes = Axes.None, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Alpha = 0.5f, + }, + }, + }, }, nubContainer = new Container { @@ -88,7 +120,7 @@ namespace osu.Game.Graphics.UserInterface { Origin = Anchor.TopCentre, RelativePositionAxes = Axes.X, - Expanded = true, + Current = { Value = true } }, }, new HoverClickSounds() @@ -97,11 +129,12 @@ namespace osu.Game.Graphics.UserInterface Current.DisabledChanged += disabled => { Alpha = disabled ? 0.3f : 1; }; } - [BackgroundDependencyLoader] - private void load(AudioManager audio, OsuColour colours) + [BackgroundDependencyLoader(true)] + private void load(AudioManager audio, [CanBeNull] OverlayColourProvider colourProvider, OsuColour colours) { sample = audio.Samples.Get(@"UI/notch-tick"); - AccentColour = colours.Pink; + AccentColour = colourProvider?.Highlight1 ?? colours.Pink; + BackgroundColour = colourProvider?.Background5 ?? colours.Pink.Opacity(0.5f); } protected override void Update() @@ -119,26 +152,25 @@ namespace osu.Game.Graphics.UserInterface protected override bool OnHover(HoverEvent e) { - Nub.Glowing = true; + updateGlow(); return base.OnHover(e); } protected override void OnHoverLost(HoverLostEvent e) { - Nub.Glowing = false; + updateGlow(); base.OnHoverLost(e); } - protected override bool OnMouseDown(MouseDownEvent e) + protected override void OnDragEnd(DragEndEvent e) { - Nub.Current.Value = true; - return base.OnMouseDown(e); + updateGlow(); + base.OnDragEnd(e); } - protected override void OnMouseUp(MouseUpEvent e) + private void updateGlow() { - Nub.Current.Value = false; - base.OnMouseUp(e); + Nub.Glowing = IsHovered || IsDragged; } protected override void OnUserChange(T value) diff --git a/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs b/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs index 24b9ca8d90..68ffc6bf4e 100644 --- a/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs +++ b/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs @@ -11,13 +11,33 @@ using osu.Framework.Input.Events; namespace osu.Game.Graphics.UserInterface { - public class OsuTabDropdown : OsuDropdown + public class OsuTabDropdown : OsuDropdown, IHasAccentColour { + private Color4 accentColour; + + public Color4 AccentColour + { + get => accentColour; + set + { + accentColour = value; + + if (IsLoaded) + propagateAccentColour(); + } + } + public OsuTabDropdown() { RelativeSizeAxes = Axes.X; } + protected override void LoadComplete() + { + base.LoadComplete(); + propagateAccentColour(); + } + protected override DropdownMenu CreateMenu() => new OsuTabDropdownMenu(); protected override DropdownHeader CreateHeader() => new OsuTabDropdownHeader @@ -26,6 +46,18 @@ namespace osu.Game.Graphics.UserInterface Origin = Anchor.TopRight }; + private void propagateAccentColour() + { + if (Menu is OsuDropdownMenu dropdownMenu) + { + dropdownMenu.HoverColour = accentColour; + dropdownMenu.SelectionColour = accentColour.Opacity(0.5f); + } + + if (Header is OsuTabDropdownHeader tabDropdownHeader) + tabDropdownHeader.AccentColour = accentColour; + } + private class OsuTabDropdownMenu : OsuDropdownMenu { public OsuTabDropdownMenu() @@ -37,7 +69,7 @@ namespace osu.Game.Graphics.UserInterface MaxHeight = 400; } - protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new DrawableOsuTabDropdownMenuItem(item) { AccentColour = AccentColour }; + protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new DrawableOsuTabDropdownMenuItem(item); private class DrawableOsuTabDropdownMenuItem : DrawableOsuDropdownMenuItem { @@ -49,15 +81,18 @@ namespace osu.Game.Graphics.UserInterface } } - protected class OsuTabDropdownHeader : OsuDropdownHeader + protected class OsuTabDropdownHeader : OsuDropdownHeader, IHasAccentColour { - public override Color4 AccentColour + private Color4 accentColour; + + public Color4 AccentColour { - get => base.AccentColour; + get => accentColour; set { - base.AccentColour = value; - Foreground.Colour = value; + accentColour = value; + BackgroundColourHover = value; + updateColour(); } } @@ -93,15 +128,20 @@ namespace osu.Game.Graphics.UserInterface protected override bool OnHover(HoverEvent e) { - Foreground.Colour = BackgroundColour; + updateColour(); return base.OnHover(e); } protected override void OnHoverLost(HoverLostEvent e) { - Foreground.Colour = BackgroundColourHover; + updateColour(); base.OnHoverLost(e); } + + private void updateColour() + { + Foreground.Colour = IsHovered ? BackgroundColour : BackgroundColourHover; + } } } } diff --git a/osu.Game/Graphics/UserInterface/OsuTextBox.cs b/osu.Game/Graphics/UserInterface/OsuTextBox.cs index 75af9efc38..96319b9fdd 100644 --- a/osu.Game/Graphics/UserInterface/OsuTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuTextBox.cs @@ -1,6 +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 enable + using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -17,18 +19,13 @@ using osu.Framework.Input.Events; using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.Containers; +using osu.Game.Overlays; using osuTK; namespace osu.Game.Graphics.UserInterface { public class OsuTextBox : BasicTextBox { - private readonly Sample[] textAddedSamples = new Sample[4]; - private Sample capsTextAddedSample; - private Sample textRemovedSample; - private Sample textCommittedSample; - private Sample caretMovedSample; - /// /// Whether to allow playing a different samples based on the type of character. /// If set to false, the same sample will be used for all characters. @@ -42,10 +39,17 @@ namespace osu.Game.Graphics.UserInterface protected override SpriteText CreatePlaceholder() => new OsuSpriteText { Font = OsuFont.GetFont(italics: true), - Colour = new Color4(180, 180, 180, 255), Margin = new MarginPadding { Left = 2 }, }; + private readonly Sample?[] textAddedSamples = new Sample[4]; + private Sample? capsTextAddedSample; + private Sample? textRemovedSample; + private Sample? textCommittedSample; + private Sample? caretMovedSample; + + private OsuCaret? caret; + public OsuTextBox() { Height = 40; @@ -56,12 +60,18 @@ namespace osu.Game.Graphics.UserInterface Current.DisabledChanged += disabled => { Alpha = disabled ? 0.3f : 1; }; } - [BackgroundDependencyLoader] - private void load(OsuColour colour, AudioManager audio) + [BackgroundDependencyLoader(true)] + private void load(OverlayColourProvider? colourProvider, OsuColour colour, AudioManager audio) { - BackgroundUnfocused = Color4.Black.Opacity(0.5f); - BackgroundFocused = OsuColour.Gray(0.3f).Opacity(0.8f); - BackgroundCommit = BorderColour = colour.Yellow; + BackgroundUnfocused = colourProvider?.Background5 ?? Color4.Black.Opacity(0.5f); + BackgroundFocused = colourProvider?.Background4 ?? OsuColour.Gray(0.3f).Opacity(0.8f); + BackgroundCommit = BorderColour = colourProvider?.Highlight1 ?? colour.Yellow; + selectionColour = colourProvider?.Background1 ?? new Color4(249, 90, 255, 255); + + if (caret != null) + caret.SelectionColour = selectionColour; + + Placeholder.Colour = colourProvider?.Foreground1 ?? new Color4(180, 180, 180, 255); for (int i = 0; i < textAddedSamples.Length; i++) textAddedSamples[i] = audio.Samples.Get($@"Keyboard/key-press-{1 + i}"); @@ -72,7 +82,9 @@ namespace osu.Game.Graphics.UserInterface caretMovedSample = audio.Samples.Get(@"Keyboard/key-movement"); } - protected override Color4 SelectionColour => new Color4(249, 90, 255, 255); + private Color4 selectionColour; + + protected override Color4 SelectionColour => selectionColour; protected override void OnUserTextAdded(string added) { @@ -124,7 +136,7 @@ namespace osu.Game.Graphics.UserInterface Child = new OsuSpriteText { Text = c.ToString(), Font = OsuFont.GetFont(size: CalculatedTextSize) }, }; - protected override Caret CreateCaret() => new OsuCaret + protected override Caret CreateCaret() => caret = new OsuCaret { CaretWidth = CaretWidth, SelectionColour = SelectionColour, diff --git a/osu.Game/Graphics/UserInterface/ScoreCounter.cs b/osu.Game/Graphics/UserInterface/ScoreCounter.cs index 7ebf3819e4..25f19aa0a9 100644 --- a/osu.Game/Graphics/UserInterface/ScoreCounter.cs +++ b/osu.Game/Graphics/UserInterface/ScoreCounter.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Localisation; using osu.Game.Graphics.Sprites; @@ -13,43 +14,30 @@ namespace osu.Game.Graphics.UserInterface protected override double RollingDuration => 1000; protected override Easing RollingEasing => Easing.Out; - /// - /// Whether comma separators should be displayed. - /// - public bool UseCommaSeparator { get; } - public Bindable RequiredDisplayDigits { get; } = new Bindable(); + private string formatString; + /// /// Displays score. /// /// How many leading zeroes the counter will have. - /// Whether comma separators should be displayed. - protected ScoreCounter(int leading = 0, bool useCommaSeparator = false) + protected ScoreCounter(int leading = 0) { - UseCommaSeparator = useCommaSeparator; - RequiredDisplayDigits.Value = leading; - RequiredDisplayDigits.BindValueChanged(_ => UpdateDisplay()); + RequiredDisplayDigits.BindValueChanged(displayDigitsChanged, true); } - protected override double GetProportionalDuration(double currentValue, double newValue) + private void displayDigitsChanged(ValueChangedEvent _) { - return currentValue > newValue ? currentValue - newValue : newValue - currentValue; + formatString = new string('0', RequiredDisplayDigits.Value); + UpdateDisplay(); } - protected override LocalisableString FormatCount(double count) - { - string format = new string('0', RequiredDisplayDigits.Value); + protected override double GetProportionalDuration(double currentValue, double newValue) => + currentValue > newValue ? currentValue - newValue : newValue - currentValue; - if (UseCommaSeparator) - { - for (int i = format.Length - 3; i > 0; i -= 3) - format = format.Insert(i, @","); - } - - return ((long)count).ToString(format); - } + protected override LocalisableString FormatCount(double count) => ((long)count).ToLocalisableString(formatString); protected override OsuSpriteText CreateSpriteText() => base.CreateSpriteText().With(s => s.Font = s.Font.With(fixedWidth: true)); diff --git a/osu.Game/Graphics/UserInterface/SlimEnumDropdown.cs b/osu.Game/Graphics/UserInterface/SlimEnumDropdown.cs index 965734792c..c01ee1a059 100644 --- a/osu.Game/Graphics/UserInterface/SlimEnumDropdown.cs +++ b/osu.Game/Graphics/UserInterface/SlimEnumDropdown.cs @@ -2,11 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.UserInterface; -using osuTK; -using osuTK.Graphics; namespace osu.Game.Graphics.UserInterface { @@ -15,30 +12,13 @@ namespace osu.Game.Graphics.UserInterface { protected override DropdownHeader CreateHeader() => new SlimDropdownHeader(); - protected override DropdownMenu CreateMenu() => new SlimMenu(); - private class SlimDropdownHeader : OsuDropdownHeader { public SlimDropdownHeader() { Height = 25; - Icon.Size = new Vector2(16); Foreground.Padding = new MarginPadding { Top = 4, Bottom = 4, Left = 8, Right = 4 }; } - - protected override void LoadComplete() - { - base.LoadComplete(); - BackgroundColour = Color4.Black.Opacity(0.25f); - } - } - - private class SlimMenu : OsuDropdownMenu - { - public SlimMenu() - { - BackgroundColour = Color4.Black.Opacity(0.7f); - } } } } diff --git a/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs b/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs index 5a697623c9..d5f76733cf 100644 --- a/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs +++ b/osu.Game/Graphics/UserInterfaceV2/LabelledDrawable.cs @@ -1,12 +1,15 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +#nullable enable + using osu.Framework.Allocation; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Graphics.Containers; +using osu.Game.Overlays; using osuTK; namespace osu.Game.Graphics.UserInterfaceV2 @@ -44,6 +47,7 @@ namespace osu.Game.Graphics.UserInterfaceV2 /// protected readonly T Component; + private readonly Box background; private readonly GridContainer grid; private readonly OsuTextFlowContainer labelText; private readonly OsuTextFlowContainer descriptionText; @@ -62,10 +66,9 @@ namespace osu.Game.Graphics.UserInterfaceV2 InternalChildren = new Drawable[] { - new Box + background = new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4Extensions.FromHex("1c2125"), }, new FillFlowContainer { @@ -146,9 +149,10 @@ namespace osu.Game.Graphics.UserInterfaceV2 } } - [BackgroundDependencyLoader] - private void load(OsuColour osuColour) + [BackgroundDependencyLoader(true)] + private void load(OverlayColourProvider? colourProvider, OsuColour osuColour) { + background.Colour = colourProvider?.Background4 ?? Color4Extensions.FromHex(@"1c2125"); descriptionText.Colour = osuColour.Yellow; } diff --git a/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs b/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs new file mode 100644 index 0000000000..23ebc6e98d --- /dev/null +++ b/osu.Game/Graphics/UserInterfaceV2/RoundedButton.cs @@ -0,0 +1,51 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; + +namespace osu.Game.Graphics.UserInterfaceV2 +{ + public class RoundedButton : OsuButton, IFilterable + { + public override float Height + { + get => base.Height; + set + { + base.Height = value; + + if (IsLoaded) + updateCornerRadius(); + } + } + + [BackgroundDependencyLoader(true)] + private void load([CanBeNull] OverlayColourProvider overlayColourProvider, OsuColour colours) + { + BackgroundColour = overlayColourProvider?.Highlight1 ?? colours.Blue3; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + updateCornerRadius(); + } + + private void updateCornerRadius() => Content.CornerRadius = DrawHeight / 2; + + public virtual IEnumerable FilterTerms => new[] { Text.ToString() }; + + public bool MatchingFilter + { + set => this.FadeTo(value ? 1 : 0); + } + + public bool FilteringActive { get; set; } + } +} diff --git a/osu.Game/Graphics/UserInterfaceV2/SwitchButton.cs b/osu.Game/Graphics/UserInterfaceV2/SwitchButton.cs index a7fd25b554..deb2e6baf6 100644 --- a/osu.Game/Graphics/UserInterfaceV2/SwitchButton.cs +++ b/osu.Game/Graphics/UserInterfaceV2/SwitchButton.cs @@ -1,6 +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 enable + using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -10,6 +12,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; +using osu.Game.Overlays; using osuTK; using osuTK.Graphics; @@ -66,11 +69,11 @@ namespace osu.Game.Graphics.UserInterfaceV2 }; } - [BackgroundDependencyLoader] - private void load(OsuColour colours) + [BackgroundDependencyLoader(true)] + private void load(OverlayColourProvider? colourProvider, OsuColour colours) { - enabledColour = colours.BlueDark; - disabledColour = colours.Gray3; + enabledColour = colourProvider?.Highlight1 ?? colours.BlueDark; + disabledColour = colourProvider?.Background3 ?? colours.Gray3; switchContainer.Colour = enabledColour; fill.Colour = disabledColour; diff --git a/osu.Game/IO/FileAbstraction/StreamFileAbstraction.cs b/osu.Game/IO/FileAbstraction/StreamFileAbstraction.cs new file mode 100644 index 0000000000..f5709b5158 --- /dev/null +++ b/osu.Game/IO/FileAbstraction/StreamFileAbstraction.cs @@ -0,0 +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 System.IO; + +namespace osu.Game.IO.FileAbstraction +{ + public class StreamFileAbstraction : TagLib.File.IFileAbstraction + { + public StreamFileAbstraction(string filename, Stream fileStream) + { + ReadStream = fileStream; + Name = filename; + } + + public string Name { get; } + + public Stream ReadStream { get; } + public Stream WriteStream => ReadStream; + + public void CloseStream(Stream stream) + { + if (stream == null) + throw new ArgumentNullException(nameof(stream)); + + stream.Close(); + } + } +} diff --git a/osu.Game/IO/WrappedStorage.cs b/osu.Game/IO/WrappedStorage.cs index b9ccc907d9..aadc4e760b 100644 --- a/osu.Game/IO/WrappedStorage.cs +++ b/osu.Game/IO/WrappedStorage.cs @@ -70,7 +70,9 @@ namespace osu.Game.IO public override Stream GetStream(string path, FileAccess access = FileAccess.Read, FileMode mode = FileMode.OpenOrCreate) => UnderlyingStorage.GetStream(MutatePath(path), access, mode); - public override void OpenPathInNativeExplorer(string path) => UnderlyingStorage.OpenPathInNativeExplorer(MutatePath(path)); + public override void OpenFileExternally(string filename) => UnderlyingStorage.OpenFileExternally(MutatePath(filename)); + + public override void PresentFileExternally(string filename) => UnderlyingStorage.PresentFileExternally(MutatePath(filename)); public override Storage GetStorageForDirectory(string path) { diff --git a/osu.Game/Localisation/AudioSettingsStrings.cs b/osu.Game/Localisation/AudioSettingsStrings.cs index aa6eabd7d1..008781c2e5 100644 --- a/osu.Game/Localisation/AudioSettingsStrings.cs +++ b/osu.Game/Localisation/AudioSettingsStrings.cs @@ -24,6 +24,11 @@ namespace osu.Game.Localisation /// public static LocalisableString VolumeHeader => new TranslatableString(getKey(@"volume_header"), @"Volume"); + /// + /// "Output device" + /// + public static LocalisableString OutputDevice => new TranslatableString(getKey(@"output_device"), @"Output device"); + /// /// "Master" /// diff --git a/osu.Game/Localisation/CommonStrings.cs b/osu.Game/Localisation/CommonStrings.cs index 33a6eb5d58..3ea337c279 100644 --- a/osu.Game/Localisation/CommonStrings.cs +++ b/osu.Game/Localisation/CommonStrings.cs @@ -24,6 +24,11 @@ namespace osu.Game.Localisation /// public static LocalisableString Enabled => new TranslatableString(getKey(@"enabled"), @"Enabled"); + /// + /// "Disabled" + /// + public static LocalisableString Disabled => new TranslatableString(getKey(@"disabled"), @"Disabled"); + /// /// "Default" /// diff --git a/osu.Game/Localisation/GameplaySettingsStrings.cs b/osu.Game/Localisation/GameplaySettingsStrings.cs index 6d6381b429..fa92187650 100644 --- a/osu.Game/Localisation/GameplaySettingsStrings.cs +++ b/osu.Game/Localisation/GameplaySettingsStrings.cs @@ -14,11 +14,36 @@ namespace osu.Game.Localisation /// public static LocalisableString GameplaySectionHeader => new TranslatableString(getKey(@"gameplay_section_header"), @"Gameplay"); + /// + /// "Beatmap" + /// + public static LocalisableString BeatmapHeader => new TranslatableString(getKey(@"beatmap_header"), @"Beatmap"); + /// /// "General" /// public static LocalisableString GeneralHeader => new TranslatableString(getKey(@"general_header"), @"General"); + /// + /// "Audio" + /// + public static LocalisableString AudioHeader => new TranslatableString(getKey(@"audio"), @"Audio"); + + /// + /// "HUD" + /// + public static LocalisableString HUDHeader => new TranslatableString(getKey(@"h_u_d"), @"HUD"); + + /// + /// "Input" + /// + public static LocalisableString InputHeader => new TranslatableString(getKey(@"input"), @"Input"); + + /// + /// "Background" + /// + public static LocalisableString BackgroundHeader => new TranslatableString(getKey(@"background"), @"Background"); + /// /// "Background dim" /// diff --git a/osu.Game/Localisation/GraphicsSettingsStrings.cs b/osu.Game/Localisation/GraphicsSettingsStrings.cs index 0e384f983f..f85cc0f2ae 100644 --- a/osu.Game/Localisation/GraphicsSettingsStrings.cs +++ b/osu.Game/Localisation/GraphicsSettingsStrings.cs @@ -104,6 +104,11 @@ namespace osu.Game.Localisation /// public static LocalisableString HitLighting => new TranslatableString(getKey(@"hit_lighting"), @"Hit lighting"); + /// + /// "Screenshots" + /// + public static LocalisableString Screenshots => new TranslatableString(getKey(@"screenshots"), @"Screenshots"); + /// /// "Screenshot format" /// diff --git a/osu.Game/Localisation/ResourceManagerLocalisationStore.cs b/osu.Game/Localisation/ResourceManagerLocalisationStore.cs index a35ce7a9c8..6a4e38fb38 100644 --- a/osu.Game/Localisation/ResourceManagerLocalisationStore.cs +++ b/osu.Game/Localisation/ResourceManagerLocalisationStore.cs @@ -29,6 +29,9 @@ namespace osu.Game.Localisation { var split = lookup.Split(':'); + if (split.Length < 2) + return null; + string ns = split[0]; string key = split[1]; diff --git a/osu.Game/Localisation/RulesetSettingsStrings.cs b/osu.Game/Localisation/RulesetSettingsStrings.cs new file mode 100644 index 0000000000..a356c9e20b --- /dev/null +++ b/osu.Game/Localisation/RulesetSettingsStrings.cs @@ -0,0 +1,19 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class RulesetSettingsStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.RulesetSettings"; + + /// + /// "Rulesets" + /// + public static LocalisableString Rulesets => new TranslatableString(getKey(@"rulesets"), @"Rulesets"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Localisation/SkinSettingsStrings.cs b/osu.Game/Localisation/SkinSettingsStrings.cs index f22b4d6bf5..8b74b94d59 100644 --- a/osu.Game/Localisation/SkinSettingsStrings.cs +++ b/osu.Game/Localisation/SkinSettingsStrings.cs @@ -14,6 +14,11 @@ namespace osu.Game.Localisation /// public static LocalisableString SkinSectionHeader => new TranslatableString(getKey(@"skin_section_header"), @"Skin"); + /// + /// "Current skin" + /// + public static LocalisableString CurrentSkin => new TranslatableString(getKey(@"current_skin"), @"Current skin"); + /// /// "Skin layout editor" /// diff --git a/osu.Game/Localisation/ToastStrings.cs b/osu.Game/Localisation/ToastStrings.cs new file mode 100644 index 0000000000..52e75425bf --- /dev/null +++ b/osu.Game/Localisation/ToastStrings.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class ToastStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.Toast"; + + /// + /// "no key bound" + /// + public static LocalisableString NoKeyBound => new TranslatableString(getKey(@"no_key_bound"), @"no key bound"); + + /// + /// "Music Playback" + /// + public static LocalisableString MusicPlayback => new TranslatableString(getKey(@"music_playback"), @"Music Playback"); + + /// + /// "Pause track" + /// + public static LocalisableString PauseTrack => new TranslatableString(getKey(@"pause_track"), @"Pause track"); + + /// + /// "Play track" + /// + public static LocalisableString PlayTrack => new TranslatableString(getKey(@"play_track"), @"Play track"); + + /// + /// "Restart track" + /// + public static LocalisableString RestartTrack => new TranslatableString(getKey(@"restart_track"), @"Restart track"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Models/RealmBeatmap.cs b/osu.Game/Models/RealmBeatmap.cs new file mode 100644 index 0000000000..9311425cb7 --- /dev/null +++ b/osu.Game/Models/RealmBeatmap.cs @@ -0,0 +1,118 @@ +// 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 JetBrains.Annotations; +using Newtonsoft.Json; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Rulesets; +using Realms; + +#nullable enable + +namespace osu.Game.Models +{ + /// + /// A single beatmap difficulty. + /// + [ExcludeFromDynamicCompile] + [Serializable] + [MapTo("Beatmap")] + public class RealmBeatmap : RealmObject, IHasGuidPrimaryKey, IBeatmapInfo + { + [PrimaryKey] + public Guid ID { get; set; } = Guid.NewGuid(); + + public string DifficultyName { get; set; } = string.Empty; + + public RealmRuleset Ruleset { get; set; } = null!; + + public RealmBeatmapDifficulty Difficulty { get; set; } = null!; + + public RealmBeatmapMetadata Metadata { get; set; } = null!; + + public RealmBeatmapSet? BeatmapSet { get; set; } + + public BeatmapSetOnlineStatus Status + { + get => (BeatmapSetOnlineStatus)StatusInt; + set => StatusInt = (int)value; + } + + [MapTo(nameof(Status))] + public int StatusInt { get; set; } + + [Indexed] + public int OnlineID { get; set; } = -1; + + public double Length { get; set; } + + public double BPM { get; set; } + + public string Hash { get; set; } = string.Empty; + + public double StarRating { get; set; } + + public string MD5Hash { get; set; } = string.Empty; + + [JsonIgnore] + public bool Hidden { get; set; } + + public RealmBeatmap(RealmRuleset ruleset, RealmBeatmapDifficulty difficulty, RealmBeatmapMetadata metadata) + { + Ruleset = ruleset; + Difficulty = difficulty; + Metadata = metadata; + } + + [UsedImplicitly] + private RealmBeatmap() + { + } + + #region Properties we may not want persisted (but also maybe no harm?) + + public double AudioLeadIn { get; set; } + + public float StackLeniency { get; set; } = 0.7f; + + public bool SpecialStyle { get; set; } + + public bool LetterboxInBreaks { get; set; } + + public bool WidescreenStoryboard { get; set; } + + public bool EpilepsyWarning { get; set; } + + public bool SamplesMatchPlaybackRate { get; set; } + + public double DistanceSpacing { get; set; } + + public int BeatDivisor { get; set; } + + public int GridSize { get; set; } + + public double TimelineZoom { get; set; } + + #endregion + + public bool AudioEquals(RealmBeatmap? other) => other != null + && BeatmapSet != null + && other.BeatmapSet != null + && BeatmapSet.Hash == other.BeatmapSet.Hash + && Metadata.AudioFile == other.Metadata.AudioFile; + + public bool BackgroundEquals(RealmBeatmap? other) => other != null + && BeatmapSet != null + && other.BeatmapSet != null + && BeatmapSet.Hash == other.BeatmapSet.Hash + && Metadata.BackgroundFile == other.Metadata.BackgroundFile; + + IBeatmapMetadataInfo IBeatmapInfo.Metadata => Metadata; + IBeatmapSetInfo? IBeatmapInfo.BeatmapSet => BeatmapSet; + IRulesetInfo IBeatmapInfo.Ruleset => Ruleset; + IBeatmapDifficultyInfo IBeatmapInfo.Difficulty => Difficulty; + } +} diff --git a/osu.Game/Models/RealmBeatmapDifficulty.cs b/osu.Game/Models/RealmBeatmapDifficulty.cs new file mode 100644 index 0000000000..3c1dad69e4 --- /dev/null +++ b/osu.Game/Models/RealmBeatmapDifficulty.cs @@ -0,0 +1,45 @@ +// 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.Testing; +using osu.Game.Beatmaps; +using Realms; + +#nullable enable + +namespace osu.Game.Models +{ + [ExcludeFromDynamicCompile] + [MapTo("BeatmapDifficulty")] + public class RealmBeatmapDifficulty : EmbeddedObject, IBeatmapDifficultyInfo + { + public float DrainRate { get; set; } = IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY; + public float CircleSize { get; set; } = IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY; + public float OverallDifficulty { get; set; } = IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY; + public float ApproachRate { get; set; } = IBeatmapDifficultyInfo.DEFAULT_DIFFICULTY; + + public double SliderMultiplier { get; set; } = 1; + public double SliderTickRate { get; set; } = 1; + + /// + /// Returns a shallow-clone of this . + /// + public RealmBeatmapDifficulty Clone() + { + var diff = new RealmBeatmapDifficulty(); + CopyTo(diff); + return diff; + } + + public void CopyTo(RealmBeatmapDifficulty difficulty) + { + difficulty.ApproachRate = ApproachRate; + difficulty.DrainRate = DrainRate; + difficulty.CircleSize = CircleSize; + difficulty.OverallDifficulty = OverallDifficulty; + + difficulty.SliderMultiplier = SliderMultiplier; + difficulty.SliderTickRate = SliderTickRate; + } + } +} diff --git a/osu.Game/Models/RealmBeatmapMetadata.cs b/osu.Game/Models/RealmBeatmapMetadata.cs new file mode 100644 index 0000000000..6ea7170d0f --- /dev/null +++ b/osu.Game/Models/RealmBeatmapMetadata.cs @@ -0,0 +1,45 @@ +// 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 Newtonsoft.Json; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using Realms; + +#nullable enable + +namespace osu.Game.Models +{ + [ExcludeFromDynamicCompile] + [Serializable] + [MapTo("BeatmapMetadata")] + public class RealmBeatmapMetadata : RealmObject, IBeatmapMetadataInfo + { + public string Title { get; set; } = string.Empty; + + [JsonProperty("title_unicode")] + public string TitleUnicode { get; set; } = string.Empty; + + public string Artist { get; set; } = string.Empty; + + [JsonProperty("artist_unicode")] + public string ArtistUnicode { get; set; } = string.Empty; + + public string Author { get; set; } = string.Empty; // eventually should be linked to a persisted User. + + public string Source { get; set; } = string.Empty; + + [JsonProperty(@"tags")] + public string Tags { get; set; } = string.Empty; + + /// + /// The time in milliseconds to begin playing the track for preview purposes. + /// If -1, the track should begin playing at 40% of its length. + /// + public int PreviewTime { get; set; } + + public string AudioFile { get; set; } = string.Empty; + public string BackgroundFile { get; set; } = string.Empty; + } +} diff --git a/osu.Game/Models/RealmBeatmapSet.cs b/osu.Game/Models/RealmBeatmapSet.cs new file mode 100644 index 0000000000..6735510422 --- /dev/null +++ b/osu.Game/Models/RealmBeatmapSet.cs @@ -0,0 +1,79 @@ +// 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.Testing; +using osu.Game.Beatmaps; +using osu.Game.Database; +using Realms; + +#nullable enable + +namespace osu.Game.Models +{ + [ExcludeFromDynamicCompile] + [MapTo("BeatmapSet")] + public class RealmBeatmapSet : RealmObject, IHasGuidPrimaryKey, IHasRealmFiles, ISoftDelete, IEquatable, IBeatmapSetInfo + { + [PrimaryKey] + public Guid ID { get; set; } = Guid.NewGuid(); + + [Indexed] + public int OnlineID { get; set; } = -1; + + public DateTimeOffset DateAdded { get; set; } + + public IBeatmapMetadataInfo? Metadata => Beatmaps.FirstOrDefault()?.Metadata; + + public IList Beatmaps { get; } = null!; + + public IList Files { get; } = null!; + + public bool DeletePending { get; set; } + + public string Hash { get; set; } = string.Empty; + + /// + /// Whether deleting this beatmap set should be prohibited (due to it being a system requirement to be present). + /// + public bool Protected { get; set; } + + public double MaxStarDifficulty => Beatmaps.Max(b => b.StarRating); + + public double MaxLength => Beatmaps.Max(b => b.Length); + + public double MaxBPM => Beatmaps.Max(b => b.BPM); + + /// + /// Returns the storage path for the file in this beatmapset with the given filename, if any exists, otherwise null. + /// The path returned is relative to the user file storage. + /// + /// The name of the file to get the storage path of. + public string? GetPathForFile(string filename) => Files.SingleOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.File.StoragePath; + + public override string ToString() => Metadata?.ToString() ?? base.ToString(); + + public bool Equals(RealmBeatmapSet? other) + { + if (other == null) + return false; + + if (IsManaged && other.IsManaged) + return ID == other.ID; + + if (OnlineID > 0 && other.OnlineID > 0) + return OnlineID == other.OnlineID; + + if (!string.IsNullOrEmpty(Hash) && !string.IsNullOrEmpty(other.Hash)) + return Hash == other.Hash; + + return ReferenceEquals(this, other); + } + + IEnumerable IBeatmapSetInfo.Beatmaps => Beatmaps; + + IEnumerable IBeatmapSetInfo.Files => Files; + } +} diff --git a/osu.Game/Models/RealmFile.cs b/osu.Game/Models/RealmFile.cs new file mode 100644 index 0000000000..2715f4be45 --- /dev/null +++ b/osu.Game/Models/RealmFile.cs @@ -0,0 +1,22 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.IO; +using osu.Framework.Testing; +using osu.Game.IO; +using Realms; + +#nullable enable + +namespace osu.Game.Models +{ + [ExcludeFromDynamicCompile] + [MapTo("File")] + public class RealmFile : RealmObject, IFileInfo + { + [PrimaryKey] + public string Hash { get; set; } = string.Empty; + + public string StoragePath => Path.Combine(Hash.Remove(1), Hash.Remove(2), Hash); + } +} diff --git a/osu.Game/Models/RealmNamedFileUsage.cs b/osu.Game/Models/RealmNamedFileUsage.cs new file mode 100644 index 0000000000..ba12d51d0b --- /dev/null +++ b/osu.Game/Models/RealmNamedFileUsage.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using JetBrains.Annotations; +using osu.Framework.Testing; +using osu.Game.Database; +using osu.Game.IO; +using Realms; + +#nullable enable + +namespace osu.Game.Models +{ + [ExcludeFromDynamicCompile] + public class RealmNamedFileUsage : EmbeddedObject, INamedFile, INamedFileUsage + { + public RealmFile File { get; set; } = null!; + + public string Filename { get; set; } = null!; + + public RealmNamedFileUsage(RealmFile file, string filename) + { + File = file; + Filename = filename; + } + + [UsedImplicitly] + private RealmNamedFileUsage() + { + } + + IFileInfo INamedFileUsage.File => File; + } +} diff --git a/osu.Game/Models/RealmRuleset.cs b/osu.Game/Models/RealmRuleset.cs new file mode 100644 index 0000000000..5d70324713 --- /dev/null +++ b/osu.Game/Models/RealmRuleset.cs @@ -0,0 +1,64 @@ +// 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 JetBrains.Annotations; +using osu.Framework.Testing; +using osu.Game.Rulesets; +using Realms; + +#nullable enable + +namespace osu.Game.Models +{ + [ExcludeFromDynamicCompile] + [MapTo("Ruleset")] + public class RealmRuleset : RealmObject, IEquatable, IRulesetInfo + { + [PrimaryKey] + public string ShortName { get; set; } = string.Empty; + + [Indexed] + public int OnlineID { get; set; } = -1; + + public string Name { get; set; } = string.Empty; + + public string InstantiationInfo { get; set; } = string.Empty; + + public RealmRuleset(string shortName, string name, string instantiationInfo, int? onlineID = null) + { + ShortName = shortName; + Name = name; + InstantiationInfo = instantiationInfo; + OnlineID = onlineID ?? -1; + } + + [UsedImplicitly] + private RealmRuleset() + { + } + + public RealmRuleset(int? onlineID, string name, string shortName, bool available) + { + OnlineID = onlineID ?? -1; + Name = name; + ShortName = shortName; + Available = available; + } + + public bool Available { get; set; } + + public bool Equals(RealmRuleset? other) => other != null && OnlineID == other.OnlineID && Available == other.Available && Name == other.Name && InstantiationInfo == other.InstantiationInfo; + + public override string ToString() => Name; + + public RealmRuleset Clone() => new RealmRuleset + { + OnlineID = OnlineID, + Name = Name, + ShortName = ShortName, + InstantiationInfo = InstantiationInfo, + Available = Available + }; + } +} diff --git a/osu.Game/Online/API/OAuth.cs b/osu.Game/Online/API/OAuth.cs index d79fc58d1c..1feb3076d1 100644 --- a/osu.Game/Online/API/OAuth.cs +++ b/osu.Game/Online/API/OAuth.cs @@ -39,17 +39,19 @@ namespace osu.Game.Online.API if (string.IsNullOrEmpty(username)) throw new ArgumentException("Missing username."); if (string.IsNullOrEmpty(password)) throw new ArgumentException("Missing password."); - using (var req = new AccessTokenRequestPassword(username, password) + var accessTokenRequest = new AccessTokenRequestPassword(username, password) { Url = $@"{endpoint}/oauth/token", Method = HttpMethod.Post, ClientId = clientId, ClientSecret = clientSecret - }) + }; + + using (accessTokenRequest) { try { - req.Perform(); + accessTokenRequest.Perform(); } catch (Exception ex) { @@ -60,7 +62,7 @@ namespace osu.Game.Online.API try { // attempt to decode a displayable error string. - var error = JsonConvert.DeserializeObject(req.GetResponseString() ?? string.Empty); + var error = JsonConvert.DeserializeObject(accessTokenRequest.GetResponseString() ?? string.Empty); if (error != null) throwableException = new APIException(error.UserDisplayableError, ex); } @@ -71,7 +73,7 @@ namespace osu.Game.Online.API throw throwableException; } - Token.Value = req.ResponseObject; + Token.Value = accessTokenRequest.ResponseObject; } } @@ -79,17 +81,19 @@ namespace osu.Game.Online.API { try { - using (var req = new AccessTokenRequestRefresh(refresh) + var refreshRequest = new AccessTokenRequestRefresh(refresh) { Url = $@"{endpoint}/oauth/token", Method = HttpMethod.Post, ClientId = clientId, ClientSecret = clientSecret - }) - { - req.Perform(); + }; - Token.Value = req.ResponseObject; + using (refreshRequest) + { + refreshRequest.Perform(); + + Token.Value = refreshRequest.ResponseObject; return true; } } diff --git a/osu.Game/Online/API/Requests/GetBeatmapRequest.cs b/osu.Game/Online/API/Requests/GetBeatmapRequest.cs index 901f7365b8..6cd45a41df 100644 --- a/osu.Game/Online/API/Requests/GetBeatmapRequest.cs +++ b/osu.Game/Online/API/Requests/GetBeatmapRequest.cs @@ -1,20 +1,38 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.IO.Network; using osu.Game.Beatmaps; using osu.Game.Online.API.Requests.Responses; +#nullable enable + namespace osu.Game.Online.API.Requests { public class GetBeatmapRequest : APIRequest { - private readonly BeatmapInfo beatmapInfo; + private readonly IBeatmapInfo beatmapInfo; - public GetBeatmapRequest(BeatmapInfo beatmapInfo) + private readonly string filename; + + public GetBeatmapRequest(IBeatmapInfo beatmapInfo) { this.beatmapInfo = beatmapInfo; + + filename = (beatmapInfo as BeatmapInfo)?.Path ?? string.Empty; } - protected override string Target => $@"beatmaps/lookup?id={beatmapInfo.OnlineBeatmapID}&checksum={beatmapInfo.MD5Hash}&filename={System.Uri.EscapeUriString(beatmapInfo.Path ?? string.Empty)}"; + protected override WebRequest CreateWebRequest() + { + var request = base.CreateWebRequest(); + + request.AddParameter(@"id", beatmapInfo.OnlineID.ToString()); + request.AddParameter(@"checksum", beatmapInfo.MD5Hash); + request.AddParameter(@"filename", filename); + + return request; + } + + protected override string Target => @"beatmaps/lookup"; } } diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs index c2a68c8ca1..0945ad30b4 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs @@ -6,12 +6,14 @@ using Newtonsoft.Json; using osu.Game.Beatmaps; using osu.Game.Rulesets; +#nullable enable + namespace osu.Game.Online.API.Requests.Responses { - public class APIBeatmap : BeatmapMetadata + public class APIBeatmap : IBeatmapInfo, IBeatmapOnlineInfo { [JsonProperty(@"id")] - public int OnlineBeatmapID { get; set; } + public int OnlineID { get; set; } [JsonProperty(@"beatmapset_id")] public int OnlineBeatmapSetID { get; set; } @@ -19,20 +21,26 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"status")] public BeatmapSetOnlineStatus Status { get; set; } + [JsonProperty("checksum")] + public string Checksum { get; set; } = string.Empty; + + [JsonProperty(@"user_id")] + public int AuthorID { get; set; } + [JsonProperty(@"beatmapset")] - public APIBeatmapSet BeatmapSet { get; set; } + public APIBeatmapSet? BeatmapSet { get; set; } [JsonProperty(@"playcount")] - private int playCount { get; set; } + public int PlayCount { get; set; } [JsonProperty(@"passcount")] - private int passCount { get; set; } + public int PassCount { get; set; } [JsonProperty(@"mode_int")] - private int ruleset { get; set; } + public int RulesetID { get; set; } [JsonProperty(@"difficulty_rating")] - private double starDifficulty { get; set; } + public double StarRating { get; set; } [JsonProperty(@"drain")] private float drainRate { get; set; } @@ -46,23 +54,27 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"accuracy")] private float overallDifficulty { get; set; } + public double Length => TimeSpan.FromSeconds(lengthInSeconds).TotalMilliseconds; + [JsonProperty(@"total_length")] - private double length { get; set; } + private double lengthInSeconds { get; set; } [JsonProperty(@"count_circles")] - private int circleCount { get; set; } + public int CircleCount { get; set; } [JsonProperty(@"count_sliders")] - private int sliderCount { get; set; } + public int SliderCount { get; set; } [JsonProperty(@"version")] - private string version { get; set; } + public string DifficultyName { get; set; } = string.Empty; [JsonProperty(@"failtimes")] - private BeatmapMetrics metrics { get; set; } + public APIFailTimes? FailTimes { get; set; } [JsonProperty(@"max_combo")] - private int? maxCombo { get; set; } + public int? MaxCombo { get; set; } + + public double BPM { get; set; } public virtual BeatmapInfo ToBeatmapInfo(RulesetStore rulesets) { @@ -70,17 +82,17 @@ namespace osu.Game.Online.API.Requests.Responses return new BeatmapInfo { - Metadata = set?.Metadata ?? this, - Ruleset = rulesets.GetRuleset(ruleset), - StarDifficulty = starDifficulty, - OnlineBeatmapID = OnlineBeatmapID, - Version = version, + Metadata = set?.Metadata ?? new BeatmapMetadata(), + Ruleset = rulesets.GetRuleset(RulesetID), + StarDifficulty = StarRating, + OnlineBeatmapID = OnlineID, + Version = DifficultyName, // this is actually an incorrect mapping (Length is calculated as drain length in lazer's import process, see BeatmapManager.calculateLength). - Length = TimeSpan.FromSeconds(length).TotalMilliseconds, + Length = Length, Status = Status, + MD5Hash = Checksum, BeatmapSet = set, - Metrics = metrics, - MaxCombo = maxCombo, + MaxCombo = MaxCombo, BaseDifficulty = new BeatmapDifficulty { DrainRate = drainRate, @@ -88,14 +100,31 @@ namespace osu.Game.Online.API.Requests.Responses ApproachRate = approachRate, OverallDifficulty = overallDifficulty, }, - OnlineInfo = new BeatmapOnlineInfo - { - PlayCount = playCount, - PassCount = passCount, - CircleCount = circleCount, - SliderCount = sliderCount, - }, + OnlineInfo = this, }; } + + #region Implementation of IBeatmapInfo + + public IBeatmapMetadataInfo Metadata => (BeatmapSet as IBeatmapSetInfo)?.Metadata ?? new BeatmapMetadata(); + + public IBeatmapDifficultyInfo Difficulty => new BeatmapDifficulty + { + DrainRate = drainRate, + CircleSize = circleSize, + ApproachRate = approachRate, + OverallDifficulty = overallDifficulty, + }; + + IBeatmapSetInfo? IBeatmapInfo.BeatmapSet => BeatmapSet; + + public string MD5Hash => Checksum; + + public IRulesetInfo Ruleset => new RulesetInfo { ID = RulesetID }; + + [JsonIgnore] + public string Hash => throw new NotImplementedException(); + + #endregion } } diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs index 35963792d0..83f04fb5f2 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs @@ -6,115 +6,132 @@ using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; using osu.Game.Beatmaps; +using osu.Game.Database; using osu.Game.Rulesets; +using osu.Game.Users; + +#nullable enable namespace osu.Game.Online.API.Requests.Responses { - public class APIBeatmapSet : BeatmapMetadata // todo: this is a bit wrong... + public class APIBeatmapSet : IBeatmapSetOnlineInfo, IBeatmapSetInfo { [JsonProperty(@"covers")] - private BeatmapSetOnlineCovers covers { get; set; } - - private int? onlineBeatmapSetID; + public BeatmapSetOnlineCovers Covers { get; set; } [JsonProperty(@"id")] - public int? OnlineBeatmapSetID - { - get => onlineBeatmapSetID; - set => onlineBeatmapSetID = value > 0 ? value : null; - } + public int OnlineID { get; set; } [JsonProperty(@"status")] public BeatmapSetOnlineStatus Status { get; set; } [JsonProperty(@"preview_url")] - private string preview { get; set; } + public string Preview { get; set; } = string.Empty; [JsonProperty(@"has_favourited")] - private bool hasFavourited { get; set; } + public bool HasFavourited { get; set; } [JsonProperty(@"play_count")] - private int playCount { get; set; } + public int PlayCount { get; set; } [JsonProperty(@"favourite_count")] - private int favouriteCount { get; set; } + public int FavouriteCount { get; set; } [JsonProperty(@"bpm")] - private double bpm { get; set; } + public double BPM { get; set; } [JsonProperty(@"nsfw")] - private bool hasExplicitContent { get; set; } + public bool HasExplicitContent { get; set; } [JsonProperty(@"video")] - private bool hasVideo { get; set; } + public bool HasVideo { get; set; } [JsonProperty(@"storyboard")] - private bool hasStoryboard { get; set; } + public bool HasStoryboard { get; set; } [JsonProperty(@"submitted_date")] - private DateTimeOffset submitted { get; set; } + public DateTimeOffset Submitted { get; set; } [JsonProperty(@"ranked_date")] - private DateTimeOffset? ranked { get; set; } + public DateTimeOffset? Ranked { get; set; } [JsonProperty(@"last_updated")] - private DateTimeOffset lastUpdated { get; set; } + public DateTimeOffset? LastUpdated { get; set; } - [JsonProperty(@"ratings")] - private int[] ratings { get; set; } + [JsonProperty("ratings")] + public int[] Ratings { get; set; } = Array.Empty(); [JsonProperty(@"track_id")] - private int? trackId { get; set; } + public int? TrackId { get; set; } + public string Title { get; set; } = string.Empty; + + [JsonProperty("title_unicode")] + public string TitleUnicode { get; set; } = string.Empty; + + public string Artist { get; set; } = string.Empty; + + [JsonProperty("artist_unicode")] + public string ArtistUnicode { get; set; } = string.Empty; + + public User? Author = new User(); + + /// + /// Helper property to deserialize a username to . + /// [JsonProperty(@"user_id")] - private int creatorId + public int AuthorID { - set => Author.Id = value; + get => Author?.Id ?? 1; + set + { + Author ??= new User(); + Author.Id = value; + } + } + + /// + /// Helper property to deserialize a username to . + /// + [JsonProperty(@"creator")] + public string AuthorString + { + get => Author?.Username ?? string.Empty; + set + { + Author ??= new User(); + Author.Username = value; + } } [JsonProperty(@"availability")] - private BeatmapSetOnlineAvailability availability { get; set; } + public BeatmapSetOnlineAvailability Availability { get; set; } [JsonProperty(@"genre")] - private BeatmapSetOnlineGenre genre { get; set; } + public BeatmapSetOnlineGenre Genre { get; set; } [JsonProperty(@"language")] - private BeatmapSetOnlineLanguage language { get; set; } + public BeatmapSetOnlineLanguage Language { get; set; } + + public string Source { get; set; } = string.Empty; + + [JsonProperty(@"tags")] + public string Tags { get; set; } = string.Empty; [JsonProperty(@"beatmaps")] - private IEnumerable beatmaps { get; set; } + public IEnumerable Beatmaps { get; set; } = Array.Empty(); public virtual BeatmapSetInfo ToBeatmapSet(RulesetStore rulesets) { var beatmapSet = new BeatmapSetInfo { - OnlineBeatmapSetID = OnlineBeatmapSetID, - Metadata = this, + OnlineBeatmapSetID = OnlineID, + Metadata = metadata, Status = Status, - Metrics = ratings == null ? null : new BeatmapSetMetrics { Ratings = ratings }, - OnlineInfo = new BeatmapSetOnlineInfo - { - Covers = covers, - Preview = preview, - PlayCount = playCount, - FavouriteCount = favouriteCount, - BPM = bpm, - Status = Status, - HasExplicitContent = hasExplicitContent, - HasVideo = hasVideo, - HasStoryboard = hasStoryboard, - Submitted = submitted, - Ranked = ranked, - LastUpdated = lastUpdated, - Availability = availability, - HasFavourited = hasFavourited, - Genre = genre, - Language = language, - TrackId = trackId - }, + OnlineInfo = this }; - beatmapSet.Beatmaps = beatmaps?.Select(b => + beatmapSet.Beatmaps = Beatmaps.Select(b => { var beatmap = b.ToBeatmapInfo(rulesets); beatmap.BeatmapSet = beatmapSet; @@ -124,5 +141,31 @@ namespace osu.Game.Online.API.Requests.Responses return beatmapSet; } + + private BeatmapMetadata metadata => new BeatmapMetadata + { + Title = Title, + TitleUnicode = TitleUnicode, + Artist = Artist, + ArtistUnicode = ArtistUnicode, + AuthorID = AuthorID, + Author = Author, + Source = Source, + Tags = Tags, + }; + + #region Implementation of IBeatmapSetInfo + + IEnumerable IBeatmapSetInfo.Beatmaps => Beatmaps; + + IBeatmapMetadataInfo IBeatmapSetInfo.Metadata => metadata; + + DateTimeOffset IBeatmapSetInfo.DateAdded => throw new NotImplementedException(); + IEnumerable IBeatmapSetInfo.Files => throw new NotImplementedException(); + double IBeatmapSetInfo.MaxStarDifficulty => throw new NotImplementedException(); + double IBeatmapSetInfo.MaxLength => throw new NotImplementedException(); + double IBeatmapSetInfo.MaxBPM => throw new NotImplementedException(); + + #endregion } } diff --git a/osu.Game/Online/API/Requests/Responses/APIUserMostPlayedBeatmap.cs b/osu.Game/Online/API/Requests/Responses/APIUserMostPlayedBeatmap.cs index 10f7ca6fe2..19c581bf95 100644 --- a/osu.Game/Online/API/Requests/Responses/APIUserMostPlayedBeatmap.cs +++ b/osu.Game/Online/API/Requests/Responses/APIUserMostPlayedBeatmap.cs @@ -2,8 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using Newtonsoft.Json; -using osu.Game.Beatmaps; -using osu.Game.Rulesets; namespace osu.Game.Online.API.Requests.Responses { @@ -16,17 +14,19 @@ namespace osu.Game.Online.API.Requests.Responses public int PlayCount { get; set; } [JsonProperty("beatmap")] - private BeatmapInfo beatmapInfo { get; set; } + private APIBeatmap beatmap { get; set; } - [JsonProperty] - private APIBeatmapSet beatmapSet { get; set; } - - public BeatmapInfo GetBeatmapInfo(RulesetStore rulesets) + public APIBeatmap BeatmapInfo { - BeatmapSetInfo setInfo = beatmapSet.ToBeatmapSet(rulesets); - beatmapInfo.BeatmapSet = setInfo; - beatmapInfo.Metadata = setInfo.Metadata; - return beatmapInfo; + get + { + // old osu-web code doesn't nest set. + beatmap.BeatmapSet = BeatmapSet; + return beatmap; + } } + + [JsonProperty("beatmapset")] + public APIBeatmapSet BeatmapSet { get; set; } } } diff --git a/osu.Game/Online/Chat/MessageFormatter.cs b/osu.Game/Online/Chat/MessageFormatter.cs index 0e4ea694aa..201ba6239b 100644 --- a/osu.Game/Online/Chat/MessageFormatter.cs +++ b/osu.Game/Online/Chat/MessageFormatter.cs @@ -177,6 +177,24 @@ namespace osu.Game.Online.Chat case "wiki": return new LinkDetails(LinkAction.OpenWiki, string.Join('/', args.Skip(3))); + + case "home": + if (mainArg != "changelog") + // handle link other than changelog as external for now + return new LinkDetails(LinkAction.External, url); + + switch (args.Length) + { + case 4: + // https://osu.ppy.sh/home/changelog + return new LinkDetails(LinkAction.OpenChangelog, string.Empty); + + case 6: + // https://osu.ppy.sh/home/changelog/lazer/2021.1006 + return new LinkDetails(LinkAction.OpenChangelog, $"{args[4]}/{args[5]}"); + } + + break; } } @@ -324,6 +342,7 @@ namespace osu.Game.Online.Chat SearchBeatmapSet, OpenWiki, Custom, + OpenChangelog, } public class Link : IComparable diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 75bbaec0ef..28505f6b0e 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -374,7 +374,7 @@ namespace osu.Game.Online.Multiplayer UserJoined?.Invoke(user); RoomUpdated?.Invoke(); - }, false); + }); } Task IMultiplayerClient.UserLeft(MultiplayerRoomUser user) => diff --git a/osu.Game/Online/Rooms/APIPlaylistBeatmap.cs b/osu.Game/Online/Rooms/APIPlaylistBeatmap.cs deleted file mode 100644 index 00623282d3..0000000000 --- a/osu.Game/Online/Rooms/APIPlaylistBeatmap.cs +++ /dev/null @@ -1,23 +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 Newtonsoft.Json; -using osu.Game.Beatmaps; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Rulesets; - -namespace osu.Game.Online.Rooms -{ - public class APIPlaylistBeatmap : APIBeatmap - { - [JsonProperty("checksum")] - public string Checksum { get; set; } - - public override BeatmapInfo ToBeatmapInfo(RulesetStore rulesets) - { - var b = base.ToBeatmapInfo(rulesets); - b.MD5Hash = Checksum; - return b; - } - } -} diff --git a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs index 86879ba245..52aa115083 100644 --- a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs @@ -59,7 +59,7 @@ namespace osu.Game.Online.Rooms protected override bool VerifyDatabasedModel(BeatmapSetInfo databasedSet) { - int? beatmapId = SelectedItem.Value?.Beatmap.Value.OnlineBeatmapID; + int beatmapId = SelectedItem.Value?.Beatmap.Value.OnlineID ?? -1; string checksum = SelectedItem.Value?.Beatmap.Value.MD5Hash; var matchingBeatmap = databasedSet.Beatmaps.FirstOrDefault(b => b.OnlineBeatmapID == beatmapId && b.MD5Hash == checksum); @@ -75,10 +75,10 @@ namespace osu.Game.Online.Rooms protected override bool IsModelAvailableLocally() { - int? beatmapId = SelectedItem.Value.Beatmap.Value.OnlineBeatmapID; + int onlineId = SelectedItem.Value.Beatmap.Value.OnlineID; string checksum = SelectedItem.Value.Beatmap.Value.MD5Hash; - var beatmap = Manager.QueryBeatmap(b => b.OnlineBeatmapID == beatmapId && b.MD5Hash == checksum); + var beatmap = Manager.QueryBeatmap(b => b.OnlineBeatmapID == onlineId && b.MD5Hash == checksum); return beatmap?.BeatmapSet.DeletePending == false; } diff --git a/osu.Game/Online/Rooms/PlaylistItem.cs b/osu.Game/Online/Rooms/PlaylistItem.cs index 48f1347fa1..7fcce1514d 100644 --- a/osu.Game/Online/Rooms/PlaylistItem.cs +++ b/osu.Game/Online/Rooms/PlaylistItem.cs @@ -7,6 +7,7 @@ using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -42,7 +43,7 @@ namespace osu.Game.Online.Rooms public readonly BindableList RequiredMods = new BindableList(); [JsonProperty("beatmap")] - private APIPlaylistBeatmap apiBeatmap { get; set; } + private APIBeatmap apiBeatmap { get; set; } private APIMod[] allowedModsBacking; diff --git a/osu.Game/Online/Rooms/Room.cs b/osu.Game/Online/Rooms/Room.cs index 5f71b4be4a..39fc7f1da8 100644 --- a/osu.Game/Online/Rooms/Room.cs +++ b/osu.Game/Online/Rooms/Room.cs @@ -130,12 +130,6 @@ namespace osu.Game.Online.Rooms set => MaxAttempts.Value = value; } - /// - /// The position of this in the list. This is not read from or written to the API. - /// - [JsonIgnore] - public readonly Bindable Position = new Bindable(-1); // Todo: This does not need to exist. - public Room() { Password.BindValueChanged(p => HasPassword.Value = !string.IsNullOrEmpty(p.NewValue)); @@ -192,8 +186,6 @@ namespace osu.Game.Online.Rooms RecentParticipants.Clear(); RecentParticipants.AddRange(other.RecentParticipants); } - - Position.Value = other.Position.Value; } public void RemoveExpiredPlaylistItems() diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index e604e91471..840a037614 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -90,6 +90,8 @@ namespace osu.Game private WikiOverlay wikiOverlay; + private ChangelogOverlay changelogOverlay; + private SkinEditorOverlay skinEditor; private Container overlayContent; @@ -209,13 +211,6 @@ namespace osu.Game [BackgroundDependencyLoader] private void load() { - if (args?.Length > 0) - { - var paths = args.Where(a => !a.StartsWith('-')).ToArray(); - if (paths.Length > 0) - Task.Run(() => Import(paths)); - } - dependencies.CacheAs(this); dependencies.Cache(SentryLogger); @@ -336,6 +331,17 @@ namespace osu.Game ShowWiki(link.Argument); break; + case LinkAction.OpenChangelog: + if (string.IsNullOrEmpty(link.Argument)) + ShowChangelogListing(); + else + { + var changelogArgs = link.Argument.Split("/"); + ShowChangelogBuild(changelogArgs[0], changelogArgs[1]); + } + + break; + default: throw new NotImplementedException($"This {nameof(LinkAction)} ({link.Action.ToString()}) is missing an associated action."); } @@ -401,6 +407,18 @@ namespace osu.Game /// The wiki page to show public void ShowWiki(string path) => waitForReady(() => wikiOverlay, _ => wikiOverlay.ShowPage(path)); + /// + /// Show changelog listing overlay + /// + public void ShowChangelogListing() => waitForReady(() => changelogOverlay, _ => changelogOverlay.ShowListing()); + + /// + /// Show changelog's build as an overlay + /// + /// The update stream name + /// The build version of the update stream + public void ShowChangelogBuild(string updateStream, string version) => waitForReady(() => changelogOverlay, _ => changelogOverlay.ShowBuild(updateStream, version)); + /// /// Present a beatmap at song select immediately. /// The user should have already requested this interactively. @@ -536,6 +554,7 @@ namespace osu.Game { beatmap.OldValue?.CancelAsyncLoad(); beatmap.NewValue?.BeginAsyncLoad(); + Logger.Log($"Game-wide working beatmap updated to {beatmap.NewValue}"); } private void modsChanged(ValueChangedEvent> mods) @@ -624,7 +643,7 @@ namespace osu.Game SkinManager.PostNotification = n => Notifications.Post(n); BeatmapManager.PostNotification = n => Notifications.Post(n); - BeatmapManager.PresentImport = items => PresentBeatmap(items.First().Value); + BeatmapManager.PostImport = items => PresentBeatmap(items.First().Value); ScoreManager.PostNotification = n => Notifications.Post(n); ScoreManager.PostImport = items => PresentScore(items.First().Value); @@ -638,9 +657,9 @@ namespace osu.Game var combinations = KeyBindingStore.GetReadableKeyCombinationsFor(l); if (combinations.Count == 0) - return "none"; + return ToastStrings.NoKeyBound; - return string.Join(" or ", combinations); + return string.Join(" / ", combinations); }; Container logoContainer; @@ -769,7 +788,7 @@ namespace osu.Game loadComponentSingleFile(chatOverlay = new ChatOverlay(), overlayContent.Add, true); loadComponentSingleFile(new MessageNotifier(), AddInternal, true); loadComponentSingleFile(Settings = new SettingsOverlay(), leftFloatingOverlayContent.Add, true); - var changelogOverlay = loadComponentSingleFile(new ChangelogOverlay(), overlayContent.Add, true); + loadComponentSingleFile(changelogOverlay = new ChangelogOverlay(), overlayContent.Add, true); loadComponentSingleFile(userProfile = new UserProfileOverlay(), overlayContent.Add, true); loadComponentSingleFile(beatmapSetOverlay = new BeatmapSetOverlay(), overlayContent.Add, true); loadComponentSingleFile(wikiOverlay = new WikiOverlay(), overlayContent.Add, true); @@ -842,6 +861,19 @@ namespace osu.Game { if (mode.NewValue != OverlayActivation.All) CloseAllOverlays(); }; + + // Importantly, this should be run after binding PostNotification to the import handlers so they can present the import after game startup. + handleStartupImport(); + } + + private void handleStartupImport() + { + if (args?.Length > 0) + { + var paths = args.Where(a => !a.StartsWith('-')).ToArray(); + if (paths.Length > 0) + Task.Run(() => Import(paths)); + } } private void showOverlayAboveOthers(OverlayContainer overlay, OverlayContainer[] otherOverlays) @@ -881,13 +913,15 @@ namespace osu.Game } else if (recentLogCount == short_term_display_limit) { + var logFile = $@"{entry.Target.ToString().ToLowerInvariant()}.log"; + Schedule(() => Notifications.Post(new SimpleNotification { Icon = FontAwesome.Solid.EllipsisH, Text = "Subsequent messages have been logged. Click to view log files.", Activated = () => { - Storage.GetStorageForDirectory("logs").OpenInNativeExplorer(); + Storage.GetStorageForDirectory(@"logs").PresentFileExternally(logFile); return true; } })); diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 7f4fe8a943..f6ec22a536 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; +using System.Threading; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Bindables; @@ -186,8 +187,6 @@ namespace osu.Game dependencies.Cache(realmFactory = new RealmContextFactory(Storage, "client")); - AddInternal(realmFactory); - dependencies.CacheAs(Storage); var largeStore = new LargeTextureStore(Host.CreateTextureLoaderStore(new NamespacedResourceStore(Resources, @"Textures"))); @@ -410,11 +409,28 @@ namespace osu.Game { Logger.Log($@"Migrating osu! data from ""{Storage.GetFullPath(string.Empty)}"" to ""{path}""..."); - using (realmFactory.BlockAllOperations()) + IDisposable realmBlocker = null; + + try { - contextFactory.FlushConnections(); + ManualResetEventSlim readyToRun = new ManualResetEventSlim(); + + Scheduler.Add(() => + { + realmBlocker = realmFactory.BlockAllOperations(); + contextFactory.FlushConnections(); + + readyToRun.Set(); + }, false); + + readyToRun.Wait(); + (Storage as OsuStorage)?.Migrate(Host.GetStorage(path)); } + finally + { + realmBlocker?.Dispose(); + } Logger.Log(@"Migration complete!"); } @@ -511,6 +527,7 @@ namespace osu.Game LocalConfig?.Dispose(); contextFactory?.FlushConnections(); + realmFactory?.Dispose(); } } } diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs index bbde03aa10..da2dcfebdf 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs @@ -76,7 +76,7 @@ namespace osu.Game.Overlays.BeatmapListing private readonly BeatmapSearchFilterRow explicitContentFilter; private readonly Box background; - private readonly UpdateableBeatmapSetCover beatmapCover; + private readonly UpdateableOnlineBeatmapSetCover beatmapCover; public BeatmapListingSearchControl() { @@ -196,7 +196,7 @@ namespace osu.Game.Overlays.BeatmapListing } } - private class TopSearchBeatmapSetCover : UpdateableBeatmapSetCover + private class TopSearchBeatmapSetCover : UpdateableOnlineBeatmapSetCover { protected override bool TransformImmediately => true; } diff --git a/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanel.cs b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanel.cs index 9ff39ce1dd..779f3860f2 100644 --- a/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanel.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanel.cs @@ -160,7 +160,7 @@ namespace osu.Game.Overlays.BeatmapListing.Panels return icons; } - protected Drawable CreateBackground() => new UpdateableBeatmapSetCover + protected Drawable CreateBackground() => new UpdateableOnlineBeatmapSetCover { RelativeSizeAxes = Axes.Both, BeatmapSet = SetInfo, diff --git a/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs index 47b477ef9a..a8c4334ffb 100644 --- a/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs @@ -92,7 +92,7 @@ namespace osu.Game.Overlays.BeatmapListing.Panels break; default: - if (BeatmapSet.Value?.OnlineInfo?.Availability?.DownloadDisabled ?? false) + if (BeatmapSet.Value?.OnlineInfo?.Availability.DownloadDisabled ?? false) { button.Enabled.Value = false; button.TooltipText = "this beatmap is currently not available for download."; diff --git a/osu.Game/Overlays/BeatmapListing/Panels/GridBeatmapPanel.cs b/osu.Game/Overlays/BeatmapListing/Panels/GridBeatmapPanel.cs index c078127353..4a0fa59c31 100644 --- a/osu.Game/Overlays/BeatmapListing/Panels/GridBeatmapPanel.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/GridBeatmapPanel.cs @@ -243,6 +243,7 @@ namespace osu.Game.Overlays.BeatmapListing.Panels statusContainer.Add(new BeatmapSetOnlineStatusPill { + AutoSizeAxes = Axes.Both, TextSize = 12, TextPadding = new MarginPadding { Horizontal = 10, Vertical = 5 }, Status = SetInfo.OnlineInfo?.Status ?? BeatmapSetOnlineStatus.None, diff --git a/osu.Game/Overlays/BeatmapListing/Panels/ListBeatmapPanel.cs b/osu.Game/Overlays/BeatmapListing/Panels/ListBeatmapPanel.cs index 5011749c5f..63d651f9de 100644 --- a/osu.Game/Overlays/BeatmapListing/Panels/ListBeatmapPanel.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/ListBeatmapPanel.cs @@ -257,6 +257,7 @@ namespace osu.Game.Overlays.BeatmapListing.Panels statusContainer.Add(new BeatmapSetOnlineStatusPill { + AutoSizeAxes = Axes.Both, TextSize = 12, TextPadding = new MarginPadding { Horizontal = 10, Vertical = 4 }, Status = SetInfo.OnlineInfo?.Status ?? BeatmapSetOnlineStatus.None, diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapAvailability.cs b/osu.Game/Overlays/BeatmapSet/BeatmapAvailability.cs index 896c646552..f005a37eaa 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapAvailability.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapAvailability.cs @@ -16,8 +16,8 @@ namespace osu.Game.Overlays.BeatmapSet { private BeatmapSetInfo beatmapSet; - private bool downloadDisabled => BeatmapSet?.OnlineInfo.Availability?.DownloadDisabled ?? false; - private bool hasExternalLink => !string.IsNullOrEmpty(BeatmapSet?.OnlineInfo.Availability?.ExternalLink); + private bool downloadDisabled => BeatmapSet?.OnlineInfo.Availability.DownloadDisabled ?? false; + private bool hasExternalLink => !string.IsNullOrEmpty(BeatmapSet?.OnlineInfo.Availability.ExternalLink); private readonly LinkFlowContainer textContainer; diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs index dcf06ac7fb..6f85846720 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs @@ -32,7 +32,7 @@ namespace osu.Game.Overlays.BeatmapSet public readonly Details Details; public readonly BeatmapPicker Picker; - private readonly UpdateableBeatmapSetCover cover; + private readonly UpdateableOnlineBeatmapSetCover cover; private readonly Box coverGradient; private readonly OsuSpriteText title, artist; private readonly AuthorInfo author; @@ -68,7 +68,7 @@ namespace osu.Game.Overlays.BeatmapSet RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - cover = new UpdateableBeatmapSetCover + cover = new UpdateableOnlineBeatmapSetCover { RelativeSizeAxes = Axes.Both, Masking = true, @@ -198,6 +198,7 @@ namespace osu.Game.Overlays.BeatmapSet { onlineStatusPill = new BeatmapSetOnlineStatusPill { + AutoSizeAxes = Axes.Both, Anchor = Anchor.TopRight, Origin = Anchor.TopRight, TextSize = 14, @@ -220,7 +221,6 @@ namespace osu.Game.Overlays.BeatmapSet private void load(OverlayColourProvider colourProvider) { coverGradient.Colour = ColourInfo.GradientVertical(colourProvider.Background6.Opacity(0.3f), colourProvider.Background6.Opacity(0.8f)); - onlineStatusPill.BackgroundColour = colourProvider.Background6; State.BindValueChanged(_ => updateDownloadButtons()); @@ -266,7 +266,7 @@ namespace osu.Game.Overlays.BeatmapSet { if (BeatmapSet.Value == null) return; - if ((BeatmapSet.Value.OnlineInfo.Availability?.DownloadDisabled ?? false) && State.Value != DownloadState.LocallyAvailable) + if (BeatmapSet.Value.OnlineInfo.Availability.DownloadDisabled && State.Value != DownloadState.LocallyAvailable) { downloadButtonsContainer.Clear(); return; diff --git a/osu.Game/Overlays/BeatmapSet/Details.cs b/osu.Game/Overlays/BeatmapSet/Details.cs index 92361ae4f8..d6720e5f35 100644 --- a/osu.Game/Overlays/BeatmapSet/Details.cs +++ b/osu.Game/Overlays/BeatmapSet/Details.cs @@ -52,7 +52,7 @@ namespace osu.Game.Overlays.BeatmapSet private void updateDisplay() { - Ratings.Metrics = BeatmapSet?.Metrics; + Ratings.Ratings = BeatmapSet?.Ratings; ratingBox.Alpha = BeatmapSet?.OnlineInfo?.Status > 0 ? 1 : 0; } diff --git a/osu.Game/Overlays/BeatmapSet/Info.cs b/osu.Game/Overlays/BeatmapSet/Info.cs index 61c660cbaa..8bc5c6d27e 100644 --- a/osu.Game/Overlays/BeatmapSet/Info.cs +++ b/osu.Game/Overlays/BeatmapSet/Info.cs @@ -117,8 +117,8 @@ namespace osu.Game.Overlays.BeatmapSet { source.Text = b.NewValue?.Metadata.Source ?? string.Empty; tags.Text = b.NewValue?.Metadata.Tags ?? string.Empty; - genre.Text = b.NewValue?.OnlineInfo?.Genre?.Name ?? string.Empty; - language.Text = b.NewValue?.OnlineInfo?.Language?.Name ?? string.Empty; + genre.Text = b.NewValue?.OnlineInfo?.Genre.Name ?? string.Empty; + language.Text = b.NewValue?.OnlineInfo?.Language.Name ?? string.Empty; var setHasLeaderboard = b.NewValue?.OnlineInfo?.Status > 0; successRate.Alpha = setHasLeaderboard ? 1 : 0; notRankedPlaceholder.Alpha = setHasLeaderboard ? 0 : 1; diff --git a/osu.Game/Overlays/BeatmapSet/SuccessRate.cs b/osu.Game/Overlays/BeatmapSet/SuccessRate.cs index 4a9b8244a5..604c4e1949 100644 --- a/osu.Game/Overlays/BeatmapSet/SuccessRate.cs +++ b/osu.Game/Overlays/BeatmapSet/SuccessRate.cs @@ -48,7 +48,7 @@ namespace osu.Game.Overlays.BeatmapSet successRate.Length = rate; percentContainer.ResizeWidthTo(successRate.Length, 250, Easing.InOutCubic); - Graph.Metrics = beatmapInfo?.Metrics; + Graph.FailTimes = beatmapInfo?.FailTimes; } public SuccessRate() diff --git a/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapPanel.cs b/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapPanel.cs index 3badea155d..edc737d8fe 100644 --- a/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapPanel.cs +++ b/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapPanel.cs @@ -77,7 +77,7 @@ namespace osu.Game.Overlays.Dashboard.Home RelativeSizeAxes = Axes.Both, Masking = true, CornerRadius = 6, - Child = new UpdateableBeatmapSetCover + Child = new UpdateableOnlineBeatmapSetCover { RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, diff --git a/osu.Game/Overlays/Login/UserDropdown.cs b/osu.Game/Overlays/Login/UserDropdown.cs index ac4e7f8eda..5c3a41aec9 100644 --- a/osu.Game/Overlays/Login/UserDropdown.cs +++ b/osu.Game/Overlays/Login/UserDropdown.cs @@ -29,12 +29,6 @@ namespace osu.Game.Overlays.Login } } - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - AccentColour = colours.Gray5; - } - protected class UserDropdownMenu : OsuDropdownMenu { public UserDropdownMenu() @@ -56,6 +50,8 @@ namespace osu.Game.Overlays.Login private void load(OsuColour colours) { BackgroundColour = colours.Gray3; + SelectionColour = colours.Gray4; + HoverColour = colours.Gray5; } protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new DrawableUserDropdownMenuItem(item); @@ -118,6 +114,7 @@ namespace osu.Game.Overlays.Login private void load(OsuColour colours) { BackgroundColour = colours.Gray3; + BackgroundColourHover = colours.Gray5; } } } diff --git a/osu.Game/Overlays/Music/CollectionDropdown.cs b/osu.Game/Overlays/Music/CollectionDropdown.cs index ed0ebf696b..658eebe67b 100644 --- a/osu.Game/Overlays/Music/CollectionDropdown.cs +++ b/osu.Game/Overlays/Music/CollectionDropdown.cs @@ -19,12 +19,6 @@ namespace osu.Game.Overlays.Music { protected override bool ShowManageCollectionsItem => false; - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - AccentColour = colours.Gray6; - } - protected override CollectionDropdownHeader CreateCollectionHeader() => new CollectionsHeader(); protected override CollectionDropdownMenu CreateCollectionMenu() => new CollectionsMenu(); @@ -41,6 +35,8 @@ namespace osu.Game.Overlays.Music private void load(OsuColour colours) { BackgroundColour = colours.Gray4; + SelectionColour = colours.Gray5; + HoverColour = colours.Gray6; } } @@ -50,6 +46,7 @@ namespace osu.Game.Overlays.Music private void load(OsuColour colours) { BackgroundColour = colours.Gray4; + BackgroundColourHover = colours.Gray6; } public CollectionsHeader() diff --git a/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs b/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs index dba4bf926f..18ec69e106 100644 --- a/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs +++ b/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs @@ -3,12 +3,15 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Input.Bindings; +using osu.Game.Localisation; using osu.Game.Overlays.OSD; namespace osu.Game.Overlays.Music @@ -39,11 +42,11 @@ namespace osu.Game.Overlays.Music bool wasPlaying = musicController.IsPlaying; if (musicController.TogglePause()) - onScreenDisplay?.Display(new MusicActionToast(wasPlaying ? "Pause track" : "Play track", e.Action)); + onScreenDisplay?.Display(new MusicActionToast(wasPlaying ? ToastStrings.PauseTrack : ToastStrings.PlayTrack, e.Action)); return true; case GlobalAction.MusicNext: - musicController.NextTrack(() => onScreenDisplay?.Display(new MusicActionToast("Next track", e.Action))); + musicController.NextTrack(() => onScreenDisplay?.Display(new MusicActionToast(GlobalActionKeyBindingStrings.MusicNext, e.Action))); return true; @@ -53,11 +56,11 @@ namespace osu.Game.Overlays.Music switch (res) { case PreviousTrackResult.Restart: - onScreenDisplay?.Display(new MusicActionToast("Restart track", e.Action)); + onScreenDisplay?.Display(new MusicActionToast(ToastStrings.RestartTrack, e.Action)); break; case PreviousTrackResult.Previous: - onScreenDisplay?.Display(new MusicActionToast("Previous track", e.Action)); + onScreenDisplay?.Display(new MusicActionToast(GlobalActionKeyBindingStrings.MusicPrev, e.Action)); break; } }); @@ -76,8 +79,8 @@ namespace osu.Game.Overlays.Music { private readonly GlobalAction action; - public MusicActionToast(string value, GlobalAction action) - : base("Music Playback", value, string.Empty) + public MusicActionToast(LocalisableString value, GlobalAction action) + : base(ToastStrings.MusicPlayback, value, string.Empty) { this.action = action; } @@ -85,7 +88,7 @@ namespace osu.Game.Overlays.Music [BackgroundDependencyLoader] private void load(OsuConfigManager config) { - ShortcutText.Text = config.LookupKeyBindings(action).ToUpperInvariant(); + ShortcutText.Text = config.LookupKeyBindings(action).ToUpper(); } } } diff --git a/osu.Game/Overlays/Notifications/ProgressNotification.cs b/osu.Game/Overlays/Notifications/ProgressNotification.cs index 3105ecd742..f8cd31f193 100644 --- a/osu.Game/Overlays/Notifications/ProgressNotification.cs +++ b/osu.Game/Overlays/Notifications/ProgressNotification.cs @@ -31,10 +31,12 @@ namespace osu.Game.Overlays.Notifications set { progress = value; - Scheduler.AddOnce(() => progressBar.Progress = progress); + Scheduler.AddOnce(updateProgress, progress); } } + private void updateProgress(float progress) => progressBar.Progress = progress; + protected override void LoadComplete() { base.LoadComplete(); diff --git a/osu.Game/Overlays/OSD/Toast.cs b/osu.Game/Overlays/OSD/Toast.cs index 4a6316df3f..12e30d8de2 100644 --- a/osu.Game/Overlays/OSD/Toast.cs +++ b/osu.Game/Overlays/OSD/Toast.cs @@ -1,13 +1,16 @@ // 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.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK; using osuTK.Graphics; +using osu.Game.Localisation; namespace osu.Game.Overlays.OSD { @@ -23,7 +26,7 @@ namespace osu.Game.Overlays.OSD protected readonly OsuSpriteText ShortcutText; - protected Toast(string description, string value, string shortcut) + protected Toast(LocalisableString description, LocalisableString value, LocalisableString shortcut) { Anchor = Anchor.Centre; Origin = Anchor.Centre; @@ -60,12 +63,12 @@ namespace osu.Game.Overlays.OSD Spacing = new Vector2(1, 0), Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Text = description.ToUpperInvariant() + Text = description.ToUpper() }, ValueText = new OsuSpriteText { Font = OsuFont.GetFont(size: 24, weight: FontWeight.Light), - Padding = new MarginPadding { Left = 10, Right = 10 }, + Padding = new MarginPadding { Horizontal = 10 }, Name = "Value", Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -77,9 +80,9 @@ namespace osu.Game.Overlays.OSD Origin = Anchor.BottomCentre, Name = "Shortcut", Alpha = 0.3f, - Margin = new MarginPadding { Bottom = 15 }, + Margin = new MarginPadding { Bottom = 15, Horizontal = 10 }, Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), - Text = string.IsNullOrEmpty(shortcut) ? "NO KEY BOUND" : shortcut.ToUpperInvariant() + Text = string.IsNullOrEmpty(shortcut.ToString()) ? ToastStrings.NoKeyBound.ToUpper() : shortcut.ToUpper() }, }; } diff --git a/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs b/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs index 7812a81f30..94ef5e5d86 100644 --- a/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/BeatmapMetadataContainer.cs @@ -15,9 +15,9 @@ namespace osu.Game.Overlays.Profile.Sections /// public abstract class BeatmapMetadataContainer : OsuHoverContainer { - private readonly BeatmapInfo beatmapInfo; + private readonly IBeatmapInfo beatmapInfo; - protected BeatmapMetadataContainer(BeatmapInfo beatmapInfo) + protected BeatmapMetadataContainer(IBeatmapInfo beatmapInfo) : base(HoverSampleSet.Submit) { this.beatmapInfo = beatmapInfo; @@ -30,10 +30,7 @@ namespace osu.Game.Overlays.Profile.Sections { Action = () => { - if (beatmapInfo.OnlineBeatmapID != null) - beatmapSetOverlay?.FetchAndShowBeatmap(beatmapInfo.OnlineBeatmapID.Value); - else if (beatmapInfo.BeatmapSet?.OnlineBeatmapSetID != null) - beatmapSetOverlay?.FetchAndShowBeatmapSet(beatmapInfo.BeatmapSet.OnlineBeatmapSetID.Value); + beatmapSetOverlay?.FetchAndShowBeatmap(beatmapInfo.OnlineID); }; Child = new FillFlowContainer @@ -43,6 +40,6 @@ namespace osu.Game.Overlays.Profile.Sections }; } - protected abstract Drawable[] CreateText(BeatmapInfo beatmapInfo); + protected abstract Drawable[] CreateText(IBeatmapInfo beatmapInfo); } } diff --git a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs index 8657e356c9..c1e56facd9 100644 --- a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs @@ -60,12 +60,12 @@ namespace osu.Game.Overlays.Profile.Sections.Beatmaps protected override APIRequest> CreateRequest() => new GetUserBeatmapsRequest(User.Value.Id, type, VisiblePages++, ItemsPerPage); - protected override Drawable CreateDrawableItem(APIBeatmapSet model) => !model.OnlineBeatmapSetID.HasValue - ? null - : new GridBeatmapPanel(model.ToBeatmapSet(Rulesets)) + protected override Drawable CreateDrawableItem(APIBeatmapSet model) => model.OnlineID > 0 + ? new GridBeatmapPanel(model.ToBeatmapSet(Rulesets)) { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - }; + } + : null; } } diff --git a/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs b/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs index 2c6fa76ca4..c4c8bfb84f 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.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.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -13,6 +14,7 @@ using osu.Game.Graphics.Sprites; using osuTK; using osu.Framework.Graphics.Cursor; using osu.Framework.Localisation; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Overlays.Profile.Sections.Historical @@ -22,13 +24,11 @@ namespace osu.Game.Overlays.Profile.Sections.Historical private const int cover_width = 100; private const int corner_radius = 6; - private readonly BeatmapInfo beatmapInfo; - private readonly int playCount; + private readonly APIUserMostPlayedBeatmap mostPlayed; - public DrawableMostPlayedBeatmap(BeatmapInfo beatmapInfo, int playCount) + public DrawableMostPlayedBeatmap(APIUserMostPlayedBeatmap mostPlayed) { - this.beatmapInfo = beatmapInfo; - this.playCount = playCount; + this.mostPlayed = mostPlayed; RelativeSizeAxes = Axes.X; Height = 50; @@ -42,11 +42,11 @@ namespace osu.Game.Overlays.Profile.Sections.Historical { AddRangeInternal(new Drawable[] { - new UpdateableBeatmapSetCover(BeatmapSetCoverType.List) + new UpdateableOnlineBeatmapSetCover(BeatmapSetCoverType.List) { RelativeSizeAxes = Axes.Y, Width = cover_width, - BeatmapSet = beatmapInfo.BeatmapSet, + BeatmapSet = mostPlayed.BeatmapSet, }, new Container { @@ -77,7 +77,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical Direction = FillDirection.Vertical, Children = new Drawable[] { - new MostPlayedBeatmapMetadataContainer(beatmapInfo), + new MostPlayedBeatmapMetadataContainer(mostPlayed.BeatmapInfo), new LinkFlowContainer(t => { t.Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular); @@ -89,11 +89,11 @@ namespace osu.Game.Overlays.Profile.Sections.Historical }.With(d => { d.AddText("mapped by "); - d.AddUserLink(beatmapInfo.Metadata.Author); + d.AddUserLink(mostPlayed.BeatmapSet.Author); }), } }, - new PlayCountText(playCount) + new PlayCountText(mostPlayed.PlayCount) { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight @@ -120,26 +120,41 @@ namespace osu.Game.Overlays.Profile.Sections.Historical private class MostPlayedBeatmapMetadataContainer : BeatmapMetadataContainer { - public MostPlayedBeatmapMetadataContainer(BeatmapInfo beatmapInfo) + public MostPlayedBeatmapMetadataContainer(IBeatmapInfo beatmapInfo) : base(beatmapInfo) { } - protected override Drawable[] CreateText(BeatmapInfo beatmapInfo) => new Drawable[] + protected override Drawable[] CreateText(IBeatmapInfo beatmapInfo) { - new OsuSpriteText + var metadata = beatmapInfo.Metadata; + + Debug.Assert(metadata != null); + + return new Drawable[] { - Text = new RomanisableString( - $"{beatmapInfo.Metadata.TitleUnicode ?? beatmapInfo.Metadata.Title} [{beatmapInfo.Version}] ", - $"{beatmapInfo.Metadata.Title ?? beatmapInfo.Metadata.TitleUnicode} [{beatmapInfo.Version}] "), - Font = OsuFont.GetFont(weight: FontWeight.Bold) - }, - new OsuSpriteText - { - Text = "by " + new RomanisableString(beatmapInfo.Metadata.ArtistUnicode, beatmapInfo.Metadata.Artist), - Font = OsuFont.GetFont(weight: FontWeight.Regular) - }, - }; + new OsuSpriteText + { + Text = new RomanisableString(metadata.TitleUnicode, metadata.Title), + Font = OsuFont.GetFont(weight: FontWeight.Bold) + }, + new OsuSpriteText + { + Text = $" [{beatmapInfo.DifficultyName}]", + Font = OsuFont.GetFont(weight: FontWeight.Bold) + }, + new OsuSpriteText + { + Text = " by ", + Font = OsuFont.GetFont(weight: FontWeight.Regular) + }, + new OsuSpriteText + { + Text = new RomanisableString(metadata.ArtistUnicode, metadata.Artist), + Font = OsuFont.GetFont(weight: FontWeight.Regular) + }, + }; + } } private class PlayCountText : CompositeDrawable, IHasTooltip diff --git a/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs index d0979526da..428d04f985 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/PaginatedMostPlayedBeatmapContainer.cs @@ -33,7 +33,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical protected override APIRequest> CreateRequest() => new GetUserMostPlayedBeatmapsRequest(User.Value.Id, VisiblePages++, ItemsPerPage); - protected override Drawable CreateDrawableItem(APIUserMostPlayedBeatmap model) => - new DrawableMostPlayedBeatmap(model.GetBeatmapInfo(Rulesets), model.PlayCount); + protected override Drawable CreateDrawableItem(APIUserMostPlayedBeatmap mostPlayed) => + new DrawableMostPlayedBeatmap(mostPlayed); } } diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs index 3561e9700e..7bfa2ee51e 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.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.Diagnostics; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -245,30 +246,42 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks private class ScoreBeatmapMetadataContainer : BeatmapMetadataContainer { - public ScoreBeatmapMetadataContainer(BeatmapInfo beatmapInfo) + public ScoreBeatmapMetadataContainer(IBeatmapInfo beatmapInfo) : base(beatmapInfo) { } - protected override Drawable[] CreateText(BeatmapInfo beatmapInfo) => new Drawable[] + protected override Drawable[] CreateText(IBeatmapInfo beatmapInfo) { - new OsuSpriteText + var metadata = beatmapInfo.Metadata; + + Debug.Assert(metadata != null); + + return new Drawable[] { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Text = new RomanisableString( - $"{beatmapInfo.Metadata.TitleUnicode ?? beatmapInfo.Metadata.Title} ", - $"{beatmapInfo.Metadata.Title ?? beatmapInfo.Metadata.TitleUnicode} "), - Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold, italics: true) - }, - new OsuSpriteText - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Text = "by " + new RomanisableString(beatmapInfo.Metadata.ArtistUnicode, beatmapInfo.Metadata.Artist), - Font = OsuFont.GetFont(size: 12, italics: true) - }, - }; + new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Text = new RomanisableString(metadata.TitleUnicode, metadata.Title), + Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold, italics: true) + }, + new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Text = " by ", + Font = OsuFont.GetFont(size: 12, italics: true) + }, + new OsuSpriteText + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Text = new RomanisableString(metadata.ArtistUnicode, metadata.Artist), + Font = OsuFont.GetFont(size: 12, italics: true) + }, + }; + } } } } diff --git a/osu.Game/Overlays/Rankings/SpotlightSelector.cs b/osu.Game/Overlays/Rankings/SpotlightSelector.cs index 0f071883ca..dfa45cc543 100644 --- a/osu.Game/Overlays/Rankings/SpotlightSelector.cs +++ b/osu.Game/Overlays/Rankings/SpotlightSelector.cs @@ -175,18 +175,18 @@ namespace osu.Game.Overlays.Rankings private class SpotlightsDropdown : OsuDropdown { - private DropdownMenu menu; + private OsuDropdownMenu menu; - protected override DropdownMenu CreateMenu() => menu = base.CreateMenu().With(m => m.MaxHeight = 400); + protected override DropdownMenu CreateMenu() => menu = (OsuDropdownMenu)base.CreateMenu().With(m => m.MaxHeight = 400); protected override DropdownHeader CreateHeader() => new SpotlightsDropdownHeader(); [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) { - // osu-web adds a 0.6 opacity container on top of the 0.5 base one when hovering, 0.8 on a single container here matches the resulting colour - AccentColour = colourProvider.Background6.Opacity(0.8f); menu.BackgroundColour = colourProvider.Background5; + menu.HoverColour = colourProvider.Background4; + menu.SelectionColour = colourProvider.Background3; Padding = new MarginPadding { Vertical = 20 }; } @@ -205,7 +205,8 @@ namespace osu.Game.Overlays.Rankings private void load(OverlayColourProvider colourProvider) { BackgroundColour = colourProvider.Background6.Opacity(0.5f); - BackgroundColourHover = colourProvider.Background5; + // osu-web adds a 0.6 opacity container on top of the 0.5 base one when hovering, 0.8 on a single container here matches the resulting colour + BackgroundColourHover = colourProvider.Background6.Opacity(0.8f); } } } diff --git a/osu.Game/Overlays/RestoreDefaultValueButton.cs b/osu.Game/Overlays/RestoreDefaultValueButton.cs index 87a294cc10..afc4146199 100644 --- a/osu.Game/Overlays/RestoreDefaultValueButton.cs +++ b/osu.Game/Overlays/RestoreDefaultValueButton.cs @@ -3,10 +3,8 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; -using osuTK.Graphics; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.UserInterface; @@ -14,6 +12,7 @@ using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; +using osuTK; namespace osu.Game.Overlays { @@ -45,30 +44,21 @@ namespace osu.Game.Overlays } } - private bool hovering; + [Resolved] + private OsuColour colours { get; set; } - public RestoreDefaultValueButton() - { - Height = 1; - - RelativeSizeAxes = Axes.Y; - Width = SettingsPanel.CONTENT_MARGINS; - } + private const float size = 4; [BackgroundDependencyLoader] private void load(OsuColour colour) { - BackgroundColour = colour.Yellow; - Content.Width = 0.33f; - Content.CornerRadius = 3; - Content.EdgeEffect = new EdgeEffectParameters - { - Colour = BackgroundColour.Opacity(0.1f), - Type = EdgeEffectType.Glow, - Radius = 2, - }; + BackgroundColour = colour.Lime1; + Size = new Vector2(3 * size); + + Content.RelativeSizeAxes = Axes.None; + Content.Size = new Vector2(size); + Content.CornerRadius = size / 2; - Padding = new MarginPadding { Vertical = 1.5f }; Alpha = 0f; Action += () => @@ -81,39 +71,55 @@ namespace osu.Game.Overlays protected override void LoadComplete() { base.LoadComplete(); - - // avoid unnecessary transforms on first display. - Alpha = currentAlpha; - Background.Colour = currentColour; + updateState(); + FinishTransforms(true); } public LocalisableString TooltipText => "revert to default"; protected override bool OnHover(HoverEvent e) { - hovering = true; UpdateState(); return false; } protected override void OnHoverLost(HoverLostEvent e) { - hovering = false; UpdateState(); } public void UpdateState() => Scheduler.AddOnce(updateState); - private float currentAlpha => current.IsDefault ? 0f : hovering && !current.Disabled ? 1f : 0.65f; - private ColourInfo currentColour => current.Disabled ? Color4.Gray : BackgroundColour; + private const double fade_duration = 200; private void updateState() { if (current == null) return; - this.FadeTo(currentAlpha, 200, Easing.OutQuint); - Background.FadeColour(currentColour, 200, Easing.OutQuint); + Enabled.Value = !Current.Disabled; + + if (!Current.Disabled) + { + this.FadeTo(Current.IsDefault ? 0 : 1, fade_duration, Easing.OutQuint); + Background.FadeColour(IsHovered ? colours.Lime0 : colours.Lime1, fade_duration, Easing.OutQuint); + Content.TweenEdgeEffectTo(new EdgeEffectParameters + { + Colour = (IsHovered ? colours.Lime1 : colours.Lime3).Opacity(0.4f), + Radius = IsHovered ? 8 : 4, + Type = EdgeEffectType.Glow + }, fade_duration, Easing.OutQuint); + } + else + { + Background.FadeColour(colours.Lime3, fade_duration, Easing.OutQuint); + Content.TweenEdgeEffectTo(new EdgeEffectParameters + { + Colour = colours.Lime3.Opacity(0.1f), + Radius = 2, + Type = EdgeEffectType.Glow + }, fade_duration, Easing.OutQuint); + } } } } diff --git a/osu.Game/Overlays/Settings/DangerousSettingsButton.cs b/osu.Game/Overlays/Settings/DangerousSettingsButton.cs index c02db40eca..4ca3ace8a1 100644 --- a/osu.Game/Overlays/Settings/DangerousSettingsButton.cs +++ b/osu.Game/Overlays/Settings/DangerousSettingsButton.cs @@ -14,10 +14,7 @@ namespace osu.Game.Overlays.Settings [BackgroundDependencyLoader] private void load(OsuColour colours) { - BackgroundColour = colours.Pink; - - Triangles.ColourDark = colours.PinkDark; - Triangles.ColourLight = colours.PinkLight; + BackgroundColour = colours.Pink3; } } } diff --git a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs index d697b45424..0c54ae2763 100644 --- a/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Audio/AudioDevicesSettings.cs @@ -28,6 +28,7 @@ namespace osu.Game.Overlays.Settings.Sections.Audio { dropdown = new AudioDeviceSettingsDropdown { + LabelText = AudioSettingsStrings.OutputDevice, Keywords = new[] { "speaker", "headphone", "output" } } }; diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/AudioSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/AudioSettings.cs new file mode 100644 index 0000000000..dba64d695a --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/AudioSettings.cs @@ -0,0 +1,34 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Settings.Sections.Gameplay +{ + public class AudioSettings : SettingsSubsection + { + protected override LocalisableString Header => GameplaySettingsStrings.AudioHeader; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + Children = new Drawable[] + { + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.PositionalHitsounds, + Current = config.GetBindable(OsuSetting.PositionalHitSounds) + }, + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.AlwaysPlayFirstComboBreak, + Current = config.GetBindable(OsuSetting.AlwaysPlayFirstComboBreak) + }, + }; + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs new file mode 100644 index 0000000000..94e0c5e494 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/BackgroundSettings.cs @@ -0,0 +1,48 @@ +// 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.Localisation; +using osu.Game.Configuration; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Settings.Sections.Gameplay +{ + public class BackgroundSettings : SettingsSubsection + { + protected override LocalisableString Header => GameplaySettingsStrings.BackgroundHeader; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + Children = new Drawable[] + { + new SettingsSlider + { + LabelText = GameplaySettingsStrings.BackgroundDim, + Current = config.GetBindable(OsuSetting.DimLevel), + KeyboardStep = 0.01f, + DisplayAsPercentage = true + }, + new SettingsSlider + { + LabelText = GameplaySettingsStrings.BackgroundBlur, + Current = config.GetBindable(OsuSetting.BlurLevel), + KeyboardStep = 0.01f, + DisplayAsPercentage = true + }, + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.LightenDuringBreaks, + Current = config.GetBindable(OsuSetting.LightenDuringBreaks) + }, + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.FadePlayfieldWhenHealthLow, + Current = config.GetBindable(OsuSetting.FadePlayfieldWhenHealthLow), + }, + }; + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/BeatmapSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/BeatmapSettings.cs new file mode 100644 index 0000000000..aaa60ce81b --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/BeatmapSettings.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Settings.Sections.Gameplay +{ + public class BeatmapSettings : SettingsSubsection + { + protected override LocalisableString Header => GameplaySettingsStrings.BeatmapHeader; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + Children = new Drawable[] + { + new SettingsCheckbox + { + LabelText = SkinSettingsStrings.BeatmapSkins, + Current = config.GetBindable(OsuSetting.BeatmapSkins) + }, + new SettingsCheckbox + { + LabelText = SkinSettingsStrings.BeatmapColours, + Current = config.GetBindable(OsuSetting.BeatmapColours) + }, + new SettingsCheckbox + { + LabelText = SkinSettingsStrings.BeatmapHitsounds, + Current = config.GetBindable(OsuSetting.BeatmapHitsounds) + }, + new SettingsCheckbox + { + LabelText = GraphicsSettingsStrings.StoryboardVideo, + Current = config.GetBindable(OsuSetting.ShowStoryboard) + }, + }; + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs index 3a0265e453..d4e4fd571d 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/GeneralSettings.cs @@ -1,7 +1,6 @@ -// 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; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Localisation; @@ -20,77 +19,18 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay { Children = new Drawable[] { - new SettingsSlider - { - LabelText = GameplaySettingsStrings.BackgroundDim, - Current = config.GetBindable(OsuSetting.DimLevel), - KeyboardStep = 0.01f, - DisplayAsPercentage = true - }, - new SettingsSlider - { - LabelText = GameplaySettingsStrings.BackgroundBlur, - Current = config.GetBindable(OsuSetting.BlurLevel), - KeyboardStep = 0.01f, - DisplayAsPercentage = true - }, - new SettingsCheckbox - { - LabelText = GameplaySettingsStrings.LightenDuringBreaks, - Current = config.GetBindable(OsuSetting.LightenDuringBreaks) - }, - new SettingsEnumDropdown - { - LabelText = GameplaySettingsStrings.HUDVisibilityMode, - Current = config.GetBindable(OsuSetting.HUDVisibilityMode) - }, - new SettingsCheckbox - { - LabelText = GameplaySettingsStrings.ShowDifficultyGraph, - Current = config.GetBindable(OsuSetting.ShowProgressGraph) - }, - new SettingsCheckbox - { - LabelText = GameplaySettingsStrings.ShowHealthDisplayWhenCantFail, - Current = config.GetBindable(OsuSetting.ShowHealthDisplayWhenCantFail), - Keywords = new[] { "hp", "bar" } - }, - new SettingsCheckbox - { - LabelText = GameplaySettingsStrings.FadePlayfieldWhenHealthLow, - Current = config.GetBindable(OsuSetting.FadePlayfieldWhenHealthLow), - }, - new SettingsCheckbox - { - LabelText = GameplaySettingsStrings.AlwaysShowKeyOverlay, - Current = config.GetBindable(OsuSetting.KeyOverlay) - }, - new SettingsCheckbox - { - LabelText = GameplaySettingsStrings.PositionalHitsounds, - Current = config.GetBindable(OsuSetting.PositionalHitSounds) - }, - new SettingsCheckbox - { - LabelText = GameplaySettingsStrings.AlwaysPlayFirstComboBreak, - Current = config.GetBindable(OsuSetting.AlwaysPlayFirstComboBreak) - }, new SettingsEnumDropdown { LabelText = GameplaySettingsStrings.ScoreDisplayMode, Current = config.GetBindable(OsuSetting.ScoreDisplayMode), Keywords = new[] { "scoring" } }, - }; - - if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) - { - Add(new SettingsCheckbox + new SettingsCheckbox { - LabelText = GameplaySettingsStrings.DisableWinKey, - Current = config.GetBindable(OsuSetting.GameplayDisableWinKey) - }); - } + LabelText = GraphicsSettingsStrings.HitLighting, + Current = config.GetBindable(OsuSetting.HitLighting) + }, + }; } } } diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs new file mode 100644 index 0000000000..e1b452e322 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs @@ -0,0 +1,45 @@ +// 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.Localisation; +using osu.Game.Configuration; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Settings.Sections.Gameplay +{ + public class HUDSettings : SettingsSubsection + { + protected override LocalisableString Header => GameplaySettingsStrings.HUDHeader; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + Children = new Drawable[] + { + new SettingsEnumDropdown + { + LabelText = GameplaySettingsStrings.HUDVisibilityMode, + Current = config.GetBindable(OsuSetting.HUDVisibilityMode) + }, + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.ShowDifficultyGraph, + Current = config.GetBindable(OsuSetting.ShowProgressGraph) + }, + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.ShowHealthDisplayWhenCantFail, + Current = config.GetBindable(OsuSetting.ShowHealthDisplayWhenCantFail), + Keywords = new[] { "hp", "bar" } + }, + new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.AlwaysShowKeyOverlay, + Current = config.GetBindable(OsuSetting.KeyOverlay) + }, + }; + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs new file mode 100644 index 0000000000..962572ca6e --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/InputSettings.cs @@ -0,0 +1,45 @@ +// 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; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Localisation; +using osu.Game.Configuration; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Settings.Sections.Gameplay +{ + public class InputSettings : SettingsSubsection + { + protected override LocalisableString Header => GameplaySettingsStrings.InputHeader; + + [BackgroundDependencyLoader] + private void load(OsuConfigManager config) + { + Children = new Drawable[] + { + new SettingsSlider + { + LabelText = SkinSettingsStrings.GameplayCursorSize, + Current = config.GetBindable(OsuSetting.GameplayCursorSize), + KeyboardStep = 0.01f + }, + new SettingsCheckbox + { + LabelText = SkinSettingsStrings.AutoCursorSize, + Current = config.GetBindable(OsuSetting.AutoCursorSize) + }, + }; + + if (RuntimeInfo.OS == RuntimeInfo.Platform.Windows) + { + Add(new SettingsCheckbox + { + LabelText = GameplaySettingsStrings.DisableWinKey, + Current = config.GetBindable(OsuSetting.GameplayDisableWinKey) + }); + } + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/GameplaySection.cs b/osu.Game/Overlays/Settings/Sections/GameplaySection.cs index 42d9d48d73..120e2d908c 100644 --- a/osu.Game/Overlays/Settings/Sections/GameplaySection.cs +++ b/osu.Game/Overlays/Settings/Sections/GameplaySection.cs @@ -1,16 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; -using osu.Framework.Allocation; using osu.Framework.Graphics; -using osu.Game.Overlays.Settings.Sections.Gameplay; -using osu.Game.Rulesets; -using System.Linq; using osu.Framework.Graphics.Sprites; -using osu.Framework.Logging; using osu.Framework.Localisation; using osu.Game.Localisation; +using osu.Game.Overlays.Settings.Sections.Gameplay; namespace osu.Game.Overlays.Settings.Sections { @@ -20,7 +15,7 @@ namespace osu.Game.Overlays.Settings.Sections public override Drawable CreateIcon() => new SpriteIcon { - Icon = FontAwesome.Regular.Circle + Icon = FontAwesome.Regular.DotCircle }; public GameplaySection() @@ -28,27 +23,13 @@ namespace osu.Game.Overlays.Settings.Sections Children = new Drawable[] { new GeneralSettings(), + new AudioSettings(), + new BeatmapSettings(), + new BackgroundSettings(), + new HUDSettings(), + new InputSettings(), new ModsSettings(), }; } - - [BackgroundDependencyLoader] - private void load(RulesetStore rulesets) - { - foreach (Ruleset ruleset in rulesets.AvailableRulesets.Select(info => info.CreateInstance())) - { - try - { - SettingsSubsection section = ruleset.CreateSettings(); - - if (section != null) - Add(section); - } - catch (Exception e) - { - Logger.Error(e, "Failed to load ruleset settings"); - } - } - } } } diff --git a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs index aa37748653..6bcb5ef715 100644 --- a/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/General/UpdateSettings.cs @@ -67,7 +67,7 @@ namespace osu.Game.Overlays.Settings.Sections.General Add(new SettingsButton { Text = GeneralSettingsStrings.OpenOsuFolder, - Action = storage.OpenInNativeExplorer, + Action = storage.PresentExternally, }); Add(new SettingsButton diff --git a/osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs b/osu.Game/Overlays/Settings/Sections/Graphics/ScreenshotSettings.cs similarity index 67% rename from osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs rename to osu.Game/Overlays/Settings/Sections/Graphics/ScreenshotSettings.cs index 20b1d8d801..dbb9ddc1c1 100644 --- a/osu.Game/Overlays/Settings/Sections/Graphics/DetailSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Graphics/ScreenshotSettings.cs @@ -9,25 +9,15 @@ using osu.Game.Localisation; namespace osu.Game.Overlays.Settings.Sections.Graphics { - public class DetailSettings : SettingsSubsection + public class ScreenshotSettings : SettingsSubsection { - protected override LocalisableString Header => GraphicsSettingsStrings.DetailSettingsHeader; + protected override LocalisableString Header => GraphicsSettingsStrings.Screenshots; [BackgroundDependencyLoader] private void load(OsuConfigManager config) { Children = new Drawable[] { - new SettingsCheckbox - { - LabelText = GraphicsSettingsStrings.StoryboardVideo, - Current = config.GetBindable(OsuSetting.ShowStoryboard) - }, - new SettingsCheckbox - { - LabelText = GraphicsSettingsStrings.HitLighting, - Current = config.GetBindable(OsuSetting.HitLighting) - }, new SettingsEnumDropdown { LabelText = GraphicsSettingsStrings.ScreenshotFormat, diff --git a/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs b/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs index fd0718f9f2..591848506a 100644 --- a/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs +++ b/osu.Game/Overlays/Settings/Sections/GraphicsSection.cs @@ -22,9 +22,9 @@ namespace osu.Game.Overlays.Settings.Sections { Children = new Drawable[] { - new RendererSettings(), new LayoutSettings(), - new DetailSettings(), + new RendererSettings(), + new ScreenshotSettings(), }; } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs index da789db79a..f44f02d0ed 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs @@ -82,60 +82,75 @@ namespace osu.Game.Overlays.Settings.Sections.Input { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS }; + Padding = new MarginPadding { Right = SettingsPanel.CONTENT_MARGINS }; InternalChildren = new Drawable[] { - new RestoreDefaultValueButton + new Container { - Current = isDefault, - Action = RestoreDefaults, - Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.Y, + Width = SettingsPanel.CONTENT_MARGINS, + Child = new RestoreDefaultValueButton + { + Current = isDefault, + Action = RestoreDefaults, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } }, - content = new Container + new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Masking = true, - CornerRadius = padding, - EdgeEffect = new EdgeEffectParameters - { - Radius = 2, - Colour = colourProvider.Highlight1.Opacity(0), - Type = EdgeEffectType.Shadow, - Hollow = true, - }, + Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS }, Children = new Drawable[] { - new Box + content = new Container { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background5, - }, - text = new OsuSpriteText - { - Text = action.GetLocalisableDescription(), - Margin = new MarginPadding(1.5f * padding), - }, - buttons = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight - }, - cancelAndClearButtons = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Padding = new MarginPadding(padding) { Top = height + padding * 2 }, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Alpha = 0, - Spacing = new Vector2(5), + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Masking = true, + CornerRadius = padding, + EdgeEffect = new EdgeEffectParameters + { + Radius = 2, + Colour = colourProvider.Highlight1.Opacity(0), + Type = EdgeEffectType.Shadow, + Hollow = true, + }, Children = new Drawable[] { - new CancelButton { Action = finalise }, - new ClearButton { Action = clear }, - }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + }, + text = new OsuSpriteText + { + Text = action.GetLocalisableDescription(), + Margin = new MarginPadding(1.5f * padding), + }, + buttons = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight + }, + cancelAndClearButtons = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding(padding) { Top = height + padding * 2 }, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Alpha = 0, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new CancelButton { Action = finalise }, + new ClearButton { Action = clear }, + }, + } + } } } }, diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs index 39dddbe1e6..2051af6f3c 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingsSubsection.cs @@ -7,7 +7,6 @@ using osu.Framework.Allocation; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Game.Database; -using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Rulesets; using osu.Game.Localisation; @@ -59,7 +58,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input } } - public class ResetButton : DangerousTriangleButton + public class ResetButton : DangerousSettingsButton { [BackgroundDependencyLoader] private void load() diff --git a/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs b/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs index 26610628d5..3ef5ce8941 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/RotationPresetButtons.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 System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -8,16 +9,24 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Handlers.Tablet; using osu.Game.Graphics; -using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Overlays.Settings.Sections.Input { - internal class RotationPresetButtons : FillFlowContainer + internal class RotationPresetButtons : CompositeDrawable { + public new MarginPadding Padding + { + get => base.Padding; + set => base.Padding = value; + } + private readonly ITabletHandler tabletHandler; private Bindable rotation; + private readonly RotationButton[] rotationPresets = new RotationButton[preset_count]; + private const int preset_count = 4; private const int height = 50; public RotationPresetButtons(ITabletHandler tabletHandler) @@ -27,18 +36,39 @@ namespace osu.Game.Overlays.Settings.Sections.Input RelativeSizeAxes = Axes.X; Height = height; - for (int i = 0; i < 360; i += 90) + IEnumerable createColumns(int count) { - var presetRotation = i; - - Add(new RotationButton(i) + for (int i = 0; i < count; ++i) { - RelativeSizeAxes = Axes.X, - Height = height, - Width = 0.25f, - Text = $@"{presetRotation}º", - Action = () => tabletHandler.Rotation.Value = presetRotation, - }); + if (i > 0) + yield return new Dimension(GridSizeMode.Absolute, 10); + + yield return new Dimension(); + } + } + + GridContainer grid; + + InternalChild = grid = new GridContainer + { + RelativeSizeAxes = Axes.Both, + ColumnDimensions = createColumns(preset_count).ToArray() + }; + + grid.Content = new[] { new Drawable[preset_count * 2 - 1] }; + + for (int i = 0; i < preset_count; i++) + { + var rotationValue = i * 90; + + var rotationPreset = new RotationButton(rotationValue) + { + RelativeSizeAxes = Axes.Both, + Height = 1, + Text = $@"{rotationValue}º", + Action = () => tabletHandler.Rotation.Value = rotationValue, + }; + grid.Content[0][2 * i] = rotationPresets[i] = rotationPreset; } } @@ -49,16 +79,19 @@ namespace osu.Game.Overlays.Settings.Sections.Input rotation = tabletHandler.Rotation.GetBoundCopy(); rotation.BindValueChanged(val => { - foreach (var b in Children.OfType()) + foreach (var b in rotationPresets) b.IsSelected = b.Preset == val.NewValue; }, true); } - public class RotationButton : TriangleButton + public class RotationButton : RoundedButton { [Resolved] private OsuColour colours { get; set; } + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + public readonly int Preset; public RotationButton(int preset) @@ -91,18 +124,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input private void updateColour() { - if (isSelected) - { - BackgroundColour = colours.BlueDark; - Triangles.ColourDark = colours.BlueDarker; - Triangles.ColourLight = colours.Blue; - } - else - { - BackgroundColour = colours.Gray4; - Triangles.ColourDark = colours.Gray5; - Triangles.ColourLight = colours.Gray6; - } + BackgroundColour = isSelected ? colours.Blue3 : colourProvider.Background3; } } } diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs index 803c8332c1..43df58a8b1 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs @@ -10,7 +10,6 @@ using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Collections; using osu.Game.Database; -using osu.Game.Graphics.UserInterface; using osu.Game.Localisation; using osu.Game.Scoring; using osu.Game.Skinning; @@ -21,15 +20,15 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance { protected override LocalisableString Header => "General"; - private TriangleButton importBeatmapsButton; - private TriangleButton importScoresButton; - private TriangleButton importSkinsButton; - private TriangleButton importCollectionsButton; - private TriangleButton deleteBeatmapsButton; - private TriangleButton deleteScoresButton; - private TriangleButton deleteSkinsButton; - private TriangleButton restoreButton; - private TriangleButton undeleteButton; + private SettingsButton importBeatmapsButton; + private SettingsButton importScoresButton; + private SettingsButton importSkinsButton; + private SettingsButton importCollectionsButton; + private SettingsButton deleteBeatmapsButton; + private SettingsButton deleteScoresButton; + private SettingsButton deleteSkinsButton; + private SettingsButton restoreButton; + private SettingsButton undeleteButton; [BackgroundDependencyLoader(permitNulls: true)] private void load(BeatmapManager beatmaps, ScoreManager scores, SkinManager skins, [CanBeNull] CollectionManager collectionManager, [CanBeNull] StableImportManager stableImportManager, DialogOverlay dialogOverlay) diff --git a/osu.Game/Overlays/Settings/Sections/RulesetSection.cs b/osu.Game/Overlays/Settings/Sections/RulesetSection.cs new file mode 100644 index 0000000000..b9339d5299 --- /dev/null +++ b/osu.Game/Overlays/Settings/Sections/RulesetSection.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Localisation; +using osu.Framework.Logging; +using osu.Game.Rulesets; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Settings.Sections +{ + public class RulesetSection : SettingsSection + { + public override LocalisableString Header => RulesetSettingsStrings.Rulesets; + + public override Drawable CreateIcon() => new SpriteIcon + { + Icon = FontAwesome.Solid.Chess + }; + + [BackgroundDependencyLoader] + private void load(RulesetStore rulesets) + { + foreach (Ruleset ruleset in rulesets.AvailableRulesets.Select(info => info.CreateInstance())) + { + try + { + SettingsSubsection section = ruleset.CreateSettings(); + + if (section != null) + Add(section); + } + catch (Exception e) + { + Logger.Error(e, "Failed to load ruleset settings"); + } + } + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index d18099eb0a..00198235c5 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -64,39 +64,16 @@ namespace osu.Game.Overlays.Settings.Sections { Children = new Drawable[] { - skinDropdown = new SkinSettingsDropdown(), + skinDropdown = new SkinSettingsDropdown + { + LabelText = SkinSettingsStrings.CurrentSkin + }, new SettingsButton { Text = SkinSettingsStrings.SkinLayoutEditor, Action = () => skinEditor?.Toggle(), }, new ExportSkinButton(), - new SettingsSlider - { - LabelText = SkinSettingsStrings.GameplayCursorSize, - Current = config.GetBindable(OsuSetting.GameplayCursorSize), - KeyboardStep = 0.01f - }, - new SettingsCheckbox - { - LabelText = SkinSettingsStrings.AutoCursorSize, - Current = config.GetBindable(OsuSetting.AutoCursorSize) - }, - new SettingsCheckbox - { - LabelText = SkinSettingsStrings.BeatmapSkins, - Current = config.GetBindable(OsuSetting.BeatmapSkins) - }, - new SettingsCheckbox - { - LabelText = SkinSettingsStrings.BeatmapColours, - Current = config.GetBindable(OsuSetting.BeatmapColours) - }, - new SettingsCheckbox - { - LabelText = SkinSettingsStrings.BeatmapHitsounds, - Current = config.GetBindable(OsuSetting.BeatmapHitsounds) - }, }; managerUpdated = skins.ItemUpdated.GetBoundCopy(); diff --git a/osu.Game/Overlays/Settings/SettingsButton.cs b/osu.Game/Overlays/Settings/SettingsButton.cs index 87b1aa0e46..be7f2de480 100644 --- a/osu.Game/Overlays/Settings/SettingsButton.cs +++ b/osu.Game/Overlays/Settings/SettingsButton.cs @@ -6,11 +6,11 @@ using System.Linq; using osu.Framework.Graphics; using osu.Framework.Graphics.Cursor; using osu.Framework.Localisation; -using osu.Game.Graphics.UserInterface; +using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Overlays.Settings { - public class SettingsButton : TriangleButton, IHasTooltip + public class SettingsButton : RoundedButton, IHasTooltip { public SettingsButton() { diff --git a/osu.Game/Overlays/Settings/SettingsDropdown.cs b/osu.Game/Overlays/Settings/SettingsDropdown.cs index 1175ddaab8..1e90222d28 100644 --- a/osu.Game/Overlays/Settings/SettingsDropdown.cs +++ b/osu.Game/Overlays/Settings/SettingsDropdown.cs @@ -35,7 +35,6 @@ namespace osu.Game.Overlays.Settings { public DropdownControl() { - Margin = new MarginPadding { Top = 5 }; RelativeSizeAxes = Axes.X; } diff --git a/osu.Game/Overlays/Settings/SettingsEnumDropdown.cs b/osu.Game/Overlays/Settings/SettingsEnumDropdown.cs index 9987a0c607..199ba14b48 100644 --- a/osu.Game/Overlays/Settings/SettingsEnumDropdown.cs +++ b/osu.Game/Overlays/Settings/SettingsEnumDropdown.cs @@ -16,7 +16,6 @@ namespace osu.Game.Overlays.Settings { public DropdownControl() { - Margin = new MarginPadding { Top = 5 }; RelativeSizeAxes = Axes.X; } diff --git a/osu.Game/Overlays/Settings/SettingsItem.cs b/osu.Game/Overlays/Settings/SettingsItem.cs index 5282217013..b593dea576 100644 --- a/osu.Game/Overlays/Settings/SettingsItem.cs +++ b/osu.Game/Overlays/Settings/SettingsItem.cs @@ -14,6 +14,7 @@ using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Containers; +using osuTK; namespace osu.Game.Overlays.Settings { @@ -34,6 +35,7 @@ namespace osu.Game.Overlays.Settings private OsuTextFlowContainer warningText; public bool ShowsDefaultIndicator = true; + private readonly Container defaultValueIndicatorContainer; public LocalisableString TooltipText { get; set; } @@ -54,6 +56,7 @@ namespace osu.Game.Overlays.Settings } labelText.Text = value; + updateLayout(); } } @@ -108,16 +111,23 @@ namespace osu.Game.Overlays.Settings InternalChildren = new Drawable[] { - FlowContent = new FillFlowContainer + defaultValueIndicatorContainer = new Container + { + Width = SettingsPanel.CONTENT_MARGINS, + }, + new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS }, - Children = new[] + Child = FlowContent = new FillFlowContainer { - Control = CreateControl(), - }, - }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0, 10), + Child = Control = CreateControl(), + } + } }; // IMPORTANT: all bindable logic is in constructor intentionally to support "CreateSettingsControls" being used in a context it is @@ -135,13 +145,25 @@ namespace osu.Game.Overlays.Settings // intentionally done before LoadComplete to avoid overhead. if (ShowsDefaultIndicator) { - AddInternal(new RestoreDefaultValueButton + defaultValueIndicatorContainer.Add(new RestoreDefaultValueButton { Current = controlWithCurrent.Current, + Anchor = Anchor.Centre, + Origin = Anchor.Centre }); + updateLayout(); } } + private void updateLayout() + { + bool hasLabel = labelText != null && !string.IsNullOrEmpty(labelText.Text.ToString()); + + // if the settings item is providing a label, the default value indicator should be centred vertically to the left of the label. + // otherwise, it should be centred vertically to the left of the main control of the settings item. + defaultValueIndicatorContainer.Height = hasLabel ? labelText.DrawHeight : Control.DrawHeight; + } + private void updateDisabled() { if (labelText != null) diff --git a/osu.Game/Overlays/Settings/SettingsNumberBox.cs b/osu.Game/Overlays/Settings/SettingsNumberBox.cs index d36aa2bfc2..aca7a210b3 100644 --- a/osu.Game/Overlays/Settings/SettingsNumberBox.cs +++ b/osu.Game/Overlays/Settings/SettingsNumberBox.cs @@ -13,7 +13,6 @@ namespace osu.Game.Overlays.Settings protected override Drawable CreateControl() => new NumberControl { RelativeSizeAxes = Axes.X, - Margin = new MarginPadding { Top = 5 } }; private sealed class NumberControl : CompositeDrawable, IHasCurrentValue diff --git a/osu.Game/Overlays/Settings/SettingsSlider.cs b/osu.Game/Overlays/Settings/SettingsSlider.cs index 9fc3379b94..b95b0af11c 100644 --- a/osu.Game/Overlays/Settings/SettingsSlider.cs +++ b/osu.Game/Overlays/Settings/SettingsSlider.cs @@ -19,7 +19,6 @@ namespace osu.Game.Overlays.Settings { protected override Drawable CreateControl() => new TSlider { - Margin = new MarginPadding { Top = 5, Bottom = 5 }, RelativeSizeAxes = Axes.X }; diff --git a/osu.Game/Overlays/Settings/SettingsTextBox.cs b/osu.Game/Overlays/Settings/SettingsTextBox.cs index 68562802cf..a724003183 100644 --- a/osu.Game/Overlays/Settings/SettingsTextBox.cs +++ b/osu.Game/Overlays/Settings/SettingsTextBox.cs @@ -11,7 +11,6 @@ namespace osu.Game.Overlays.Settings { protected override Drawable CreateControl() => new OutlinedTextBox { - Margin = new MarginPadding { Top = 5 }, RelativeSizeAxes = Axes.X, CommitOnFocusLost = true }; diff --git a/osu.Game/Overlays/SettingsOverlay.cs b/osu.Game/Overlays/SettingsOverlay.cs index 55e8aee266..c84cba8189 100644 --- a/osu.Game/Overlays/SettingsOverlay.cs +++ b/osu.Game/Overlays/SettingsOverlay.cs @@ -24,12 +24,13 @@ namespace osu.Game.Overlays protected override IEnumerable CreateSections() => new SettingsSection[] { new GeneralSection(), - new GraphicsSection(), - new AudioSection(), + new SkinSection(), new InputSection(createSubPanel(new KeyBindingPanel())), new UserInterfaceSection(), new GameplaySection(), - new SkinSection(), + new RulesetSection(), + new AudioSection(), + new GraphicsSection(), new OnlineSection(), new MaintenanceSection(), new DebugSection(), diff --git a/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs b/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs index a0ec8e3e0e..eec71a3623 100644 --- a/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs +++ b/osu.Game/Rulesets/Configuration/RulesetConfigManager.cs @@ -47,9 +47,34 @@ namespace osu.Game.Rulesets.Configuration } } + private readonly HashSet pendingWrites = new HashSet(); + protected override bool PerformSave() { - // do nothing, realm saves immediately + TLookup[] changed; + + lock (pendingWrites) + { + changed = pendingWrites.ToArray(); + pendingWrites.Clear(); + } + + if (realmFactory == null) + return true; + + using (var context = realmFactory.CreateContext()) + { + context.Write(realm => + { + foreach (var c in changed) + { + var setting = realm.All().First(s => s.RulesetID == rulesetId && s.Variant == variant && s.Key == c.ToString()); + + setting.Value = ConfigStore[c].ToString(); + } + }); + } + return true; } @@ -80,7 +105,8 @@ namespace osu.Game.Rulesets.Configuration bindable.ValueChanged += b => { - realmFactory?.Context.Write(() => setting.Value = b.NewValue.ToString()); + lock (pendingWrites) + pendingWrites.Add(lookup); }; } } diff --git a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs index 81f4808789..6ed91e983a 100644 --- a/osu.Game/Rulesets/Edit/BeatmapVerifier.cs +++ b/osu.Game/Rulesets/Edit/BeatmapVerifier.cs @@ -24,6 +24,11 @@ namespace osu.Game.Rulesets.Edit new CheckAudioQuality(), new CheckMutedObjects(), new CheckFewHitsounds(), + new CheckTooShortAudioFiles(), + new CheckAudioInVideo(), + + // Files + new CheckZeroByteFiles(), // Compose new CheckUnsnappedObjects(), diff --git a/osu.Game/Rulesets/Edit/Checks/CheckAudioInVideo.cs b/osu.Game/Rulesets/Edit/Checks/CheckAudioInVideo.cs new file mode 100644 index 0000000000..ac2542beb0 --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckAudioInVideo.cs @@ -0,0 +1,112 @@ +// 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.IO; +using osu.Game.IO.FileAbstraction; +using osu.Game.Rulesets.Edit.Checks.Components; +using osu.Game.Storyboards; +using TagLib; +using File = TagLib.File; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckAudioInVideo : ICheck + { + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Audio track in video files"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateHasAudioTrack(this), + new IssueTemplateMissingFile(this), + new IssueTemplateFileError(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + var beatmapSet = context.Beatmap.BeatmapInfo.BeatmapSet; + var videoPaths = new List(); + + foreach (var layer in context.WorkingBeatmap.Storyboard.Layers) + { + foreach (var element in layer.Elements) + { + if (!(element is StoryboardVideo video)) + continue; + + // Ensures we don't check the same video file multiple times in case of multiple elements using it. + if (!videoPaths.Contains(video.Path)) + videoPaths.Add(video.Path); + } + } + + foreach (var filename in videoPaths) + { + string storagePath = beatmapSet.GetPathForFile(filename); + + if (storagePath == null) + { + // There's an element in the storyboard that requires this resource, so it being missing is worth warning about. + yield return new IssueTemplateMissingFile(this).Create(filename); + + continue; + } + + Issue issue; + + try + { + // We use TagLib here for platform invariance; BASS cannot detect audio presence on Linux. + using (Stream data = context.WorkingBeatmap.GetStream(storagePath)) + using (File tagFile = File.Create(new StreamFileAbstraction(filename, data))) + { + if (tagFile.Properties.AudioChannels == 0) + continue; + } + + issue = new IssueTemplateHasAudioTrack(this).Create(filename); + } + catch (CorruptFileException) + { + issue = new IssueTemplateFileError(this).Create(filename, "Corrupt file"); + } + catch (UnsupportedFormatException) + { + issue = new IssueTemplateFileError(this).Create(filename, "Unsupported format"); + } + + yield return issue; + } + } + + public class IssueTemplateHasAudioTrack : IssueTemplate + { + public IssueTemplateHasAudioTrack(ICheck check) + : base(check, IssueType.Problem, "\"{0}\" has an audio track.") + { + } + + public Issue Create(string filename) => new Issue(this, filename); + } + + public class IssueTemplateFileError : IssueTemplate + { + public IssueTemplateFileError(ICheck check) + : base(check, IssueType.Error, "Could not check whether \"{0}\" has an audio track ({1}).") + { + } + + public Issue Create(string filename, string errorReason) => new Issue(this, filename, errorReason); + } + + public class IssueTemplateMissingFile : IssueTemplate + { + public IssueTemplateMissingFile(ICheck check) + : base(check, IssueType.Warning, "Could not check whether \"{0}\" has an audio track, because it is missing.") + { + } + + public Issue Create(string filename) => new Issue(this, filename); + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckTooShortAudioFiles.cs b/osu.Game/Rulesets/Edit/Checks/CheckTooShortAudioFiles.cs new file mode 100644 index 0000000000..57f7c60916 --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckTooShortAudioFiles.cs @@ -0,0 +1,85 @@ +// 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.IO; +using System.Linq; +using ManagedBass; +using osu.Framework.Audio.Callbacks; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckTooShortAudioFiles : ICheck + { + private const int ms_threshold = 25; + private const int min_bytes_threshold = 100; + + private readonly string[] audioExtensions = { "mp3", "ogg", "wav" }; + + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Audio, "Too short audio files"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateTooShort(this), + new IssueTemplateBadFormat(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + var beatmapSet = context.Beatmap.BeatmapInfo.BeatmapSet; + + foreach (var file in beatmapSet.Files) + { + using (Stream data = context.WorkingBeatmap.GetStream(file.FileInfo.StoragePath)) + { + if (data == null) + continue; + + var fileCallbacks = new FileCallbacks(new DataStreamFileProcedures(data)); + int decodeStream = Bass.CreateStream(StreamSystem.NoBuffer, BassFlags.Decode | BassFlags.Prescan, fileCallbacks.Callbacks, fileCallbacks.Handle); + + if (decodeStream == 0) + { + // If the file is not likely to be properly parsed by Bass, we don't produce Error issues about it. + // Image files and audio files devoid of audio data both fail, for example, but neither would be issues in this check. + if (hasAudioExtension(file.Filename) && probablyHasAudioData(data)) + yield return new IssueTemplateBadFormat(this).Create(file.Filename); + + continue; + } + + long length = Bass.ChannelGetLength(decodeStream); + double ms = Bass.ChannelBytes2Seconds(decodeStream, length) * 1000; + + // Extremely short audio files do not play on some soundcards, resulting in nothing being heard in-game for some users. + if (ms > 0 && ms < ms_threshold) + yield return new IssueTemplateTooShort(this).Create(file.Filename, ms); + } + } + } + + private bool hasAudioExtension(string filename) => audioExtensions.Any(filename.ToLower().EndsWith); + private bool probablyHasAudioData(Stream data) => data.Length > min_bytes_threshold; + + public class IssueTemplateTooShort : IssueTemplate + { + public IssueTemplateTooShort(ICheck check) + : base(check, IssueType.Problem, "\"{0}\" is too short ({1:0} ms), should be at least {2:0} ms.") + { + } + + public Issue Create(string filename, double ms) => new Issue(this, filename, ms, ms_threshold); + } + + public class IssueTemplateBadFormat : IssueTemplate + { + public IssueTemplateBadFormat(ICheck check) + : base(check, IssueType.Error, "Could not check whether \"{0}\" is too short (code \"{1}\").") + { + } + + public Issue Create(string filename) => new Issue(this, filename, Bass.LastError); + } + } +} diff --git a/osu.Game/Rulesets/Edit/Checks/CheckZeroByteFiles.cs b/osu.Game/Rulesets/Edit/Checks/CheckZeroByteFiles.cs new file mode 100644 index 0000000000..3a994fabfa --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckZeroByteFiles.cs @@ -0,0 +1,43 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.IO; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public class CheckZeroByteFiles : ICheck + { + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Files, "Zero-byte files"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateZeroBytes(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + var beatmapSet = context.Beatmap.BeatmapInfo.BeatmapSet; + + foreach (var file in beatmapSet.Files) + { + using (Stream data = context.WorkingBeatmap.GetStream(file.FileInfo.StoragePath)) + { + if (data?.Length == 0) + yield return new IssueTemplateZeroBytes(this).Create(file.Filename); + } + } + } + + public class IssueTemplateZeroBytes : IssueTemplate + { + public IssueTemplateZeroBytes(ICheck check) + : base(check, IssueType.Problem, "\"{0}\" is a 0-byte file.") + { + } + + public Issue Create(string filename) => new Issue(this, filename); + } + } +} diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index b41e0442bc..91cc80e930 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -13,7 +13,6 @@ using osu.Framework.Input; using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Game.Beatmaps; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Rulesets.Configuration; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mods; @@ -389,41 +388,42 @@ namespace osu.Game.Rulesets.Edit return new SnapResult(screenSpacePosition, targetTime, playfield); } - public override float GetBeatSnapDistanceAt(double referenceTime) + public override float GetBeatSnapDistanceAt(HitObject referenceObject) { - DifficultyControlPoint difficultyPoint = EditorBeatmap.ControlPointInfo.DifficultyPointAt(referenceTime); - return (float)(100 * EditorBeatmap.Difficulty.SliderMultiplier * difficultyPoint.SpeedMultiplier / BeatSnapProvider.BeatDivisor); + return (float)(100 * EditorBeatmap.Difficulty.SliderMultiplier * referenceObject.DifficultyControlPoint.SliderVelocity / BeatSnapProvider.BeatDivisor); } - public override float DurationToDistance(double referenceTime, double duration) + public override float DurationToDistance(HitObject referenceObject, double duration) { - double beatLength = BeatSnapProvider.GetBeatLengthAtTime(referenceTime); - return (float)(duration / beatLength * GetBeatSnapDistanceAt(referenceTime)); + double beatLength = BeatSnapProvider.GetBeatLengthAtTime(referenceObject.StartTime); + return (float)(duration / beatLength * GetBeatSnapDistanceAt(referenceObject)); } - public override double DistanceToDuration(double referenceTime, float distance) + public override double DistanceToDuration(HitObject referenceObject, float distance) { - double beatLength = BeatSnapProvider.GetBeatLengthAtTime(referenceTime); - return distance / GetBeatSnapDistanceAt(referenceTime) * beatLength; + double beatLength = BeatSnapProvider.GetBeatLengthAtTime(referenceObject.StartTime); + return distance / GetBeatSnapDistanceAt(referenceObject) * beatLength; } - public override double GetSnappedDurationFromDistance(double referenceTime, float distance) - => BeatSnapProvider.SnapTime(referenceTime + DistanceToDuration(referenceTime, distance), referenceTime) - referenceTime; + public override double GetSnappedDurationFromDistance(HitObject referenceObject, float distance) + => BeatSnapProvider.SnapTime(referenceObject.StartTime + DistanceToDuration(referenceObject, distance), referenceObject.StartTime) - referenceObject.StartTime; - public override float GetSnappedDistanceFromDistance(double referenceTime, float distance) + public override float GetSnappedDistanceFromDistance(HitObject referenceObject, float distance) { - double actualDuration = referenceTime + DistanceToDuration(referenceTime, distance); + double startTime = referenceObject.StartTime; - double snappedEndTime = BeatSnapProvider.SnapTime(actualDuration, referenceTime); + double actualDuration = startTime + DistanceToDuration(referenceObject, distance); - double beatLength = BeatSnapProvider.GetBeatLengthAtTime(referenceTime); + double snappedEndTime = BeatSnapProvider.SnapTime(actualDuration, startTime); + + double beatLength = BeatSnapProvider.GetBeatLengthAtTime(startTime); // we don't want to exceed the actual duration and snap to a point in the future. // as we are snapping to beat length via SnapTime (which will round-to-nearest), check for snapping in the forward direction and reverse it. if (snappedEndTime > actualDuration + 1) snappedEndTime -= beatLength; - return DurationToDistance(referenceTime, snappedEndTime - referenceTime); + return DurationToDistance(referenceObject, snappedEndTime - startTime); } #endregion @@ -466,15 +466,15 @@ namespace osu.Game.Rulesets.Edit public virtual SnapResult SnapScreenSpacePositionToValidPosition(Vector2 screenSpacePosition) => new SnapResult(screenSpacePosition, null); - public abstract float GetBeatSnapDistanceAt(double referenceTime); + public abstract float GetBeatSnapDistanceAt(HitObject referenceObject); - public abstract float DurationToDistance(double referenceTime, double duration); + public abstract float DurationToDistance(HitObject referenceObject, double duration); - public abstract double DistanceToDuration(double referenceTime, float distance); + public abstract double DistanceToDuration(HitObject referenceObject, float distance); - public abstract double GetSnappedDurationFromDistance(double referenceTime, float distance); + public abstract double GetSnappedDurationFromDistance(HitObject referenceObject, float distance); - public abstract float GetSnappedDistanceFromDistance(double referenceTime, float distance); + public abstract float GetSnappedDistanceFromDistance(HitObject referenceObject, float distance); #endregion } diff --git a/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs b/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs index 4664f3808c..743a2f41fc 100644 --- a/osu.Game/Rulesets/Edit/IPositionSnapProvider.cs +++ b/osu.Game/Rulesets/Edit/IPositionSnapProvider.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 osu.Game.Rulesets.Objects; using osuTK; namespace osu.Game.Rulesets.Edit @@ -27,41 +28,41 @@ namespace osu.Game.Rulesets.Edit /// /// Retrieves the distance between two points within a timing point that are one beat length apart. /// - /// The time of the timing point. + /// An object to be used as a reference point for this operation. /// The distance between two points residing in the timing point that are one beat length apart. - float GetBeatSnapDistanceAt(double referenceTime); + float GetBeatSnapDistanceAt(HitObject referenceObject); /// /// Converts a duration to a distance. /// - /// The time of the timing point which resides in. + /// An object to be used as a reference point for this operation. /// The duration to convert. /// A value that represents as a distance in the timing point. - float DurationToDistance(double referenceTime, double duration); + float DurationToDistance(HitObject referenceObject, double duration); /// /// Converts a distance to a duration. /// - /// The time of the timing point which resides in. + /// An object to be used as a reference point for this operation. /// The distance to convert. /// A value that represents as a duration in the timing point. - double DistanceToDuration(double referenceTime, float distance); + double DistanceToDuration(HitObject referenceObject, float distance); /// /// Converts a distance to a snapped duration. /// - /// The time of the timing point which resides in. + /// An object to be used as a reference point for this operation. /// The distance to convert. /// A value that represents as a duration snapped to the closest beat of the timing point. - double GetSnappedDurationFromDistance(double referenceTime, float distance); + double GetSnappedDurationFromDistance(HitObject referenceObject, float distance); /// /// Converts an unsnapped distance to a snapped distance. /// The returned distance will always be floored (as to never exceed the provided . /// - /// The time of the timing point which resides in. + /// An object to be used as a reference point for this operation. /// The distance to convert. /// A value that represents snapped to the closest beat of the timing point. - float GetSnappedDistanceFromDistance(double referenceTime, float distance); + float GetSnappedDistanceFromDistance(HitObject referenceObject, float distance); } } diff --git a/osu.Game/Rulesets/Objects/HitObject.cs b/osu.Game/Rulesets/Objects/HitObject.cs index 0b159819d4..a80b3d0fa5 100644 --- a/osu.Game/Rulesets/Objects/HitObject.cs +++ b/osu.Game/Rulesets/Objects/HitObject.cs @@ -67,7 +67,8 @@ namespace osu.Game.Rulesets.Objects } } - public SampleControlPoint SampleControlPoint; + public SampleControlPoint SampleControlPoint = SampleControlPoint.DEFAULT; + public DifficultyControlPoint DifficultyControlPoint = DifficultyControlPoint.DEFAULT; /// /// Whether this is in Kiai time. @@ -94,6 +95,12 @@ namespace osu.Game.Rulesets.Objects foreach (var nested in nestedHitObjects) nested.StartTime += offset; + + if (DifficultyControlPoint != DifficultyControlPoint.DEFAULT) + DifficultyControlPoint.Time = time.NewValue; + + if (SampleControlPoint != SampleControlPoint.DEFAULT) + SampleControlPoint.Time = this.GetEndTime() + control_point_leniency; }; } @@ -105,17 +112,26 @@ namespace osu.Game.Rulesets.Objects /// The cancellation token. public void ApplyDefaults(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty, CancellationToken cancellationToken = default) { + var legacyInfo = controlPointInfo as LegacyControlPointInfo; + + if (legacyInfo != null) + { + DifficultyControlPoint = (DifficultyControlPoint)legacyInfo.DifficultyPointAt(StartTime).DeepClone(); + DifficultyControlPoint.Time = StartTime; + } + else if (DifficultyControlPoint == DifficultyControlPoint.DEFAULT) + DifficultyControlPoint = new DifficultyControlPoint(); + ApplyDefaultsToSelf(controlPointInfo, difficulty); - if (controlPointInfo is LegacyControlPointInfo legacyInfo) + // This is done here after ApplyDefaultsToSelf as we may require custom defaults to be applied to have an accurate end time. + if (legacyInfo != null) { - // This is done here since ApplyDefaultsToSelf may be used to determine the end time - SampleControlPoint = legacyInfo.SamplePointAt(this.GetEndTime() + control_point_leniency); - } - else - { - SampleControlPoint ??= SampleControlPoint.DEFAULT; + SampleControlPoint = (SampleControlPoint)legacyInfo.SamplePointAt(this.GetEndTime() + control_point_leniency).DeepClone(); + SampleControlPoint.Time = this.GetEndTime() + control_point_leniency; } + else if (SampleControlPoint == SampleControlPoint.DEFAULT) + SampleControlPoint = new SampleControlPoint(); nestedHitObjects.Clear(); diff --git a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs index e1de82ade7..ad191f7ff5 100644 --- a/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs +++ b/osu.Game/Rulesets/Objects/Legacy/ConvertSlider.cs @@ -43,9 +43,8 @@ namespace osu.Game.Rulesets.Objects.Legacy base.ApplyDefaultsToSelf(controlPointInfo, difficulty); TimingControlPoint timingPoint = controlPointInfo.TimingPointAt(StartTime); - DifficultyControlPoint difficultyPoint = controlPointInfo.DifficultyPointAt(StartTime); - double scoringDistance = base_scoring_distance * difficulty.SliderMultiplier * difficultyPoint.SpeedMultiplier; + double scoringDistance = base_scoring_distance * difficulty.SliderMultiplier * DifficultyControlPoint.SliderVelocity; Velocity = scoringDistance / timingPoint.BeatLength; } diff --git a/osu.Game/Rulesets/Objects/SliderPath.cs b/osu.Game/Rulesets/Objects/SliderPath.cs index 9cc215589b..0dec0655b9 100644 --- a/osu.Game/Rulesets/Objects/SliderPath.cs +++ b/osu.Game/Rulesets/Objects/SliderPath.cs @@ -280,6 +280,13 @@ namespace osu.Game.Rulesets.Objects if (ExpectedDistance.Value is double expectedDistance && calculatedLength != expectedDistance) { + // In osu-stable, if the last two control points of a slider are equal, extension is not performed. + if (ControlPoints.Count >= 2 && ControlPoints[^1].Position == ControlPoints[^2].Position && expectedDistance > calculatedLength) + { + cumulativeLength.Add(calculatedLength); + return; + } + // The last length is always incorrect cumulativeLength.RemoveAt(cumulativeLength.Count - 1); diff --git a/osu.Game/Rulesets/Objects/SliderPathExtensions.cs b/osu.Game/Rulesets/Objects/SliderPathExtensions.cs index 663746bfca..052fc7c775 100644 --- a/osu.Game/Rulesets/Objects/SliderPathExtensions.cs +++ b/osu.Game/Rulesets/Objects/SliderPathExtensions.cs @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Objects public static void Reverse(this SliderPath sliderPath, out Vector2 positionalOffset) { var points = sliderPath.ControlPoints.ToArray(); - positionalOffset = points.Last().Position; + positionalOffset = sliderPath.PositionAt(1); sliderPath.ControlPoints.Clear(); @@ -32,7 +32,10 @@ namespace osu.Game.Rulesets.Objects // propagate types forwards to last null type if (i == points.Length - 1) + { p.Type = lastType; + p.Position = Vector2.Zero; + } else if (p.Type != null) (p.Type, lastType) = (lastType, p.Type); diff --git a/osu.Game/Rulesets/RulesetInfo.cs b/osu.Game/Rulesets/RulesetInfo.cs index ca6a083a58..8cd3fa8c63 100644 --- a/osu.Game/Rulesets/RulesetInfo.cs +++ b/osu.Game/Rulesets/RulesetInfo.cs @@ -57,7 +57,7 @@ namespace osu.Game.Rulesets #region Implementation of IHasOnlineID - public int? OnlineID => ID; + public int OnlineID => ID ?? -1; #endregion } diff --git a/osu.Game/Rulesets/Timing/MultiplierControlPoint.cs b/osu.Game/Rulesets/Timing/MultiplierControlPoint.cs index dcd2cc8b55..23325bcd13 100644 --- a/osu.Game/Rulesets/Timing/MultiplierControlPoint.cs +++ b/osu.Game/Rulesets/Timing/MultiplierControlPoint.cs @@ -7,7 +7,7 @@ using osu.Game.Beatmaps.ControlPoints; namespace osu.Game.Rulesets.Timing { /// - /// A control point which adds an aggregated multiplier based on the provided 's BeatLength and 's SpeedMultiplier. + /// A control point which adds an aggregated multiplier based on the provided 's BeatLength and 's SpeedMultiplier. /// public class MultiplierControlPoint : IComparable { @@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Timing /// /// The aggregate multiplier which this provides. /// - public double Multiplier => Velocity * DifficultyPoint.SpeedMultiplier * BaseBeatLength / TimingPoint.BeatLength; + public double Multiplier => Velocity * EffectPoint.ScrollSpeed * BaseBeatLength / TimingPoint.BeatLength; /// /// The base beat length to scale the provided multiplier relative to. @@ -38,9 +38,9 @@ namespace osu.Game.Rulesets.Timing public TimingControlPoint TimingPoint = new TimingControlPoint(); /// - /// The that provides additional difficulty information for this . + /// The that provides additional difficulty information for this . /// - public DifficultyControlPoint DifficultyPoint = new DifficultyControlPoint(); + public EffectControlPoint EffectPoint = new EffectControlPoint(); /// /// Creates a . This is required for JSON serialization diff --git a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs index e9865f6c8b..c0b339a231 100644 --- a/osu.Game/Rulesets/UI/FrameStabilityContainer.cs +++ b/osu.Game/Rulesets/UI/FrameStabilityContainer.cs @@ -55,7 +55,10 @@ namespace osu.Game.Rulesets.UI /// /// The current direction of playback to be exposed to frame stable children. /// - private int direction; + /// + /// Initially it is presumed that playback will proceed in the forward direction. + /// + private int direction = 1; [BackgroundDependencyLoader(true)] private void load(GameplayClock clock, ISamplePlaybackDisabler sampleDisabler) @@ -139,7 +142,9 @@ namespace osu.Game.Rulesets.UI state = PlaybackState.NotValid; } - if (state == PlaybackState.Valid) + // if the proposed time is the same as the current time, assume that the clock will continue progressing in the same direction as previously. + // this avoids spurious flips in direction from -1 to 1 during rewinds. + if (state == PlaybackState.Valid && proposedTime != manualClock.CurrentTime) direction = proposedTime >= manualClock.CurrentTime ? 1 : -1; double timeBehind = Math.Abs(proposedTime - parentGameplayClock.CurrentTime); diff --git a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs index 041c5ebef5..2a9d3d1cf0 100644 --- a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs +++ b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs @@ -140,25 +140,32 @@ namespace osu.Game.Rulesets.UI.Scrolling // Merge sequences of timing and difficulty control points to create the aggregate "multiplier" control point var lastTimingPoint = new TimingControlPoint(); - var lastDifficultyPoint = new DifficultyControlPoint(); + var lastEffectPoint = new EffectControlPoint(); var allPoints = new SortedList(Comparer.Default); + allPoints.AddRange(Beatmap.ControlPointInfo.TimingPoints); - allPoints.AddRange(Beatmap.ControlPointInfo.DifficultyPoints); + allPoints.AddRange(Beatmap.ControlPointInfo.EffectPoints); // Generate the timing points, making non-timing changes use the previous timing change and vice-versa var timingChanges = allPoints.Select(c => { - if (c is TimingControlPoint timingPoint) - lastTimingPoint = timingPoint; - else if (c is DifficultyControlPoint difficultyPoint) - lastDifficultyPoint = difficultyPoint; + switch (c) + { + case TimingControlPoint timingPoint: + lastTimingPoint = timingPoint; + break; + + case EffectControlPoint difficultyPoint: + lastEffectPoint = difficultyPoint; + break; + } return new MultiplierControlPoint(c.Time) { Velocity = Beatmap.Difficulty.SliderMultiplier, BaseBeatLength = baseBeatLength, TimingPoint = lastTimingPoint, - DifficultyPoint = lastDifficultyPoint + EffectPoint = lastEffectPoint }; }); diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index dde956233b..a9791fba7e 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -25,7 +25,7 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Scoring { - public class ScoreManager : IModelManager, IModelFileManager, IModelDownloader, ICanAcceptFiles, IPostImports + public class ScoreManager : IModelManager, IModelFileManager, IModelDownloader { private readonly Scheduler scheduler; private readonly Func difficulties; @@ -72,9 +72,12 @@ namespace osu.Game.Scoring } } - // We're calling .Result, but this should not be a blocking call due to the above GetDifficultyAsync() calls. - return scores.OrderByDescending(s => GetTotalScoreAsync(s, cancellationToken: cancellationToken).Result) - .ThenBy(s => s.OnlineScoreID) + var totalScores = await Task.WhenAll(scores.Select(s => GetTotalScoreAsync(s, cancellationToken: cancellationToken))).ConfigureAwait(false); + + return scores.Select((score, index) => (score, totalScore: totalScores[index])) + .OrderByDescending(g => g.totalScore) + .ThenBy(g => g.score.OnlineScoreID) + .Select(g => g.score) .ToArray(); } diff --git a/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs index 730f482f83..6b32ff96c4 100644 --- a/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/CircularDistanceSnapGrid.cs @@ -5,14 +5,15 @@ using System; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.UserInterface; +using osu.Game.Rulesets.Objects; using osuTK; namespace osu.Game.Screens.Edit.Compose.Components { public abstract class CircularDistanceSnapGrid : DistanceSnapGrid { - protected CircularDistanceSnapGrid(Vector2 startPosition, double startTime, double? endTime = null) - : base(startPosition, startTime, endTime) + protected CircularDistanceSnapGrid(HitObject referenceObject, Vector2 startPosition, double startTime, double? endTime = null) + : base(referenceObject, startPosition, startTime, endTime) { } @@ -79,7 +80,7 @@ namespace osu.Game.Screens.Edit.Compose.Components Vector2 normalisedDirection = direction * new Vector2(1f / distance); Vector2 snappedPosition = StartPosition + normalisedDirection * radialCount * radius; - return (snappedPosition, StartTime + SnapProvider.GetSnappedDurationFromDistance(StartTime, (snappedPosition - StartPosition).Length)); + return (snappedPosition, StartTime + SnapProvider.GetSnappedDurationFromDistance(ReferenceObject, (snappedPosition - StartPosition).Length)); } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs index 59f88ac641..9d43e3258a 100644 --- a/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs +++ b/osu.Game/Screens/Edit/Compose/Components/DistanceSnapGrid.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Layout; using osu.Game.Graphics; using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; using osuTK; namespace osu.Game.Screens.Edit.Compose.Components @@ -54,15 +55,20 @@ namespace osu.Game.Screens.Edit.Compose.Components private readonly LayoutValue gridCache = new LayoutValue(Invalidation.RequiredParentSizeToFit); private readonly double? endTime; + protected readonly HitObject ReferenceObject; + /// /// Creates a new . /// + /// A reference object to gather relevant difficulty values from. /// The position at which the grid should start. The first tick is located one distance spacing length away from this point. /// The snapping time at . /// The time at which the snapping grid should end. If null, the grid will continue until the bounds of the screen are exceeded. - protected DistanceSnapGrid(Vector2 startPosition, double startTime, double? endTime = null) + protected DistanceSnapGrid(HitObject referenceObject, Vector2 startPosition, double startTime, double? endTime = null) { + ReferenceObject = referenceObject; this.endTime = endTime; + StartPosition = startPosition; StartTime = startTime; @@ -80,7 +86,7 @@ namespace osu.Game.Screens.Edit.Compose.Components private void updateSpacing() { - DistanceSpacing = SnapProvider.GetBeatSnapDistanceAt(StartTime); + DistanceSpacing = SnapProvider.GetBeatSnapDistanceAt(ReferenceObject); if (endTime == null) MaxIntervals = int.MaxValue; @@ -88,7 +94,7 @@ namespace osu.Game.Screens.Edit.Compose.Components { // +1 is added since a snapped hitobject may have its start time slightly less than the snapped time due to floating point errors double maxDuration = endTime.Value - StartTime + 1; - MaxIntervals = (int)(maxDuration / SnapProvider.DistanceToDuration(StartTime, DistanceSpacing)); + MaxIntervals = (int)(maxDuration / SnapProvider.DistanceToDuration(ReferenceObject, DistanceSpacing)); } gridCache.Invalidate(); diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs index 3248936765..21457ea273 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/DifficultyPointPiece.cs @@ -1,27 +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 osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Rulesets.Objects; +using osu.Game.Screens.Edit.Timing; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class DifficultyPointPiece : TopPointPiece + public class DifficultyPointPiece : HitObjectPointPiece, IHasPopover { + private readonly HitObject hitObject; + private readonly BindableNumber speedMultiplier; - public DifficultyPointPiece(DifficultyControlPoint point) - : base(point) + public DifficultyPointPiece(HitObject hitObject) + : base(hitObject.DifficultyControlPoint) { - speedMultiplier = point.SpeedMultiplierBindable.GetBoundCopy(); + this.hitObject = hitObject; - Y = Height; + speedMultiplier = hitObject.DifficultyControlPoint.SliderVelocityBindable.GetBoundCopy(); } protected override void LoadComplete() { base.LoadComplete(); + speedMultiplier.BindValueChanged(multiplier => Label.Text = $"{multiplier.NewValue:n2}x", true); } + + protected override bool OnClick(ClickEvent e) + { + this.ShowPopover(); + return true; + } + + public Popover GetPopover() => new DifficultyEditPopover(hitObject); + + public class DifficultyEditPopover : OsuPopover + { + private readonly HitObject hitObject; + private readonly DifficultyControlPoint point; + + private SliderWithTextBoxInput sliderVelocitySlider; + + [Resolved(canBeNull: true)] + private EditorBeatmap beatmap { get; set; } + + public DifficultyEditPopover(HitObject hitObject) + { + this.hitObject = hitObject; + point = hitObject.DifficultyControlPoint; + } + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] + { + new FillFlowContainer + { + Width = 200, + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] + { + sliderVelocitySlider = new SliderWithTextBoxInput("Velocity") + { + Current = new DifficultyControlPoint().SliderVelocityBindable, + KeyboardStep = 0.1f + }, + new OsuTextFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Text = "Hold shift while dragging the end of an object to adjust velocity while snapping." + } + } + } + }; + + var selectedPointBindable = point.SliderVelocityBindable; + + // there may be legacy control points, which contain infinite precision for compatibility reasons (see LegacyDifficultyControlPoint). + // generally that level of precision could only be set by externally editing the .osu file, so at the point + // a user is looking to update this within the editor it should be safe to obliterate this additional precision. + double expectedPrecision = new DifficultyControlPoint().SliderVelocityBindable.Precision; + if (selectedPointBindable.Precision < expectedPrecision) + selectedPointBindable.Precision = expectedPrecision; + + sliderVelocitySlider.Current = selectedPointBindable; + sliderVelocitySlider.Current.BindValueChanged(_ => beatmap?.Update(hitObject)); + } + } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/HitObjectPointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/HitObjectPointPiece.cs new file mode 100644 index 0000000000..6b62459c97 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/HitObjectPointPiece.cs @@ -0,0 +1,63 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osuTK.Graphics; + +namespace osu.Game.Screens.Edit.Compose.Components.Timeline +{ + public class HitObjectPointPiece : CircularContainer + { + private readonly ControlPoint point; + + protected OsuSpriteText Label { get; private set; } + + protected HitObjectPointPiece(ControlPoint point) + { + this.point = point; + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + AutoSizeAxes = Axes.Both; + + Color4 colour = point.GetRepresentingColour(colours); + + InternalChildren = new Drawable[] + { + new Container + { + AutoSizeAxes = Axes.X, + Height = 16, + Masking = true, + CornerRadius = 8, + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + Children = new Drawable[] + { + new Box + { + Colour = colour, + RelativeSizeAxes = Axes.Both, + }, + Label = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Padding = new MarginPadding(5), + Font = OsuFont.Default.With(size: 12, weight: FontWeight.SemiBold), + Colour = colours.B5, + } + } + }, + }; + } + } +} diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs index 9461f5e885..6a26f69e41 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/SamplePointPiece.cs @@ -3,88 +3,102 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Cursor; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Input.Events; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; -using osu.Game.Graphics.Sprites; -using osuTK.Graphics; +using osu.Game.Graphics.UserInterfaceV2; +using osu.Game.Rulesets.Objects; +using osu.Game.Screens.Edit.Timing; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { - public class SamplePointPiece : CompositeDrawable + public class SamplePointPiece : HitObjectPointPiece, IHasPopover { - private readonly SampleControlPoint samplePoint; + private readonly HitObject hitObject; private readonly Bindable bank; private readonly BindableNumber volume; - private OsuSpriteText text; - private Container volumeBox; - - private const int max_volume_height = 22; - - public SamplePointPiece(SampleControlPoint samplePoint) + public SamplePointPiece(HitObject hitObject) + : base(hitObject.SampleControlPoint) { - this.samplePoint = samplePoint; - volume = samplePoint.SampleVolumeBindable.GetBoundCopy(); - bank = samplePoint.SampleBankBindable.GetBoundCopy(); + this.hitObject = hitObject; + volume = hitObject.SampleControlPoint.SampleVolumeBindable.GetBoundCopy(); + bank = hitObject.SampleControlPoint.SampleBankBindable.GetBoundCopy(); } [BackgroundDependencyLoader] private void load(OsuColour colours) { - Margin = new MarginPadding { Vertical = 5 }; + volume.BindValueChanged(volume => updateText()); + bank.BindValueChanged(bank => updateText(), true); + } - Origin = Anchor.BottomCentre; - Anchor = Anchor.BottomCentre; + protected override bool OnClick(ClickEvent e) + { + this.ShowPopover(); + return true; + } - AutoSizeAxes = Axes.X; - RelativeSizeAxes = Axes.Y; + private void updateText() + { + Label.Text = $"{bank.Value} {volume.Value}"; + } - Color4 colour = samplePoint.GetRepresentingColour(colours); + public Popover GetPopover() => new SampleEditPopover(hitObject); - InternalChildren = new Drawable[] + public class SampleEditPopover : OsuPopover + { + private readonly HitObject hitObject; + private readonly SampleControlPoint point; + + private LabelledTextBox bank; + private SliderWithTextBoxInput volume; + + [Resolved(canBeNull: true)] + private EditorBeatmap beatmap { get; set; } + + public SampleEditPopover(HitObject hitObject) { - volumeBox = new Circle + this.hitObject = hitObject; + point = hitObject.SampleControlPoint; + } + + [BackgroundDependencyLoader] + private void load() + { + Children = new Drawable[] { - CornerRadius = 5, - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Y = -20, - Width = 10, - Colour = colour, - }, - new Container - { - AutoSizeAxes = Axes.X, - Height = 16, - Masking = true, - CornerRadius = 8, - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Children = new Drawable[] + new FillFlowContainer { - new Box + Width = 200, + Direction = FillDirection.Vertical, + AutoSizeAxes = Axes.Y, + Children = new Drawable[] { - Colour = colour, - RelativeSizeAxes = Axes.Both, - }, - text = new OsuSpriteText - { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Padding = new MarginPadding(5), - Font = OsuFont.Default.With(size: 12, weight: FontWeight.SemiBold), - Colour = colours.B5, + bank = new LabelledTextBox + { + Label = "Bank Name", + }, + volume = new SliderWithTextBoxInput("Volume") + { + Current = new SampleControlPoint().SampleVolumeBindable, + } } } - }, - }; + }; - volume.BindValueChanged(volume => volumeBox.Height = max_volume_height * volume.NewValue / 100f, true); - bank.BindValueChanged(bank => text.Text = bank.NewValue, true); + bank.Current = point.SampleBankBindable; + bank.Current.BindValueChanged(_ => beatmap.Update(hitObject)); + + volume.Current = point.SampleVolumeBindable; + volume.Current.BindValueChanged(_ => beatmap.Update(hitObject)); + } } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs index 621a24c67d..b8fa05e7eb 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs @@ -15,6 +15,7 @@ using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; using osuTK; namespace osu.Game.Screens.Edit.Compose.Components.Timeline @@ -58,7 +59,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private Track track; private const float timeline_height = 72; - private const float timeline_expanded_height = 156; + private const float timeline_expanded_height = 94; public Timeline(Drawable userContent) { @@ -158,7 +159,7 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (visible.NewValue) { this.ResizeHeightTo(timeline_expanded_height, 200, Easing.OutQuint); - mainContent.MoveToY(36, 200, Easing.OutQuint); + mainContent.MoveToY(20, 200, Easing.OutQuint); // delay the fade in else masking looks weird. controlPoints.Delay(180).FadeIn(400, Easing.OutQuint); @@ -298,14 +299,14 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private double getTimeFromPosition(Vector2 localPosition) => (localPosition.X / Content.DrawWidth) * track.Length; - public float GetBeatSnapDistanceAt(double referenceTime) => throw new NotImplementedException(); + public float GetBeatSnapDistanceAt(HitObject referenceObject) => throw new NotImplementedException(); - public float DurationToDistance(double referenceTime, double duration) => throw new NotImplementedException(); + public float DurationToDistance(HitObject referenceObject, double duration) => throw new NotImplementedException(); - public double DistanceToDuration(double referenceTime, float distance) => throw new NotImplementedException(); + public double DistanceToDuration(HitObject referenceObject, float distance) => throw new NotImplementedException(); - public double GetSnappedDurationFromDistance(double referenceTime, float distance) => throw new NotImplementedException(); + public double GetSnappedDurationFromDistance(HitObject referenceObject, float distance) => throw new NotImplementedException(); - public float GetSnappedDistanceFromDistance(double referenceTime, float distance) => throw new NotImplementedException(); + public float GetSnappedDistanceFromDistance(HitObject referenceObject, float distance) => throw new NotImplementedException(); } } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs index c4beb40f92..2b2e66fb18 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs @@ -45,17 +45,9 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline { switch (point) { - case DifficultyControlPoint difficultyPoint: - AddInternal(new DifficultyPointPiece(difficultyPoint) { Depth = -2 }); - break; - case TimingControlPoint timingPoint: AddInternal(new TimingPointPiece(timingPoint)); break; - - case SampleControlPoint samplePoint: - AddInternal(new SamplePointPiece(samplePoint) { Depth = -1 }); - break; } } }, true); diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs index 911c9fea51..e2458d45c9 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs @@ -13,7 +13,9 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Primitives; using osu.Framework.Graphics.Shapes; using osu.Framework.Input.Events; +using osu.Framework.Threading; using osu.Framework.Utils; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Rulesets.Edit; @@ -179,6 +181,15 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline colouredComponents.Colour = OsuColour.ForegroundTextColourFor(col); } + private SamplePointPiece sampleOverrideDisplay; + private DifficultyPointPiece difficultyOverrideDisplay; + + [Resolved] + private EditorBeatmap beatmap { get; set; } + + private DifficultyControlPoint difficultyControlPoint; + private SampleControlPoint sampleControlPoint; + protected override void Update() { base.Update(); @@ -194,6 +205,36 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline if (Item is IHasRepeats repeats) updateRepeats(repeats); } + + if (difficultyControlPoint != Item.DifficultyControlPoint) + { + difficultyControlPoint = Item.DifficultyControlPoint; + difficultyOverrideDisplay?.Expire(); + + if (Item.DifficultyControlPoint != null && Item is IHasDistance) + { + AddInternal(difficultyOverrideDisplay = new DifficultyPointPiece(Item) + { + Anchor = Anchor.TopLeft, + Origin = Anchor.BottomCentre + }); + } + } + + if (sampleControlPoint != Item.SampleControlPoint) + { + sampleControlPoint = Item.SampleControlPoint; + sampleOverrideDisplay?.Expire(); + + if (Item.SampleControlPoint != null) + { + AddInternal(sampleOverrideDisplay = new SamplePointPiece(Item) + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.TopCentre + }); + } + } } private void updateRepeats(IHasRepeats repeats) @@ -331,39 +372,66 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline return true; } + private ScheduledDelegate dragOperation; + protected override void OnDrag(DragEvent e) { base.OnDrag(e); - OnDragHandled?.Invoke(e); - - if (timeline.SnapScreenSpacePositionToValidTime(e.ScreenSpaceMousePosition).Time is double time) + // schedule is temporary to ensure we don't process multiple times on a single update frame. we need to find a better method of doing this. + // without it, a hitobject's endtime may not always be in a valid state (ie. sliders, which needs to recompute their path). + dragOperation?.Cancel(); + dragOperation = Scheduler.Add(() => { - switch (hitObject) + OnDragHandled?.Invoke(e); + + if (timeline.SnapScreenSpacePositionToValidTime(e.ScreenSpaceMousePosition).Time is double time) { - case IHasRepeats repeatHitObject: - // find the number of repeats which can fit in the requested time. - var lengthOfOneRepeat = repeatHitObject.Duration / (repeatHitObject.RepeatCount + 1); - var proposedCount = Math.Max(0, (int)Math.Round((time - hitObject.StartTime) / lengthOfOneRepeat) - 1); + switch (hitObject) + { + case IHasRepeats repeatHitObject: + double proposedDuration = time - hitObject.StartTime; - if (proposedCount == repeatHitObject.RepeatCount) - return; + if (e.CurrentState.Keyboard.ShiftPressed) + { + if (hitObject.DifficultyControlPoint == DifficultyControlPoint.DEFAULT) + hitObject.DifficultyControlPoint = new DifficultyControlPoint(); - repeatHitObject.RepeatCount = proposedCount; - beatmap.Update(hitObject); - break; + var newVelocity = hitObject.DifficultyControlPoint.SliderVelocity * (repeatHitObject.Duration / proposedDuration); - case IHasDuration endTimeHitObject: - var snappedTime = Math.Max(hitObject.StartTime, beatSnapProvider.SnapTime(time)); + if (Precision.AlmostEquals(newVelocity, hitObject.DifficultyControlPoint.SliderVelocity)) + return; - if (endTimeHitObject.EndTime == snappedTime || Precision.AlmostEquals(snappedTime, hitObject.StartTime, beatmap.GetBeatLengthAtTime(snappedTime))) - return; + hitObject.DifficultyControlPoint.SliderVelocity = newVelocity; + beatmap.Update(hitObject); + } + else + { + // find the number of repeats which can fit in the requested time. + var lengthOfOneRepeat = repeatHitObject.Duration / (repeatHitObject.RepeatCount + 1); + var proposedCount = Math.Max(0, (int)Math.Round(proposedDuration / lengthOfOneRepeat) - 1); - endTimeHitObject.Duration = snappedTime - hitObject.StartTime; - beatmap.Update(hitObject); - break; + if (proposedCount == repeatHitObject.RepeatCount) + return; + + repeatHitObject.RepeatCount = proposedCount; + beatmap.Update(hitObject); + } + + break; + + case IHasDuration endTimeHitObject: + var snappedTime = Math.Max(hitObject.StartTime, beatSnapProvider.SnapTime(time)); + + if (endTimeHitObject.EndTime == snappedTime || Precision.AlmostEquals(snappedTime, hitObject.StartTime, beatmap.GetBeatLengthAtTime(snappedTime))) + return; + + endTimeHitObject.Duration = snappedTime - hitObject.StartTime; + beatmap.Update(hitObject); + break; + } } - } + }); } protected override void OnDragEnd(DragEndEvent e) diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 1170658abb..512226413b 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -162,7 +162,7 @@ namespace osu.Game.Screens.Edit // todo: remove caching of this and consume via editorBeatmap? dependencies.Cache(beatDivisor); - AddInternal(editorBeatmap = new EditorBeatmap(playableBeatmap, loadableBeatmap.GetSkin())); + AddInternal(editorBeatmap = new EditorBeatmap(playableBeatmap, loadableBeatmap.GetSkin(), loadableBeatmap.BeatmapInfo)); dependencies.CacheAs(editorBeatmap); changeHandler = new EditorChangeHandler(editorBeatmap); dependencies.CacheAs(changeHandler); @@ -333,10 +333,10 @@ namespace osu.Game.Screens.Edit isNewBeatmap = false; // apply any set-level metadata changes. - beatmapManager.Update(playableBeatmap.BeatmapInfo.BeatmapSet); + beatmapManager.Update(editorBeatmap.BeatmapInfo.BeatmapSet); // save the loaded beatmap's data stream. - beatmapManager.Save(playableBeatmap.BeatmapInfo, editorBeatmap, editorBeatmap.BeatmapSkin); + beatmapManager.Save(editorBeatmap.BeatmapInfo, editorBeatmap.PlayableBeatmap, editorBeatmap.BeatmapSkin); updateLastSavedHash(); } @@ -523,7 +523,10 @@ namespace osu.Game.Screens.Edit var refetchedBeatmap = beatmapManager.GetWorkingBeatmap(Beatmap.Value.BeatmapInfo); if (!(refetchedBeatmap is DummyWorkingBeatmap)) + { + Logger.Log("Editor providing re-fetched beatmap post edit session"); Beatmap.Value = refetchedBeatmap; + } return base.OnExiting(next); } diff --git a/osu.Game/Screens/Edit/EditorBeatmap.cs b/osu.Game/Screens/Edit/EditorBeatmap.cs index 64eb6225fa..2e84ef437a 100644 --- a/osu.Game/Screens/Edit/EditorBeatmap.cs +++ b/osu.Game/Screens/Edit/EditorBeatmap.cs @@ -10,6 +10,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; +using osu.Game.Beatmaps.Legacy; using osu.Game.Beatmaps.Timing; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Objects; @@ -44,6 +45,7 @@ namespace osu.Game.Screens.Edit /// public readonly Bindable PlacementObject = new Bindable(); + private readonly BeatmapInfo beatmapInfo; public readonly IBeatmap PlayableBeatmap; /// @@ -66,9 +68,37 @@ namespace osu.Game.Screens.Edit private readonly Dictionary> startTimeBindables = new Dictionary>(); - public EditorBeatmap(IBeatmap playableBeatmap, ISkin beatmapSkin = null) + public EditorBeatmap(IBeatmap playableBeatmap, ISkin beatmapSkin = null, BeatmapInfo beatmapInfo = null) { PlayableBeatmap = playableBeatmap; + + // ensure we are not working with legacy control points. + // if we leave the legacy points around they will be applied over any local changes on + // ApplyDefaults calls. this should eventually be removed once the default logic is moved to the decoder/converter. + if (PlayableBeatmap.ControlPointInfo is LegacyControlPointInfo) + { + var newControlPoints = new ControlPointInfo(); + + foreach (var controlPoint in PlayableBeatmap.ControlPointInfo.AllControlPoints) + { + switch (controlPoint) + { + case DifficultyControlPoint _: + case SampleControlPoint _: + // skip legacy types. + continue; + + default: + newControlPoints.Add(controlPoint.Time, controlPoint); + break; + } + } + + playableBeatmap.ControlPointInfo = newControlPoints; + } + + this.beatmapInfo = beatmapInfo ?? playableBeatmap.BeatmapInfo; + if (beatmapSkin is Skin skin) BeatmapSkin = new EditorBeatmapSkin(skin); @@ -80,11 +110,11 @@ namespace osu.Game.Screens.Edit public BeatmapInfo BeatmapInfo { - get => PlayableBeatmap.BeatmapInfo; - set => PlayableBeatmap.BeatmapInfo = value; + get => beatmapInfo; + set => throw new InvalidOperationException(); } - public BeatmapMetadata Metadata => PlayableBeatmap.Metadata; + public BeatmapMetadata Metadata => beatmapInfo.Metadata; public BeatmapDifficulty Difficulty { diff --git a/osu.Game/Screens/Edit/EditorClock.cs b/osu.Game/Screens/Edit/EditorClock.cs index ba83261731..86e5729196 100644 --- a/osu.Game/Screens/Edit/EditorClock.cs +++ b/osu.Game/Screens/Edit/EditorClock.cs @@ -25,7 +25,9 @@ namespace osu.Game.Screens.Edit public double TrackLength => track.Value?.Length ?? 60000; - public ControlPointInfo ControlPointInfo; + public ControlPointInfo ControlPointInfo => Beatmap.ControlPointInfo; + + public IBeatmap Beatmap { get; set; } private readonly BindableBeatDivisor beatDivisor; @@ -42,25 +44,15 @@ namespace osu.Game.Screens.Edit /// public bool IsSeeking { get; private set; } - public EditorClock(IBeatmap beatmap, BindableBeatDivisor beatDivisor) - : this(beatmap.ControlPointInfo, beatDivisor) + public EditorClock(IBeatmap beatmap = null, BindableBeatDivisor beatDivisor = null) { - } + Beatmap = beatmap ?? new Beatmap(); - public EditorClock(ControlPointInfo controlPointInfo, BindableBeatDivisor beatDivisor) - { - this.beatDivisor = beatDivisor; - - ControlPointInfo = controlPointInfo; + this.beatDivisor = beatDivisor ?? new BindableBeatDivisor(); underlyingClock = new DecoupleableInterpolatingFramedClock(); } - public EditorClock() - : this(new ControlPointInfo(), new BindableBeatDivisor()) - { - } - /// /// Seek to the closest snappable beat from a time. /// diff --git a/osu.Game/Screens/Edit/EditorRoundedScreen.cs b/osu.Game/Screens/Edit/EditorRoundedScreen.cs index b271a145f5..508663224d 100644 --- a/osu.Game/Screens/Edit/EditorRoundedScreen.cs +++ b/osu.Game/Screens/Edit/EditorRoundedScreen.cs @@ -41,7 +41,7 @@ namespace osu.Game.Screens.Edit { new Box { - Colour = ColourProvider.Dark4, + Colour = ColourProvider.Background3, RelativeSizeAxes = Axes.Both, }, roundedContent = new Container diff --git a/osu.Game/Screens/Edit/Setup/MetadataSection.cs b/osu.Game/Screens/Edit/Setup/MetadataSection.cs index 9e93b0b038..5bb40c09a5 100644 --- a/osu.Game/Screens/Edit/Setup/MetadataSection.cs +++ b/osu.Game/Screens/Edit/Setup/MetadataSection.cs @@ -10,7 +10,7 @@ using osu.Game.Graphics.UserInterfaceV2; namespace osu.Game.Screens.Edit.Setup { - internal class MetadataSection : SetupSection + public class MetadataSection : SetupSection { protected LabelledTextBox ArtistTextBox; protected LabelledTextBox RomanisedArtistTextBox; diff --git a/osu.Game/Screens/Edit/Timing/ControlPointSettings.cs b/osu.Game/Screens/Edit/Timing/ControlPointSettings.cs index 48639789af..938c7f9cf0 100644 --- a/osu.Game/Screens/Edit/Timing/ControlPointSettings.cs +++ b/osu.Game/Screens/Edit/Timing/ControlPointSettings.cs @@ -12,8 +12,6 @@ namespace osu.Game.Screens.Edit.Timing { new GroupSection(), new TimingSection(), - new DifficultySection(), - new SampleSection(), new EffectSection(), }; } diff --git a/osu.Game/Screens/Edit/Timing/DifficultySection.cs b/osu.Game/Screens/Edit/Timing/DifficultySection.cs deleted file mode 100644 index 97d110c502..0000000000 --- a/osu.Game/Screens/Edit/Timing/DifficultySection.cs +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Game.Beatmaps.ControlPoints; - -namespace osu.Game.Screens.Edit.Timing -{ - internal class DifficultySection : Section - { - private SliderWithTextBoxInput multiplierSlider; - - [BackgroundDependencyLoader] - private void load() - { - Flow.AddRange(new[] - { - multiplierSlider = new SliderWithTextBoxInput("Speed Multiplier") - { - Current = new DifficultyControlPoint().SpeedMultiplierBindable, - KeyboardStep = 0.1f - } - }); - } - - protected override void OnControlPointChanged(ValueChangedEvent point) - { - if (point.NewValue != null) - { - var selectedPointBindable = point.NewValue.SpeedMultiplierBindable; - - // there may be legacy control points, which contain infinite precision for compatibility reasons (see LegacyDifficultyControlPoint). - // generally that level of precision could only be set by externally editing the .osu file, so at the point - // a user is looking to update this within the editor it should be safe to obliterate this additional precision. - double expectedPrecision = new DifficultyControlPoint().SpeedMultiplierBindable.Precision; - if (selectedPointBindable.Precision < expectedPrecision) - selectedPointBindable.Precision = expectedPrecision; - - multiplierSlider.Current = selectedPointBindable; - multiplierSlider.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); - } - } - - protected override DifficultyControlPoint CreatePoint() - { - var reference = Beatmap.ControlPointInfo.DifficultyPointAt(SelectedGroup.Value.Time); - - return new DifficultyControlPoint - { - SpeedMultiplier = reference.SpeedMultiplier, - }; - } - } -} diff --git a/osu.Game/Screens/Edit/Timing/EffectSection.cs b/osu.Game/Screens/Edit/Timing/EffectSection.cs index 6d23b52c05..c8944d0357 100644 --- a/osu.Game/Screens/Edit/Timing/EffectSection.cs +++ b/osu.Game/Screens/Edit/Timing/EffectSection.cs @@ -3,6 +3,7 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Graphics; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.UserInterfaceV2; @@ -13,13 +14,20 @@ namespace osu.Game.Screens.Edit.Timing private LabelledSwitchButton kiai; private LabelledSwitchButton omitBarLine; + private SliderWithTextBoxInput scrollSpeedSlider; + [BackgroundDependencyLoader] private void load() { - Flow.AddRange(new[] + Flow.AddRange(new Drawable[] { kiai = new LabelledSwitchButton { Label = "Kiai Time" }, omitBarLine = new LabelledSwitchButton { Label = "Skip Bar Line" }, + scrollSpeedSlider = new SliderWithTextBoxInput("Scroll Speed") + { + Current = new EffectControlPoint().ScrollSpeedBindable, + KeyboardStep = 0.1f + } }); } @@ -32,6 +40,9 @@ namespace osu.Game.Screens.Edit.Timing omitBarLine.Current = point.NewValue.OmitFirstBarLineBindable; omitBarLine.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); + + scrollSpeedSlider.Current = point.NewValue.ScrollSpeedBindable; + scrollSpeedSlider.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); } } @@ -42,7 +53,8 @@ namespace osu.Game.Screens.Edit.Timing return new EffectControlPoint { KiaiMode = reference.KiaiMode, - OmitFirstBarLine = reference.OmitFirstBarLine + OmitFirstBarLine = reference.OmitFirstBarLine, + ScrollSpeed = reference.ScrollSpeed, }; } } diff --git a/osu.Game/Screens/Edit/Timing/RowAttributes/DifficultyRowAttribute.cs b/osu.Game/Screens/Edit/Timing/RowAttributes/DifficultyRowAttribute.cs index 7b553ac7ad..a8de476d67 100644 --- a/osu.Game/Screens/Edit/Timing/RowAttributes/DifficultyRowAttribute.cs +++ b/osu.Game/Screens/Edit/Timing/RowAttributes/DifficultyRowAttribute.cs @@ -18,7 +18,7 @@ namespace osu.Game.Screens.Edit.Timing.RowAttributes public DifficultyRowAttribute(DifficultyControlPoint difficulty) : base(difficulty, "difficulty") { - speedMultiplier = difficulty.SpeedMultiplierBindable.GetBoundCopy(); + speedMultiplier = difficulty.SliderVelocityBindable.GetBoundCopy(); } [BackgroundDependencyLoader] @@ -32,7 +32,7 @@ namespace osu.Game.Screens.Edit.Timing.RowAttributes }, text = new AttributeText(Point) { - Width = 40, + Width = 45, }, }); diff --git a/osu.Game/Screens/Edit/Timing/RowAttributes/EffectRowAttribute.cs b/osu.Game/Screens/Edit/Timing/RowAttributes/EffectRowAttribute.cs index 812407d6da..1b33fd62aa 100644 --- a/osu.Game/Screens/Edit/Timing/RowAttributes/EffectRowAttribute.cs +++ b/osu.Game/Screens/Edit/Timing/RowAttributes/EffectRowAttribute.cs @@ -12,14 +12,18 @@ namespace osu.Game.Screens.Edit.Timing.RowAttributes { private readonly Bindable kiaiMode; private readonly Bindable omitBarLine; + private readonly BindableNumber scrollSpeed; + private AttributeText kiaiModeBubble; private AttributeText omitBarLineBubble; + private AttributeText text; public EffectRowAttribute(EffectControlPoint effect) : base(effect, "effect") { kiaiMode = effect.KiaiModeBindable.GetBoundCopy(); omitBarLine = effect.OmitFirstBarLineBindable.GetBoundCopy(); + scrollSpeed = effect.ScrollSpeedBindable.GetBoundCopy(); } [BackgroundDependencyLoader] @@ -27,12 +31,20 @@ namespace osu.Game.Screens.Edit.Timing.RowAttributes { Content.AddRange(new Drawable[] { + new AttributeProgressBar(Point) + { + Current = scrollSpeed, + }, + text = new AttributeText(Point) { Width = 45 }, kiaiModeBubble = new AttributeText(Point) { Text = "kiai" }, omitBarLineBubble = new AttributeText(Point) { Text = "no barline" }, }); kiaiMode.BindValueChanged(enabled => kiaiModeBubble.FadeTo(enabled.NewValue ? 1 : 0), true); omitBarLine.BindValueChanged(enabled => omitBarLineBubble.FadeTo(enabled.NewValue ? 1 : 0), true); + scrollSpeed.BindValueChanged(_ => updateText(), true); } + + private void updateText() => text.Text = $"{scrollSpeed.Value:n2}x"; } } diff --git a/osu.Game/Screens/Edit/Timing/SampleSection.cs b/osu.Game/Screens/Edit/Timing/SampleSection.cs deleted file mode 100644 index 52709a2bbe..0000000000 --- a/osu.Game/Screens/Edit/Timing/SampleSection.cs +++ /dev/null @@ -1,47 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Graphics.UserInterfaceV2; - -namespace osu.Game.Screens.Edit.Timing -{ - internal class SampleSection : Section - { - private LabelledTextBox bank; - private SliderWithTextBoxInput volume; - - [BackgroundDependencyLoader] - private void load() - { - Flow.AddRange(new Drawable[] - { - bank = new LabelledTextBox - { - Label = "Bank Name", - }, - volume = new SliderWithTextBoxInput("Volume") - { - Current = new SampleControlPoint().SampleVolumeBindable, - } - }); - } - - protected override void OnControlPointChanged(ValueChangedEvent point) - { - if (point.NewValue != null) - { - bank.Current = point.NewValue.SampleBankBindable; - bank.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); - - volume.Current = point.NewValue.SampleVolumeBindable; - volume.Current.BindValueChanged(_ => ChangeHandler?.SaveState()); - } - } - - protected override SampleControlPoint CreatePoint() => new SampleControlPoint(); // TODO: remove - } -} diff --git a/osu.Game/Screens/OnlinePlay/Components/BeatmapTitle.cs b/osu.Game/Screens/OnlinePlay/Components/BeatmapTitle.cs index e5a5e35897..2901758332 100644 --- a/osu.Game/Screens/OnlinePlay/Components/BeatmapTitle.cs +++ b/osu.Game/Screens/OnlinePlay/Components/BeatmapTitle.cs @@ -86,7 +86,7 @@ namespace osu.Game.Screens.OnlinePlay.Components Text = new RomanisableString(beatmap.Value.Metadata.TitleUnicode, beatmap.Value.Metadata.Title), Font = OsuFont.GetFont(size: TextSize), } - }, LinkAction.OpenBeatmap, beatmap.Value.OnlineBeatmapID.ToString(), "Open beatmap"); + }, LinkAction.OpenBeatmap, beatmap.Value.OnlineID.ToString(), "Open beatmap"); } } } diff --git a/osu.Game/Screens/OnlinePlay/Components/BeatmapTypeInfo.cs b/osu.Game/Screens/OnlinePlay/Components/BeatmapTypeInfo.cs deleted file mode 100644 index 3aa13458a4..0000000000 --- a/osu.Game/Screens/OnlinePlay/Components/BeatmapTypeInfo.cs +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osuTK; - -namespace osu.Game.Screens.OnlinePlay.Components -{ - public class BeatmapTypeInfo : OnlinePlayComposite - { - private LinkFlowContainer beatmapAuthor; - - public BeatmapTypeInfo() - { - AutoSizeAxes = Axes.Both; - } - - [BackgroundDependencyLoader] - private void load() - { - InternalChild = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - LayoutDuration = 100, - Spacing = new Vector2(5, 0), - Children = new Drawable[] - { - new ModeTypeInfo(), - new Container - { - AutoSizeAxes = Axes.X, - Height = 30, - Margin = new MarginPadding { Left = 5 }, - Children = new Drawable[] - { - new BeatmapTitle(), - beatmapAuthor = new LinkFlowContainer(s => s.Font = s.Font.With(size: 14)) - { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - AutoSizeAxes = Axes.Both - }, - }, - }, - } - }; - - Playlist.CollectionChanged += (_, __) => updateInfo(); - - updateInfo(); - } - - private void updateInfo() - { - beatmapAuthor.Clear(); - - var beatmap = Playlist.FirstOrDefault()?.Beatmap; - - if (beatmap != null) - { - beatmapAuthor.AddText("mapped by ", s => s.Colour = OsuColour.Gray(0.8f)); - beatmapAuthor.AddUserLink(beatmap.Value.Metadata.Author); - } - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundScreen.cs b/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundScreen.cs index 6ce5f6a6db..8b6077b9f2 100644 --- a/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundScreen.cs @@ -61,7 +61,7 @@ namespace osu.Game.Screens.OnlinePlay.Components { var beatmap = playlistItem?.Beatmap.Value; - if (background?.BeatmapInfo?.BeatmapSet?.OnlineInfo?.Covers?.Cover == beatmap?.BeatmapSet?.OnlineInfo?.Covers?.Cover) + if (background?.BeatmapInfo?.BeatmapSet?.OnlineInfo?.Covers.Cover == beatmap?.BeatmapSet?.OnlineInfo?.Covers.Cover) return; cancellationSource?.Cancel(); diff --git a/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs b/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs index 381849189d..abda9e897b 100644 --- a/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs +++ b/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs @@ -116,8 +116,6 @@ namespace osu.Game.Screens.OnlinePlay.Components if (ignoredRooms.Contains(room.RoomID.Value.Value)) return; - room.Position.Value = -room.RoomID.Value.Value; - try { foreach (var pi in room.Playlist) @@ -152,6 +150,11 @@ namespace osu.Game.Screens.OnlinePlay.Components notifyRoomsUpdated(); } - private void notifyRoomsUpdated() => Scheduler.AddOnce(() => RoomsUpdated?.Invoke()); + private void notifyRoomsUpdated() + { + Scheduler.AddOnce(invokeRoomsUpdated); + + void invokeRoomsUpdated() => RoomsUpdated?.Invoke(); + } } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs index 907b7e308a..85efdcef1a 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs @@ -129,7 +129,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components private void updateSorting() { foreach (var room in roomFlow) - roomFlow.SetLayoutPosition(room, room.Room.Position.Value); + roomFlow.SetLayoutPosition(room, -(room.Room.RoomID.Value ?? 0)); } protected override bool OnClick(ClickEvent e) diff --git a/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs b/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs index fe7c7cc364..72574b729a 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/DrawableLoungeRoom.cs @@ -103,7 +103,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge public IEnumerable FilterTerms => new[] { Room.Name.Value }; - private bool matchingFilter; + private bool matchingFilter = true; public bool MatchingFilter { @@ -181,6 +181,10 @@ namespace osu.Game.Screens.OnlinePlay.Lounge [Resolved(canBeNull: true)] private LoungeSubScreen lounge { get; set; } + public override bool HandleNonPositionalInput => true; + + protected override bool BlockNonPositionalInput => true; + public PasswordEntryPopover(Room room) { this.room = room; @@ -200,6 +204,8 @@ namespace osu.Game.Screens.OnlinePlay.Lounge Spacing = new Vector2(5), AutoSizeAxes = Axes.Both, Direction = FillDirection.Vertical, + LayoutDuration = 500, + LayoutEasing = Easing.OutQuint, Children = new Drawable[] { new FillFlowContainer @@ -230,10 +236,24 @@ namespace osu.Game.Screens.OnlinePlay.Lounge sampleJoinFail = audio.Samples.Get(@"UI/password-fail"); - joinButton.Action = () => lounge?.Join(room, passwordTextbox.Text, null, joinFailed); + joinButton.Action = performJoin; } - private void joinFailed(string error) + protected override void LoadComplete() + { + base.LoadComplete(); + + Schedule(() => GetContainingInputManager().ChangeFocus(passwordTextbox)); + passwordTextbox.OnCommit += (_, __) => performJoin(); + } + + private void performJoin() + { + lounge?.Join(room, passwordTextbox.Text, null, joinFailed); + GetContainingInputManager().TriggerFocusContention(passwordTextbox); + } + + private void joinFailed(string error) => Schedule(() => { passwordTextbox.Text = string.Empty; @@ -249,15 +269,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge Body.Shake(); sampleJoinFail?.Play(); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - Schedule(() => GetContainingInputManager().ChangeFocus(passwordTextbox)); - passwordTextbox.OnCommit += (_, __) => lounge?.Join(room, passwordTextbox.Text, null, joinFailed); - } + }); } } } diff --git a/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs index 08bdd0487a..cd1c8a0a64 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs @@ -17,7 +17,6 @@ using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Framework.Threading; -using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Input; @@ -126,7 +125,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge { RelativeSizeAxes = Axes.X, Height = Header.HEIGHT, - Child = searchTextBox = new LoungeSearchTextBox + Child = searchTextBox = new SearchTextBox { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, @@ -290,7 +289,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge popoverContainer.HidePopover(); } - public void Join(Room room, string password, Action onSuccess = null, Action onFailure = null) => Schedule(() => + public virtual void Join(Room room, string password, Action onSuccess = null, Action onFailure = null) => Schedule(() => { if (joiningRoomOperation != null) return; @@ -362,15 +361,5 @@ namespace osu.Game.Screens.OnlinePlay.Lounge protected abstract RoomSubScreen CreateRoomSubScreen(Room room); protected abstract ListingPollingComponent CreatePollingComponent(); - - private class LoungeSearchTextBox : SearchTextBox - { - [BackgroundDependencyLoader] - private void load() - { - BackgroundUnfocused = OsuColour.Gray(0.06f); - BackgroundFocused = OsuColour.Gray(0.12f); - } - } } } diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs index a6cdde14f6..6d14b95aec 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs @@ -12,7 +12,6 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Online.Rooms; using osuTK; -using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Match.Components { @@ -91,31 +90,6 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components { } - protected class SettingsTextBox : OsuTextBox - { - [BackgroundDependencyLoader] - private void load() - { - BackgroundUnfocused = Color4.Black; - BackgroundFocused = Color4.Black; - } - } - - protected class SettingsNumberTextBox : SettingsTextBox - { - protected override bool CanAddCharacter(char character) => char.IsNumber(character); - } - - protected class SettingsPasswordTextBox : OsuPasswordTextBox - { - [BackgroundDependencyLoader] - private void load() - { - BackgroundUnfocused = Color4.Black; - BackgroundFocused = Color4.Black; - } - } - protected class SectionContainer : FillFlowContainer
{ public SectionContainer() diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs index af0c50a848..0e73f65f8b 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/GameplayChatDisplay.cs @@ -75,6 +75,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { switch (e.Action) { + case GlobalAction.Back: + if (Textbox.HasFocus) + { + Schedule(() => Textbox.KillFocus()); + return true; + } + + break; + case GlobalAction.ToggleChatFocus: if (Textbox.HasFocus) { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs index 80a5daa7c8..5bc76a10bc 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs @@ -7,7 +7,6 @@ using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.ExceptionExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -99,14 +98,14 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider, OsuColour colours) { InternalChildren = new Drawable[] { new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4Extensions.FromHex(@"28242d"), + Colour = colourProvider.Background4 }, new GridContainer { @@ -154,7 +153,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { new Section("Room name") { - Child = NameField = new SettingsTextBox + Child = NameField = new OsuTextBox { RelativeSizeAxes = Axes.X, TabbableContentContainer = this, @@ -203,7 +202,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match new Section("Max participants") { Alpha = disabled_alpha, - Child = MaxParticipantsField = new SettingsNumberTextBox + Child = MaxParticipantsField = new OsuNumberBox { RelativeSizeAxes = Axes.X, TabbableContentContainer = this, @@ -212,7 +211,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match }, new Section("Password (optional)") { - Child = PasswordTextBox = new SettingsPasswordTextBox + Child = PasswordTextBox = new OsuPasswordTextBox { RelativeSizeAxes = Axes.X, TabbableContentContainer = this, @@ -249,7 +248,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4Extensions.FromHex(@"28242d").Darken(0.5f).Opacity(1f), + Colour = colourProvider.Background5 }, new FillFlowContainer { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs index 6c3dfe7382..cf1066df10 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerLoungeSubScreen.cs @@ -79,11 +79,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer private void load() { isConnected.BindTo(client.IsConnected); - isConnected.BindValueChanged(c => Scheduler.AddOnce(() => - { - if (isConnected.Value && IsLoaded) - PollImmediately(); - }), true); + isConnected.BindValueChanged(c => Scheduler.AddOnce(poll), true); + } + + private void poll() + { + if (isConnected.Value && IsLoaded) + PollImmediately(); } protected override Task Poll() diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs index 0f256160eb..a380ddef25 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs @@ -19,15 +19,19 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { base.LoadComplete(); - Client.RoomUpdated += OnRoomUpdated; - - Client.UserLeft += UserLeft; - Client.UserKicked += UserKicked; - Client.UserJoined += UserJoined; + Client.RoomUpdated += invokeOnRoomUpdated; + Client.UserLeft += invokeUserLeft; + Client.UserKicked += invokeUserKicked; + Client.UserJoined += invokeUserJoined; OnRoomUpdated(); } + private void invokeOnRoomUpdated() => Scheduler.AddOnce(OnRoomUpdated); + private void invokeUserJoined(MultiplayerRoomUser user) => Scheduler.AddOnce(UserJoined, user); + private void invokeUserKicked(MultiplayerRoomUser user) => Scheduler.AddOnce(UserKicked, user); + private void invokeUserLeft(MultiplayerRoomUser user) => Scheduler.AddOnce(UserLeft, user); + /// /// Invoked when a user has joined the room. /// @@ -63,10 +67,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { if (Client != null) { - Client.UserLeft -= UserLeft; - Client.UserKicked -= UserKicked; - Client.UserJoined -= UserJoined; - Client.RoomUpdated -= OnRoomUpdated; + Client.RoomUpdated -= invokeOnRoomUpdated; + Client.UserLeft -= invokeUserLeft; + Client.UserKicked -= invokeUserKicked; + Client.UserJoined -= invokeUserJoined; } base.Dispose(isDisposing); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs index 6f8c735b6e..79e305b765 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs @@ -15,6 +15,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Online.Multiplayer; using osu.Game.Rulesets; @@ -190,6 +191,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants userStateDisplay.UpdateStatus(User.State, User.BeatmapAvailability); + if ((User.BeatmapAvailability.State == DownloadState.LocallyAvailable) && (User.State != MultiplayerUserState.Spectating)) + userModsDisplay.FadeIn(fade_time); + else + userModsDisplay.FadeOut(fade_time); + if (Client.IsHost && !User.Equals(Client.LocalUser)) kickButton.FadeIn(fade_time); else diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs index 7384c60888..c2bd7730e9 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Specialized; using Humanizer; using osu.Framework.Allocation; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -77,14 +76,14 @@ namespace osu.Game.Screens.OnlinePlay.Playlists } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OverlayColourProvider colourProvider, OsuColour colours) { InternalChildren = new Drawable[] { new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4Extensions.FromHex(@"28242d"), + Colour = colourProvider.Background4 }, new GridContainer { @@ -122,7 +121,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { new Section("Room name") { - Child = NameField = new SettingsTextBox + Child = NameField = new OsuTextBox { RelativeSizeAxes = Axes.X, TabbableContentContainer = this, @@ -151,7 +150,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists }, new Section("Allowed attempts (across all playlist items)") { - Child = MaxAttemptsField = new SettingsNumberTextBox + Child = MaxAttemptsField = new OsuNumberBox { RelativeSizeAxes = Axes.X, TabbableContentContainer = this, @@ -169,7 +168,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists new Section("Max participants") { Alpha = disabled_alpha, - Child = MaxParticipantsField = new SettingsNumberTextBox + Child = MaxParticipantsField = new OsuNumberBox { RelativeSizeAxes = Axes.X, TabbableContentContainer = this, @@ -179,7 +178,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists new Section("Password (optional)") { Alpha = disabled_alpha, - Child = new SettingsPasswordTextBox + Child = new OsuPasswordTextBox { RelativeSizeAxes = Axes.X, TabbableContentContainer = this, @@ -256,7 +255,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists new Box { RelativeSizeAxes = Axes.Both, - Colour = Color4Extensions.FromHex(@"28242d").Darken(0.5f).Opacity(1f), + Colour = colourProvider.Background5 }, new FillFlowContainer { diff --git a/osu.Game/Screens/Play/Break/BreakInfo.cs b/osu.Game/Screens/Play/Break/BreakInfo.cs index 6e129b20ea..6349ebd9a7 100644 --- a/osu.Game/Screens/Play/Break/BreakInfo.cs +++ b/osu.Game/Screens/Play/Break/BreakInfo.cs @@ -13,7 +13,9 @@ namespace osu.Game.Screens.Play.Break public class BreakInfo : Container { public PercentageBreakInfoLine AccuracyDisplay; - public BreakInfoLine RankDisplay; + + // Currently unused but may be revisited in a future design update (see https://github.com/ppy/osu/discussions/15185) + // public BreakInfoLine RankDisplay; public BreakInfoLine GradeDisplay; public BreakInfo() @@ -41,7 +43,9 @@ namespace osu.Game.Screens.Play.Break Children = new Drawable[] { AccuracyDisplay = new PercentageBreakInfoLine("Accuracy"), - RankDisplay = new BreakInfoLine("Rank"), + + // See https://github.com/ppy/osu/discussions/15185 + // RankDisplay = new BreakInfoLine("Rank"), GradeDisplay = new BreakInfoLine("Grade"), }, } diff --git a/osu.Game/Screens/Play/FailAnimation.cs b/osu.Game/Screens/Play/FailAnimation.cs index e250791b72..edfb8186bb 100644 --- a/osu.Game/Screens/Play/FailAnimation.cs +++ b/osu.Game/Screens/Play/FailAnimation.cs @@ -6,14 +6,17 @@ using osu.Framework.Bindables; using osu.Game.Rulesets.UI; using System; using System.Collections.Generic; +using ManagedBass.Fx; using osu.Framework.Allocation; using osu.Framework.Audio.Sample; using osu.Framework.Audio.Track; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; using osu.Framework.Utils; using osu.Game.Audio.Effects; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Rulesets.Objects.Drawables; using osuTK; using osuTK.Graphics; @@ -22,27 +25,43 @@ namespace osu.Game.Screens.Play { /// /// Manage the animation to be applied when a player fails. - /// Single file; automatically disposed after use. + /// Single use and automatically disposed after use. /// - public class FailAnimation : CompositeDrawable + public class FailAnimation : Container { public Action OnComplete; private readonly DrawableRuleset drawableRuleset; - private readonly BindableDouble trackFreq = new BindableDouble(1); + private Container filters; + + private Box redFlashLayer; + private Track track; private AudioFilter failLowPassFilter; + private AudioFilter failHighPassFilter; private const float duration = 2500; private Sample failSample; + [Resolved] + private OsuConfigManager config { get; set; } + + protected override Container Content { get; } = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + }; + public FailAnimation(DrawableRuleset drawableRuleset) { this.drawableRuleset = drawableRuleset; + + RelativeSizeAxes = Axes.Both; } [BackgroundDependencyLoader] @@ -51,7 +70,26 @@ namespace osu.Game.Screens.Play track = beatmap.Value.Track; failSample = audio.Samples.Get(@"Gameplay/failsound"); - AddInternal(failLowPassFilter = new AudioFilter(audio.TrackMixer)); + AddRangeInternal(new Drawable[] + { + filters = new Container + { + Children = new Drawable[] + { + failLowPassFilter = new AudioFilter(audio.TrackMixer), + failHighPassFilter = new AudioFilter(audio.TrackMixer, BQFType.HighPass), + }, + }, + Content, + redFlashLayer = new Box + { + Colour = Color4.Red, + RelativeSizeAxes = Axes.Both, + Blending = BlendingParameters.Additive, + Depth = float.MinValue, + Alpha = 0 + }, + }); } private bool started; @@ -66,21 +104,47 @@ namespace osu.Game.Screens.Play started = true; - failSample.Play(); - this.TransformBindableTo(trackFreq, 0, duration).OnComplete(_ => { + RemoveFilters(); OnComplete?.Invoke(); - Expire(); }); + failHighPassFilter.CutoffTo(300); failLowPassFilter.CutoffTo(300, duration, Easing.OutCubic); + failSample.Play(); track.AddAdjustment(AdjustableProperty.Frequency, trackFreq); applyToPlayfield(drawableRuleset.Playfield); - drawableRuleset.Playfield.HitObjectContainer.FlashColour(Color4.Red, 500); drawableRuleset.Playfield.HitObjectContainer.FadeOut(duration / 2); + + if (config.Get(OsuSetting.FadePlayfieldWhenHealthLow)) + redFlashLayer.FadeOutFromOne(1000); + + Content.Masking = true; + + Content.Add(new Box + { + Colour = Color4.Black, + RelativeSizeAxes = Axes.Both, + Depth = float.MaxValue + }); + + Content.ScaleTo(0.85f, duration, Easing.OutQuart); + Content.RotateTo(1, duration, Easing.OutQuart); + Content.FadeColour(Color4.Gray, duration); + } + + public void RemoveFilters() + { + if (filters.Parent == null) + return; + + RemoveInternal(filters); + filters.Dispose(); + + track?.RemoveAdjustment(AdjustableProperty.Frequency, trackFreq); } protected override void Update() @@ -129,11 +193,5 @@ namespace osu.Game.Screens.Play obj.MoveTo(originalPosition + new Vector2(0, 400), duration); } } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - track?.RemoveAdjustment(AdjustableProperty.Frequency, trackFreq); - } } } diff --git a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs index 63de5c8de5..87b19e8433 100644 --- a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs @@ -11,7 +11,6 @@ namespace osu.Game.Screens.Play.HUD public class DefaultScoreCounter : GameplayScoreCounter, ISkinnableDrawable { public DefaultScoreCounter() - : base(6) { Anchor = Anchor.TopCentre; Origin = Anchor.TopCentre; diff --git a/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs b/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs index e09630d2c4..e05eff5f3e 100644 --- a/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs +++ b/osu.Game/Screens/Play/HUD/GameplayScoreCounter.cs @@ -14,8 +14,8 @@ namespace osu.Game.Screens.Play.HUD { private Bindable scoreDisplayMode; - protected GameplayScoreCounter(int leading = 0, bool useCommaSeparator = false) - : base(leading, useCommaSeparator) + protected GameplayScoreCounter() + : base(6) { } diff --git a/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs b/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs index d04e60a2ab..b1c07512dd 100644 --- a/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs +++ b/osu.Game/Screens/Play/HUD/MatchScoreDisplay.cs @@ -4,11 +4,9 @@ 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.Graphics.Shapes; -using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; @@ -148,7 +146,7 @@ namespace osu.Game.Screens.Play.HUD Score2Text.X = Math.Max(5 + Score2Text.DrawWidth / 2, score2Bar.DrawWidth); } - protected class MatchScoreCounter : ScoreCounter + protected class MatchScoreCounter : CommaSeparatedScoreCounter { private OsuSpriteText displayedSpriteText; @@ -173,8 +171,6 @@ namespace osu.Game.Screens.Play.HUD => displayedSpriteText.Font = winning ? OsuFont.Torus.With(weight: FontWeight.Bold, size: font_size, fixedWidth: true) : OsuFont.Torus.With(weight: FontWeight.Regular, size: font_size * 0.8f, fixedWidth: true); - - protected override LocalisableString FormatCount(double count) => count.ToLocalisableString(@"N0"); } } } diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 090210e611..1381493fdf 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -220,6 +220,8 @@ namespace osu.Game.Screens.Play // ensure the score is in a consistent state with the current player. Score.ScoreInfo.BeatmapInfo = Beatmap.Value.BeatmapInfo; Score.ScoreInfo.Ruleset = ruleset.RulesetInfo; + if (ruleset.RulesetInfo.ID != null) + Score.ScoreInfo.RulesetID = ruleset.RulesetInfo.ID.Value; Score.ScoreInfo.Mods = gameplayMods; dependencies.CacheAs(GameplayState = new GameplayState(playableBeatmap, ruleset, gameplayMods, Score)); @@ -230,17 +232,53 @@ namespace osu.Game.Screens.Play // this is intentionally done in two stages to ensure things are in a loaded state before exposing the ruleset to skin sources. GameplayClockContainer.Add(rulesetSkinProvider); - rulesetSkinProvider.AddRange(new[] + rulesetSkinProvider.AddRange(new Drawable[] { - // underlay and gameplay should have access to the skinning sources. - createUnderlayComponents(), - createGameplayComponents(Beatmap.Value, playableBeatmap) + failAnimationLayer = new FailAnimation(DrawableRuleset) + { + OnComplete = onFailComplete, + Children = new[] + { + // underlay and gameplay should have access to the skinning sources. + createUnderlayComponents(), + createGameplayComponents(Beatmap.Value, playableBeatmap) + } + }, + FailOverlay = new FailOverlay + { + OnRetry = Restart, + OnQuit = () => PerformExit(true), + }, + new HotkeyExitOverlay + { + Action = () => + { + if (!this.IsCurrentScreen()) return; + + fadeOut(true); + PerformExit(false); + }, + }, }); + if (Configuration.AllowRestart) + { + rulesetSkinProvider.Add(new HotkeyRetryOverlay + { + Action = () => + { + if (!this.IsCurrentScreen()) return; + + fadeOut(true); + Restart(); + }, + }); + } + // add the overlay components as a separate step as they proxy some elements from the above underlay/gameplay components. // also give the overlays the ruleset skin provider to allow rulesets to potentially override HUD elements (used to disable combo counters etc.) // we may want to limit this in the future to disallow rulesets from outright replacing elements the user expects to be there. - rulesetSkinProvider.Add(createOverlayComponents(Beatmap.Value)); + failAnimationLayer.Add(createOverlayComponents(Beatmap.Value)); if (!DrawableRuleset.AllowGameplayOverlays) { @@ -375,11 +413,6 @@ namespace osu.Game.Screens.Play RequestSkip = () => progressToResults(false), Alpha = 0 }, - FailOverlay = new FailOverlay - { - OnRetry = Restart, - OnQuit = () => PerformExit(true), - }, PauseOverlay = new PauseOverlay { OnResume = Resume, @@ -387,18 +420,7 @@ namespace osu.Game.Screens.Play OnRetry = Restart, OnQuit = () => PerformExit(true), }, - new HotkeyExitOverlay - { - Action = () => - { - if (!this.IsCurrentScreen()) return; - - fadeOut(true); - PerformExit(false); - }, - }, - failAnimation = new FailAnimation(DrawableRuleset) { OnComplete = onFailComplete, }, - } + }, }; if (!Configuration.AllowSkipping || !DrawableRuleset.AllowGameplayOverlays) @@ -410,20 +432,6 @@ namespace osu.Game.Screens.Play if (GameplayClockContainer is MasterGameplayClockContainer master) HUDOverlay.PlayerSettingsOverlay.PlaybackSettings.UserPlaybackRate.BindTarget = master.UserPlaybackRate; - if (Configuration.AllowRestart) - { - container.Add(new HotkeyRetryOverlay - { - Action = () => - { - if (!this.IsCurrentScreen()) return; - - fadeOut(true); - Restart(); - }, - }); - } - return container; } @@ -541,7 +549,7 @@ namespace osu.Game.Screens.Play // if the fail animation is currently in progress, accelerate it (it will show the pause dialog on completion). if (ValidForResume && HasFailed) { - failAnimation.FinishTransforms(true); + failAnimationLayer.FinishTransforms(true); return; } @@ -766,7 +774,7 @@ namespace osu.Game.Screens.Play protected FailOverlay FailOverlay { get; private set; } - private FailAnimation failAnimation; + private FailAnimation failAnimationLayer; private bool onFail() { @@ -782,7 +790,7 @@ namespace osu.Game.Screens.Play if (PauseOverlay.State.Value == Visibility.Visible) PauseOverlay.Hide(); - failAnimation.Start(); + failAnimationLayer.Start(); if (GameplayState.Mods.OfType().Any(m => m.RestartOnFail)) Restart(); @@ -947,7 +955,7 @@ namespace osu.Game.Screens.Play public override void OnSuspending(IScreen next) { - screenSuspension?.Expire(); + screenSuspension?.RemoveAndDisposeImmediately(); fadeOut(); base.OnSuspending(next); @@ -955,7 +963,8 @@ namespace osu.Game.Screens.Play public override bool OnExiting(IScreen next) { - screenSuspension?.Expire(); + screenSuspension?.RemoveAndDisposeImmediately(); + failAnimationLayer?.RemoveFilters(); // if arriving here and the results screen preparation task hasn't run, it's safe to say the user has not completed the beatmap. if (prepareScoreForDisplayTask == null) diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 94a61a4ef3..d852ac2940 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -35,6 +35,8 @@ namespace osu.Game.Screens.Play { protected const float BACKGROUND_BLUR = 15; + private const double content_out_duration = 300; + public override bool HideOverlaysOnEnter => hideOverlays; public override bool DisallowExternalBeatmapRulesetChanges => true; @@ -135,36 +137,39 @@ namespace osu.Game.Screens.Play muteWarningShownOnce = sessionStatics.GetBindable(Static.MutedAudioNotificationShownOnce); batteryWarningShownOnce = sessionStatics.GetBindable(Static.LowBatteryNotificationShownOnce); - InternalChild = (content = new LogoTrackingContainer + InternalChildren = new Drawable[] { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - RelativeSizeAxes = Axes.Both, - }).WithChildren(new Drawable[] - { - MetadataInfo = new BeatmapMetadataDisplay(Beatmap.Value, Mods, content.LogoFacade) + (content = new LogoTrackingContainer { - Alpha = 0, Anchor = Anchor.Centre, Origin = Anchor.Centre, - }, - PlayerSettings = new FillFlowContainer + RelativeSizeAxes = Axes.Both, + }).WithChildren(new Drawable[] { - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 20), - Margin = new MarginPadding(25), - Children = new PlayerSettingsGroup[] + MetadataInfo = new BeatmapMetadataDisplay(Beatmap.Value, Mods, content.LogoFacade) { - VisualSettings = new VisualSettings(), - new InputSettings() - } - }, - idleTracker = new IdleTracker(750), + Alpha = 0, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + PlayerSettings = new FillFlowContainer + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 20), + Margin = new MarginPadding(25), + Children = new PlayerSettingsGroup[] + { + VisualSettings = new VisualSettings(), + new InputSettings() + } + }, + idleTracker = new IdleTracker(750), + }), lowPassFilter = new AudioFilter(audio.TrackMixer) - }); + }; if (Beatmap.Value.BeatmapInfo.EpilepsyWarning) { @@ -195,7 +200,6 @@ namespace osu.Game.Screens.Play epilepsyWarning.DimmableBackground = b; }); - lowPassFilter.CutoffTo(500, 100, Easing.OutCubic); Beatmap.Value.Track.AddAdjustment(AdjustableProperty.Volume, volumeAdjustment); content.ScaleTo(0.7f); @@ -240,15 +244,18 @@ namespace osu.Game.Screens.Play public override bool OnExiting(IScreen next) { cancelLoad(); + contentOut(); - content.ScaleTo(0.7f, 150, Easing.InQuint); - this.FadeOut(150); + // If the load sequence was interrupted, the epilepsy warning may already be displayed (or in the process of being displayed). + epilepsyWarning?.Hide(); + + // Ensure the screen doesn't expire until all the outwards fade operations have completed. + this.Delay(content_out_duration).FadeOut(); ApplyToBackground(b => b.IgnoreUserSettings.Value = true); BackgroundBrightnessReduction = false; Beatmap.Value.Track.RemoveAdjustment(AdjustableProperty.Volume, volumeAdjustment); - lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, 100, Easing.InCubic); return base.OnExiting(next); } @@ -344,6 +351,7 @@ namespace osu.Game.Screens.Play content.FadeInFromZero(400); content.ScaleTo(1, 650, Easing.OutQuint).Then().Schedule(prepareNewPlayer); + lowPassFilter.CutoffTo(1000, 650, Easing.OutQuint); ApplyToBackground(b => b?.FadeColour(Color4.White, 800, Easing.OutQuint)); } @@ -353,8 +361,9 @@ namespace osu.Game.Screens.Play // Ensure the logo is no longer tracking before we scale the content content.StopTracking(); - content.ScaleTo(0.7f, 300, Easing.InQuint); - content.FadeOut(250); + content.ScaleTo(0.7f, content_out_duration * 2, Easing.OutQuint); + content.FadeOut(content_out_duration, Easing.OutQuint); + lowPassFilter.CutoffTo(AudioFilter.MAX_LOWPASS_CUTOFF, content_out_duration); } private void pushWhenLoaded() @@ -381,7 +390,7 @@ namespace osu.Game.Screens.Play contentOut(); - TransformSequence pushSequence = this.Delay(250); + TransformSequence pushSequence = this.Delay(content_out_duration); // only show if the warning was created (i.e. the beatmap needs it) // and this is not a restart of the map (the warning expires after first load). @@ -400,6 +409,11 @@ namespace osu.Game.Screens.Play }) .Delay(EpilepsyWarning.FADE_DURATION); } + else + { + // This goes hand-in-hand with the restoration of low pass filter in contentOut(). + this.TransformBindableTo(volumeAdjustment, 0, content_out_duration, Easing.OutCubic); + } pushSequence.Schedule(() => { diff --git a/osu.Game/Screens/Play/PlayerSettings/PlayerCheckbox.cs b/osu.Game/Screens/Play/PlayerSettings/PlayerCheckbox.cs index 8a4acacb24..26887327cd 100644 --- a/osu.Game/Screens/Play/PlayerSettings/PlayerCheckbox.cs +++ b/osu.Game/Screens/Play/PlayerSettings/PlayerCheckbox.cs @@ -14,7 +14,7 @@ namespace osu.Game.Screens.Play.PlayerSettings { Nub.AccentColour = colours.Yellow; Nub.GlowingAccentColour = colours.YellowLighter; - Nub.GlowColour = colours.YellowDarker; + Nub.GlowColour = colours.YellowDark; } } } diff --git a/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs b/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs index c8e281195a..9903a74043 100644 --- a/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs +++ b/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs @@ -17,7 +17,6 @@ namespace osu.Game.Screens.Play.PlayerSettings protected override Drawable CreateControl() => new Sliderbar { - Margin = new MarginPadding { Top = 5, Bottom = 5 }, RelativeSizeAxes = Axes.X }; @@ -29,7 +28,7 @@ namespace osu.Game.Screens.Play.PlayerSettings AccentColour = colours.Yellow; Nub.AccentColour = colours.Yellow; Nub.GlowingAccentColour = colours.YellowLighter; - Nub.GlowColour = colours.YellowDarker; + Nub.GlowColour = colours.YellowDark; } } } diff --git a/osu.Game/Screens/Select/BeatmapDetails.cs b/osu.Game/Screens/Select/BeatmapDetails.cs index 6ace92370c..16455940bf 100644 --- a/osu.Game/Screens/Select/BeatmapDetails.cs +++ b/osu.Game/Screens/Select/BeatmapDetails.cs @@ -29,7 +29,7 @@ namespace osu.Game.Screens.Select private const float transition_duration = 250; private readonly AdvancedStats advanced; - private readonly UserRatings ratings; + private readonly UserRatings ratingsDisplay; private readonly MetadataSection description, source, tags; private readonly Container failRetryContainer; private readonly FailRetryGraph failRetryGraph; @@ -43,6 +43,10 @@ namespace osu.Game.Screens.Select private BeatmapInfo beatmapInfo; + private APIFailTimes failTimes; + + private int[] ratings; + public BeatmapInfo BeatmapInfo { get => beatmapInfo; @@ -52,6 +56,9 @@ namespace osu.Game.Screens.Select beatmapInfo = value; + failTimes = beatmapInfo?.OnlineInfo?.FailTimes; + ratings = beatmapInfo?.BeatmapSet?.Ratings; + Scheduler.AddOnce(updateStatistics); } } @@ -110,7 +117,7 @@ namespace osu.Game.Screens.Select RelativeSizeAxes = Axes.X, Height = 134, Padding = new MarginPadding { Horizontal = spacing, Top = spacing }, - Child = ratings = new UserRatings + Child = ratingsDisplay = new UserRatings { RelativeSizeAxes = Axes.Both, }, @@ -176,7 +183,7 @@ namespace osu.Game.Screens.Select tags.Text = BeatmapInfo?.Metadata?.Tags; // metrics may have been previously fetched - if (BeatmapInfo?.BeatmapSet?.Metrics != null && BeatmapInfo?.Metrics != null) + if (ratings != null && failTimes != null) { updateMetrics(); return; @@ -201,14 +208,8 @@ namespace osu.Game.Screens.Select // the beatmap has been changed since we started the lookup. return; - var b = res.ToBeatmapInfo(rulesets); - - if (requestedBeatmap.BeatmapSet == null) - requestedBeatmap.BeatmapSet = b.BeatmapSet; - else - requestedBeatmap.BeatmapSet.Metrics = b.BeatmapSet.Metrics; - - requestedBeatmap.Metrics = b.Metrics; + ratings = res.BeatmapSet?.Ratings; + failTimes = res.FailTimes; updateMetrics(); }); @@ -232,29 +233,28 @@ namespace osu.Game.Screens.Select private void updateMetrics() { - var hasRatings = beatmapInfo?.BeatmapSet?.Metrics?.Ratings?.Any() ?? false; - var hasRetriesFails = (beatmapInfo?.Metrics?.Retries?.Any() ?? false) || (beatmapInfo?.Metrics?.Fails?.Any() ?? false); + var hasMetrics = (failTimes?.Retries?.Any() ?? false) || (failTimes?.Fails?.Any() ?? false); - if (hasRatings) + if (ratings?.Any() ?? false) { - ratings.Metrics = beatmapInfo.BeatmapSet.Metrics; - ratings.FadeIn(transition_duration); + ratingsDisplay.Ratings = ratings; + ratingsDisplay.FadeIn(transition_duration); } else { // loading or just has no data server-side. - ratings.Metrics = new BeatmapSetMetrics { Ratings = new int[10] }; - ratings.FadeTo(0.25f, transition_duration); + ratingsDisplay.Ratings = new int[10]; + ratingsDisplay.FadeTo(0.25f, transition_duration); } - if (hasRetriesFails) + if (hasMetrics) { - failRetryGraph.Metrics = beatmapInfo.Metrics; + failRetryGraph.FailTimes = failTimes; failRetryContainer.FadeIn(transition_duration); } else { - failRetryGraph.Metrics = new BeatmapMetrics + failRetryGraph.FailTimes = new APIFailTimes { Fails = new int[100], Retries = new int[100], diff --git a/osu.Game/Screens/Select/BeatmapInfoWedge.cs b/osu.Game/Screens/Select/BeatmapInfoWedge.cs index ac191a38f2..2de72beaad 100644 --- a/osu.Game/Screens/Select/BeatmapInfoWedge.cs +++ b/osu.Game/Screens/Select/BeatmapInfoWedge.cs @@ -257,6 +257,7 @@ namespace osu.Game.Screens.Select }, StatusPill = new BeatmapSetOnlineStatusPill { + AutoSizeAxes = Axes.Both, Anchor = Anchor.TopRight, Origin = Anchor.TopRight, Shear = -wedged_container_shear, diff --git a/osu.Game/Screens/Select/Carousel/SetPanelContent.cs b/osu.Game/Screens/Select/Carousel/SetPanelContent.cs index 9fb640ba1a..f2054677b0 100644 --- a/osu.Game/Screens/Select/Carousel/SetPanelContent.cs +++ b/osu.Game/Screens/Select/Carousel/SetPanelContent.cs @@ -60,6 +60,7 @@ namespace osu.Game.Screens.Select.Carousel { new BeatmapSetOnlineStatusPill { + AutoSizeAxes = Axes.Both, Origin = Anchor.CentreLeft, Anchor = Anchor.CentreLeft, Margin = new MarginPadding { Right = 5 }, diff --git a/osu.Game/Screens/Select/Details/FailRetryGraph.cs b/osu.Game/Screens/Select/Details/FailRetryGraph.cs index 7cc80acfd3..ecaf02cb30 100644 --- a/osu.Game/Screens/Select/Details/FailRetryGraph.cs +++ b/osu.Game/Screens/Select/Details/FailRetryGraph.cs @@ -16,19 +16,19 @@ namespace osu.Game.Screens.Select.Details { private readonly BarGraph retryGraph, failGraph; - private BeatmapMetrics metrics; + private APIFailTimes failTimes; - public BeatmapMetrics Metrics + public APIFailTimes FailTimes { - get => metrics; + get => failTimes; set { - if (value == metrics) return; + if (value == failTimes) return; - metrics = value; + failTimes = value; - var retries = Metrics?.Retries ?? Array.Empty(); - var fails = Metrics?.Fails ?? Array.Empty(); + var retries = FailTimes?.Retries ?? Array.Empty(); + var fails = FailTimes?.Fails ?? Array.Empty(); var retriesAndFails = sumRetriesAndFails(retries, fails); float maxValue = retriesAndFails.Any() ? retriesAndFails.Max() : 0; diff --git a/osu.Game/Screens/Select/Details/UserRatings.cs b/osu.Game/Screens/Select/Details/UserRatings.cs index eabc476db9..aa316d6e40 100644 --- a/osu.Game/Screens/Select/Details/UserRatings.cs +++ b/osu.Game/Screens/Select/Details/UserRatings.cs @@ -1,15 +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.Linq; using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; -using System.Linq; -using osu.Framework.Extensions.LocalisationExtensions; -using osu.Game.Beatmaps; using osu.Game.Resources.Localisation.Web; namespace osu.Game.Screens.Select.Details @@ -22,20 +21,20 @@ namespace osu.Game.Screens.Select.Details private readonly Container graphContainer; private readonly BarGraph graph; - private BeatmapSetMetrics metrics; + private int[] ratings; - public BeatmapSetMetrics Metrics + public int[] Ratings { - get => metrics; + get => ratings; set { - if (value == metrics) return; + if (value == ratings) return; - metrics = value; + ratings = value; const int rating_range = 10; - if (metrics == null) + if (ratings == null) { negativeRatings.Text = 0.ToLocalisableString(@"N0"); positiveRatings.Text = 0.ToLocalisableString(@"N0"); @@ -44,15 +43,15 @@ namespace osu.Game.Screens.Select.Details } else { - var ratings = Metrics.Ratings.Skip(1).Take(rating_range); // adjust for API returning weird empty data at 0. + var usableRange = Ratings.Skip(1).Take(rating_range); // adjust for API returning weird empty data at 0. - var negativeCount = ratings.Take(rating_range / 2).Sum(); - var totalCount = ratings.Sum(); + var negativeCount = usableRange.Take(rating_range / 2).Sum(); + var totalCount = usableRange.Sum(); negativeRatings.Text = negativeCount.ToLocalisableString(@"N0"); positiveRatings.Text = (totalCount - negativeCount).ToLocalisableString(@"N0"); ratingsBar.Length = totalCount == 0 ? 0 : (float)negativeCount / totalCount; - graph.Values = ratings.Take(rating_range).Select(r => (float)r); + graph.Values = usableRange.Take(rating_range).Select(r => (float)r); } } } diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index 6cafcb9d16..a2dea355ac 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -410,7 +410,7 @@ namespace osu.Game.Screens.Select { if (e.NewValue is DummyWorkingBeatmap || !this.IsCurrentScreen()) return; - Logger.Log($"working beatmap updated to {e.NewValue}"); + Logger.Log($"Song select working beatmap updated to {e.NewValue}"); if (!Carousel.SelectBeatmap(e.NewValue.BeatmapInfo, false)) { diff --git a/osu.Game/Skinning/ISkinSource.cs b/osu.Game/Skinning/ISkinSource.cs index ba3e2bf6ad..a5ed0fc990 100644 --- a/osu.Game/Skinning/ISkinSource.cs +++ b/osu.Game/Skinning/ISkinSource.cs @@ -12,6 +12,9 @@ namespace osu.Game.Skinning ///
public interface ISkinSource : ISkin { + /// + /// Fired whenever a source change occurs, signalling that consumers should re-query as required. + /// event Action SourceChanged; /// diff --git a/osu.Game/Skinning/LegacyScoreCounter.cs b/osu.Game/Skinning/LegacyScoreCounter.cs index a12defe87e..0c9a82074f 100644 --- a/osu.Game/Skinning/LegacyScoreCounter.cs +++ b/osu.Game/Skinning/LegacyScoreCounter.cs @@ -16,7 +16,6 @@ namespace osu.Game.Skinning public bool UsesFixedAnchor { get; set; } public LegacyScoreCounter() - : base(6) { Anchor = Anchor.TopRight; Origin = Anchor.TopRight; diff --git a/osu.Game/Skinning/RulesetSkinProvidingContainer.cs b/osu.Game/Skinning/RulesetSkinProvidingContainer.cs index f5a7788359..b884794739 100644 --- a/osu.Game/Skinning/RulesetSkinProvidingContainer.cs +++ b/osu.Game/Skinning/RulesetSkinProvidingContainer.cs @@ -58,10 +58,8 @@ namespace osu.Game.Skinning return base.CreateChildDependencies(parent); } - protected override void OnSourceChanged() + protected override void RefreshSources() { - ResetSources(); - // Populate a local list first so we can adjust the returned order as we go. var sources = new List(); @@ -91,8 +89,7 @@ namespace osu.Game.Skinning else sources.Add(rulesetResourcesSkin); - foreach (var skin in sources) - AddSource(skin); + SetSources(sources); } protected ISkin GetLegacyRulesetTransformedSkin(ISkin legacySkin) diff --git a/osu.Game/Skinning/SkinProvidingContainer.cs b/osu.Game/Skinning/SkinProvidingContainer.cs index ada6e4b788..c8e4c2c7b6 100644 --- a/osu.Game/Skinning/SkinProvidingContainer.cs +++ b/osu.Game/Skinning/SkinProvidingContainer.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio.Sample; @@ -40,10 +41,12 @@ namespace osu.Game.Skinning protected virtual bool AllowColourLookup => true; + private readonly object sourceSetLock = new object(); + /// /// A dictionary mapping each source to a wrapper which handles lookup allowances. /// - private readonly List<(ISkin skin, DisableableSkinSource wrapped)> skinSources = new List<(ISkin, DisableableSkinSource)>(); + private (ISkin skin, DisableableSkinSource wrapped)[] skinSources = Array.Empty<(ISkin skin, DisableableSkinSource wrapped)>(); /// /// Constructs a new initialised with a single skin source. @@ -52,7 +55,7 @@ namespace osu.Game.Skinning : this() { if (skin != null) - AddSource(skin); + SetSources(new[] { skin }); } /// @@ -168,49 +171,42 @@ namespace osu.Game.Skinning } /// - /// Add a new skin to this provider. Will be added to the end of the lookup order precedence. + /// Replace the sources used for lookups in this container. /// - /// The skin to add. - protected void AddSource(ISkin skin) + /// + /// This does not implicitly fire a event. Consider calling if required. + /// + /// The new sources. + protected void SetSources(IEnumerable sources) { - skinSources.Add((skin, new DisableableSkinSource(skin, this))); + lock (sourceSetLock) + { + foreach (var skin in skinSources) + { + if (skin.skin is ISkinSource source) + source.SourceChanged -= TriggerSourceChanged; + } - if (skin is ISkinSource source) - source.SourceChanged += TriggerSourceChanged; + skinSources = sources.Select(skin => (skin, new DisableableSkinSource(skin, this))).ToArray(); + + foreach (var skin in skinSources) + { + if (skin.skin is ISkinSource source) + source.SourceChanged += TriggerSourceChanged; + } + } } /// - /// Remove a skin from this provider. - /// - /// The skin to remove. - protected void RemoveSource(ISkin skin) - { - if (skinSources.RemoveAll(s => s.skin == skin) == 0) - return; - - if (skin is ISkinSource source) - source.SourceChanged -= TriggerSourceChanged; - } - - /// - /// Clears all skin sources. - /// - protected void ResetSources() - { - foreach (var i in skinSources.ToArray()) - RemoveSource(i.skin); - } - - /// - /// Invoked when any source has changed (either or a source registered via ). + /// Invoked after any consumed source change, before the external event is fired. /// This is also invoked once initially during to ensure sources are ready for children consumption. /// - protected virtual void OnSourceChanged() { } + protected virtual void RefreshSources() { } protected void TriggerSourceChanged() { // Expose to implementations, giving them a chance to react before notifying external consumers. - OnSourceChanged(); + RefreshSources(); SourceChanged?.Invoke(); } diff --git a/osu.Game/Skinning/SkinnableTargetContainer.cs b/osu.Game/Skinning/SkinnableTargetContainer.cs index e7125bb034..20c2fcc075 100644 --- a/osu.Game/Skinning/SkinnableTargetContainer.cs +++ b/osu.Game/Skinning/SkinnableTargetContainer.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using System.Threading; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -22,6 +23,8 @@ namespace osu.Game.Skinning public bool ComponentsLoaded { get; private set; } + private CancellationTokenSource cancellationSource; + public SkinnableTargetContainer(SkinnableTarget target) { Target = target; @@ -38,6 +41,9 @@ namespace osu.Game.Skinning content = CurrentSkin.GetDrawableComponent(new SkinnableTargetComponent(Target)) as SkinnableTargetComponentsContainer; + cancellationSource?.Cancel(); + cancellationSource = null; + if (content != null) { LoadComponentAsync(content, wrapper => @@ -45,7 +51,7 @@ namespace osu.Game.Skinning AddInternal(wrapper); components.AddRange(wrapper.Children.OfType()); ComponentsLoaded = true; - }); + }, (cancellationSource = new CancellationTokenSource()).Token); } else ComponentsLoaded = true; diff --git a/osu.Game/Stores/BeatmapImporter.cs b/osu.Game/Stores/BeatmapImporter.cs new file mode 100644 index 0000000000..254127cc7e --- /dev/null +++ b/osu.Game/Stores/BeatmapImporter.cs @@ -0,0 +1,331 @@ +// 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using NuGet.Packaging; +using osu.Framework.Audio.Track; +using osu.Framework.Extensions; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics.Textures; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Formats; +using osu.Game.Database; +using osu.Game.IO; +using osu.Game.IO.Archives; +using osu.Game.Models; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; +using osu.Game.Skinning; +using Realms; + +#nullable enable + +namespace osu.Game.Stores +{ + /// + /// Handles the storage and retrieval of Beatmaps/WorkingBeatmaps. + /// + [ExcludeFromDynamicCompile] + public class BeatmapImporter : RealmArchiveModelImporter, IDisposable + { + public override IEnumerable HandledExtensions => new[] { ".osz" }; + + protected override string[] HashableFileTypes => new[] { ".osu" }; + + // protected override bool CheckLocalAvailability(RealmBeatmapSet model, System.Linq.IQueryable items) + // => base.CheckLocalAvailability(model, items) || (model.OnlineID > -1)); + + private readonly BeatmapOnlineLookupQueue? onlineLookupQueue; + + public BeatmapImporter(RealmContextFactory contextFactory, Storage storage, BeatmapOnlineLookupQueue? onlineLookupQueue = null) + : base(storage, contextFactory) + { + this.onlineLookupQueue = onlineLookupQueue; + } + + protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path).ToLowerInvariant() == ".osz"; + + protected override Task Populate(RealmBeatmapSet beatmapSet, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default) + { + if (archive != null) + beatmapSet.Beatmaps.AddRange(createBeatmapDifficulties(beatmapSet.Files, realm)); + + foreach (RealmBeatmap b in beatmapSet.Beatmaps) + b.BeatmapSet = beatmapSet; + + validateOnlineIds(beatmapSet, realm); + + bool hadOnlineBeatmapIDs = beatmapSet.Beatmaps.Any(b => b.OnlineID > 0); + + if (onlineLookupQueue != null) + { + // TODO: this required `BeatmapOnlineLookupQueue` to somehow support new types. + // await onlineLookupQueue.UpdateAsync(beatmapSet, cancellationToken).ConfigureAwait(false); + } + + // ensure at least one beatmap was able to retrieve or keep an online ID, else drop the set ID. + if (hadOnlineBeatmapIDs && !beatmapSet.Beatmaps.Any(b => b.OnlineID > 0)) + { + if (beatmapSet.OnlineID > 0) + { + beatmapSet.OnlineID = -1; + LogForModel(beatmapSet, "Disassociating beatmap set ID due to loss of all beatmap IDs"); + } + } + + return Task.CompletedTask; + } + + protected override void PreImport(RealmBeatmapSet beatmapSet, Realm realm) + { + // We are about to import a new beatmap. Before doing so, ensure that no other set shares the online IDs used by the new one. + // Note that this means if the previous beatmap is restored by the user, it will no longer be linked to its online IDs. + // If this is ever an issue, we can consider marking as pending delete but not resetting the IDs (but care will be required for + // beatmaps, which don't have their own `DeletePending` state). + + if (beatmapSet.OnlineID > 0) + { + var existingSetWithSameOnlineID = realm.All().SingleOrDefault(b => b.OnlineID == beatmapSet.OnlineID); + + if (existingSetWithSameOnlineID != null) + { + existingSetWithSameOnlineID.DeletePending = true; + existingSetWithSameOnlineID.OnlineID = -1; + + foreach (var b in existingSetWithSameOnlineID.Beatmaps) + b.OnlineID = -1; + + LogForModel(beatmapSet, $"Found existing beatmap set with same OnlineID ({beatmapSet.OnlineID}). It will be deleted."); + } + } + } + + private void validateOnlineIds(RealmBeatmapSet beatmapSet, Realm realm) + { + var beatmapIds = beatmapSet.Beatmaps.Where(b => b.OnlineID > 0).Select(b => b.OnlineID).ToList(); + + // ensure all IDs are unique + if (beatmapIds.GroupBy(b => b).Any(g => g.Count() > 1)) + { + LogForModel(beatmapSet, "Found non-unique IDs, resetting..."); + resetIds(); + return; + } + + // find any existing beatmaps in the database that have matching online ids + List existingBeatmaps = new List(); + + foreach (var id in beatmapIds) + existingBeatmaps.AddRange(realm.All().Where(b => b.OnlineID == id)); + + if (existingBeatmaps.Any()) + { + // reset the import ids (to force a re-fetch) *unless* they match the candidate CheckForExisting set. + // we can ignore the case where the new ids are contained by the CheckForExisting set as it will either be used (import skipped) or deleted. + + var existing = CheckForExisting(beatmapSet, realm); + + if (existing == null || existingBeatmaps.Any(b => !existing.Beatmaps.Contains(b))) + { + LogForModel(beatmapSet, "Found existing import with online IDs already, resetting..."); + resetIds(); + } + } + + void resetIds() => beatmapSet.Beatmaps.ForEach(b => b.OnlineID = -1); + } + + protected override bool CanSkipImport(RealmBeatmapSet existing, RealmBeatmapSet import) + { + if (!base.CanSkipImport(existing, import)) + return false; + + return existing.Beatmaps.Any(b => b.OnlineID > 0); + } + + protected override bool CanReuseExisting(RealmBeatmapSet existing, RealmBeatmapSet import) + { + if (!base.CanReuseExisting(existing, import)) + return false; + + var existingIds = existing.Beatmaps.Select(b => b.OnlineID).OrderBy(i => i); + var importIds = import.Beatmaps.Select(b => b.OnlineID).OrderBy(i => i); + + // force re-import if we are not in a sane state. + return existing.OnlineID == import.OnlineID && existingIds.SequenceEqual(importIds); + } + + public override string HumanisedModelName => "beatmap"; + + protected override RealmBeatmapSet? CreateModel(ArchiveReader reader) + { + // let's make sure there are actually .osu files to import. + string? mapName = reader.Filenames.FirstOrDefault(f => f.EndsWith(".osu", StringComparison.OrdinalIgnoreCase)); + + if (string.IsNullOrEmpty(mapName)) + { + Logger.Log($"No beatmap files found in the beatmap archive ({reader.Name}).", LoggingTarget.Database); + return null; + } + + Beatmap beatmap; + using (var stream = new LineBufferedReader(reader.GetStream(mapName))) + beatmap = Decoder.GetDecoder(stream).Decode(stream); + + return new RealmBeatmapSet + { + OnlineID = beatmap.BeatmapInfo.BeatmapSet?.OnlineBeatmapSetID ?? -1, + // Metadata = beatmap.Metadata, + DateAdded = DateTimeOffset.UtcNow + }; + } + + /// + /// Create all required s for the provided archive. + /// + private List createBeatmapDifficulties(IList files, Realm realm) + { + var beatmaps = new List(); + + foreach (var file in files.Where(f => f.Filename.EndsWith(".osu", StringComparison.OrdinalIgnoreCase))) + { + using (var memoryStream = new MemoryStream(Files.Store.Get(file.File.StoragePath))) // we need a memory stream so we can seek + { + IBeatmap decoded; + using (var lineReader = new LineBufferedReader(memoryStream, true)) + decoded = Decoder.GetDecoder(lineReader).Decode(lineReader); + + string hash = memoryStream.ComputeSHA2Hash(); + + if (beatmaps.Any(b => b.Hash == hash)) + { + Logger.Log($"Skipping import of {file.Filename} due to duplicate file content.", LoggingTarget.Database); + continue; + } + + var decodedInfo = decoded.BeatmapInfo; + var decodedDifficulty = decodedInfo.BaseDifficulty; + + var ruleset = realm.All().FirstOrDefault(r => r.OnlineID == decodedInfo.RulesetID); + + if (ruleset?.Available != true) + { + Logger.Log($"Skipping import of {file.Filename} due to missing local ruleset {decodedInfo.RulesetID}.", LoggingTarget.Database); + continue; + } + + var difficulty = new RealmBeatmapDifficulty + { + DrainRate = decodedDifficulty.DrainRate, + CircleSize = decodedDifficulty.CircleSize, + OverallDifficulty = decodedDifficulty.OverallDifficulty, + ApproachRate = decodedDifficulty.ApproachRate, + SliderMultiplier = decodedDifficulty.SliderMultiplier, + SliderTickRate = decodedDifficulty.SliderTickRate, + }; + + var metadata = new RealmBeatmapMetadata + { + Title = decoded.Metadata.Title, + TitleUnicode = decoded.Metadata.TitleUnicode, + Artist = decoded.Metadata.Artist, + ArtistUnicode = decoded.Metadata.ArtistUnicode, + Author = decoded.Metadata.AuthorString, + Source = decoded.Metadata.Source, + Tags = decoded.Metadata.Tags, + PreviewTime = decoded.Metadata.PreviewTime, + AudioFile = decoded.Metadata.AudioFile, + BackgroundFile = decoded.Metadata.BackgroundFile, + }; + + var beatmap = new RealmBeatmap(ruleset, difficulty, metadata) + { + Hash = hash, + DifficultyName = decodedInfo.Version, + OnlineID = decodedInfo.OnlineBeatmapID ?? -1, + AudioLeadIn = decodedInfo.AudioLeadIn, + StackLeniency = decodedInfo.StackLeniency, + SpecialStyle = decodedInfo.SpecialStyle, + LetterboxInBreaks = decodedInfo.LetterboxInBreaks, + WidescreenStoryboard = decodedInfo.WidescreenStoryboard, + EpilepsyWarning = decodedInfo.EpilepsyWarning, + SamplesMatchPlaybackRate = decodedInfo.SamplesMatchPlaybackRate, + DistanceSpacing = decodedInfo.DistanceSpacing, + BeatDivisor = decodedInfo.BeatDivisor, + GridSize = decodedInfo.GridSize, + TimelineZoom = decodedInfo.TimelineZoom, + MD5Hash = memoryStream.ComputeMD5Hash(), + }; + + updateBeatmapStatistics(beatmap, decoded); + + beatmaps.Add(beatmap); + } + } + + return beatmaps; + } + + private void updateBeatmapStatistics(RealmBeatmap beatmap, IBeatmap decoded) + { + var rulesetInstance = ((IRulesetInfo)beatmap.Ruleset).CreateInstance(); + + if (rulesetInstance == null) + return; + + decoded.BeatmapInfo.Ruleset = rulesetInstance.RulesetInfo; + + // TODO: this should be done in a better place once we actually need to dynamically update it. + beatmap.StarRating = rulesetInstance.CreateDifficultyCalculator(new DummyConversionBeatmap(decoded)).Calculate().StarRating; + beatmap.Length = calculateLength(decoded); + beatmap.BPM = 60000 / decoded.GetMostCommonBeatLength(); + } + + 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; + } + + public void Dispose() + { + onlineLookupQueue?.Dispose(); + } + + /// + /// A dummy WorkingBeatmap for the purpose of retrieving a beatmap for star difficulty calculation. + /// + private class DummyConversionBeatmap : WorkingBeatmap + { + private readonly IBeatmap beatmap; + + public DummyConversionBeatmap(IBeatmap beatmap) + : base(beatmap.BeatmapInfo, null) + { + this.beatmap = beatmap; + } + + protected override IBeatmap GetBeatmap() => beatmap; + protected override Texture? GetBackground() => null; + protected override Track? GetBeatmapTrack() => null; + protected internal override ISkin? GetSkin() => null; + public override Stream? GetStream(string storagePath) => null; + } + } +} diff --git a/osu.Game/Stores/RealmArchiveModelImporter.cs b/osu.Game/Stores/RealmArchiveModelImporter.cs new file mode 100644 index 0000000000..ec454d25fa --- /dev/null +++ b/osu.Game/Stores/RealmArchiveModelImporter.cs @@ -0,0 +1,550 @@ +// 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using Humanizer; +using NuGet.Packaging; +using osu.Framework.Extensions; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Threading; +using osu.Game.Database; +using osu.Game.IO.Archives; +using osu.Game.Models; +using osu.Game.Overlays.Notifications; +using Realms; + +#nullable enable + +namespace osu.Game.Stores +{ + /// + /// Encapsulates a model store class to give it import functionality. + /// Adds cross-functionality with to give access to the central file store for the provided model. + /// + /// The model type. + public abstract class RealmArchiveModelImporter : IModelImporter + where TModel : RealmObject, IHasRealmFiles, IHasGuidPrimaryKey, ISoftDelete + { + private const int import_queue_request_concurrency = 1; + + /// + /// The size of a batch import operation before considering it a lower priority operation. + /// + private const int low_priority_import_batch_size = 1; + + /// + /// A singleton scheduler shared by all . + /// + /// + /// This scheduler generally performs IO and CPU intensive work so concurrency is limited harshly. + /// It is mainly being used as a queue mechanism for large imports. + /// + private static readonly ThreadedTaskScheduler import_scheduler = new ThreadedTaskScheduler(import_queue_request_concurrency, nameof(RealmArchiveModelImporter)); + + /// + /// A second scheduler for lower priority imports. + /// For simplicity, these will just run in parallel with normal priority imports, but a future refactor would see this implemented via a custom scheduler/queue. + /// See https://gist.github.com/peppy/f0e118a14751fc832ca30dd48ba3876b for an incomplete version of this. + /// + private static readonly ThreadedTaskScheduler import_scheduler_low_priority = new ThreadedTaskScheduler(import_queue_request_concurrency, nameof(RealmArchiveModelImporter)); + + public virtual IEnumerable HandledExtensions => new[] { @".zip" }; + + protected readonly RealmFileStore Files; + + protected readonly RealmContextFactory ContextFactory; + + /// + /// Fired when the user requests to view the resulting import. + /// + public Action>>? PostImport { get; set; } + + /// + /// Set an endpoint for notifications to be posted to. + /// + public Action? PostNotification { protected get; set; } + + protected RealmArchiveModelImporter(Storage storage, RealmContextFactory contextFactory) + { + ContextFactory = contextFactory; + + Files = new RealmFileStore(contextFactory, storage); + } + + /// + /// Import one or more items from filesystem . + /// + /// + /// This will be treated as a low priority import if more than one path is specified; use to always import at standard priority. + /// This will post notifications tracking progress. + /// + /// One or more archive locations on disk. + public Task Import(params string[] paths) + { + var notification = new ProgressNotification { State = ProgressNotificationState.Active }; + + PostNotification?.Invoke(notification); + + return Import(notification, paths.Select(p => new ImportTask(p)).ToArray()); + } + + public Task Import(params ImportTask[] tasks) + { + var notification = new ProgressNotification { State = ProgressNotificationState.Active }; + + PostNotification?.Invoke(notification); + + return Import(notification, tasks); + } + + public async Task>> Import(ProgressNotification notification, params ImportTask[] tasks) + { + if (tasks.Length == 0) + { + notification.CompletionText = $"No {HumanisedModelName}s were found to import!"; + notification.State = ProgressNotificationState.Completed; + return Enumerable.Empty>(); + } + + notification.Progress = 0; + notification.Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import is initialising..."; + + int current = 0; + + var imported = new List>(); + + bool isLowPriorityImport = tasks.Length > low_priority_import_batch_size; + + try + { + await Task.WhenAll(tasks.Select(async task => + { + notification.CancellationToken.ThrowIfCancellationRequested(); + + try + { + var model = await Import(task, isLowPriorityImport, notification.CancellationToken).ConfigureAwait(false); + + lock (imported) + { + if (model != null) + imported.Add(model); + current++; + + notification.Text = $"Imported {current} of {tasks.Length} {HumanisedModelName}s"; + notification.Progress = (float)current / tasks.Length; + } + } + catch (TaskCanceledException) + { + throw; + } + catch (Exception e) + { + Logger.Error(e, $@"Could not import ({task})", LoggingTarget.Database); + } + })).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + if (imported.Count == 0) + { + notification.State = ProgressNotificationState.Cancelled; + return imported; + } + } + + if (imported.Count == 0) + { + notification.Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import failed!"; + notification.State = ProgressNotificationState.Cancelled; + } + else + { + notification.CompletionText = imported.Count == 1 + ? $"Imported {imported.First()}!" + : $"Imported {imported.Count} {HumanisedModelName}s!"; + + if (imported.Count > 0 && PostImport != null) + { + notification.CompletionText += " Click to view."; + notification.CompletionClickAction = () => + { + PostImport?.Invoke(imported); + return true; + }; + } + + notification.State = ProgressNotificationState.Completed; + } + + return imported; + } + + /// + /// Import one from the filesystem and delete the file on success. + /// Note that this bypasses the UI flow and should only be used for special cases or testing. + /// + /// The containing data about the to import. + /// Whether this is a low priority import. + /// An optional cancellation token. + /// The imported model, if successful. + public async Task?> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + ILive? import; + using (ArchiveReader reader = task.GetReader()) + import = await Import(reader, lowPriority, cancellationToken).ConfigureAwait(false); + + // We may or may not want to delete the file depending on where it is stored. + // e.g. reconstructing/repairing database with items from default storage. + // Also, not always a single file, i.e. for LegacyFilesystemReader + // TODO: Add a check to prevent files from storage to be deleted. + try + { + if (import != null && File.Exists(task.Path) && ShouldDeleteArchive(task.Path)) + File.Delete(task.Path); + } + catch (Exception e) + { + Logger.Error(e, $@"Could not delete original file after import ({task})"); + } + + return import; + } + + /// + /// Silently import an item from an . + /// + /// The archive to be imported. + /// Whether this is a low priority import. + /// An optional cancellation token. + public async Task?> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + TModel? model = null; + + try + { + model = CreateModel(archive); + + if (model == null) + return null; + } + catch (TaskCanceledException) + { + throw; + } + catch (Exception e) + { + LogForModel(model, @$"Model creation of {archive.Name} failed.", e); + return null; + } + + var scheduledImport = Task.Factory.StartNew(async () => await Import(model, archive, lowPriority, cancellationToken).ConfigureAwait(false), + cancellationToken, TaskCreationOptions.HideScheduler, lowPriority ? import_scheduler_low_priority : import_scheduler).Unwrap(); + + return await scheduledImport.ConfigureAwait(true); + } + + /// + /// Any file extensions which should be included in hash creation. + /// Generally should include all file types which determine the file's uniqueness. + /// Large files should be avoided if possible. + /// + /// + /// This is only used by the default hash implementation. If is overridden, it will not be used. + /// + protected abstract string[] HashableFileTypes { get; } + + internal static void LogForModel(TModel? model, string message, Exception? e = null) + { + string trimmedHash; + if (model == null || !model.IsValid || string.IsNullOrEmpty(model.Hash)) + trimmedHash = "?????"; + else + trimmedHash = model.Hash.Substring(0, 5); + + string prefix = $"[{trimmedHash}]"; + + if (e != null) + Logger.Error(e, $"{prefix} {message}", LoggingTarget.Database); + else + Logger.Log($"{prefix} {message}", LoggingTarget.Database); + } + + /// + /// Whether the implementation overrides with a custom implementation. + /// Custom hash implementations must bypass the early exit in the import flow (see usage). + /// + protected virtual bool HasCustomHashFunction => false; + + /// + /// Create a SHA-2 hash from the provided archive based on file content of all files matching . + /// + /// + /// In the case of no matching files, a hash will be generated from the passed archive's . + /// + protected virtual string ComputeHash(TModel item, ArchiveReader? reader = null) + { + if (reader != null) + // fast hashing for cases where the item's files may not be populated. + return computeHashFast(reader); + + // for now, concatenate all hashable files in the set to create a unique hash. + MemoryStream hashable = new MemoryStream(); + + foreach (RealmNamedFileUsage file in item.Files.Where(f => HashableFileTypes.Any(ext => f.Filename.EndsWith(ext, StringComparison.OrdinalIgnoreCase))).OrderBy(f => f.Filename)) + { + using (Stream s = Files.Store.GetStream(file.File.StoragePath)) + s.CopyTo(hashable); + } + + if (hashable.Length > 0) + return hashable.ComputeSHA2Hash(); + + return item.Hash; + } + + /// + /// Silently import an item from a . + /// + /// The model to be imported. + /// An optional archive to use for model population. + /// Whether this is a low priority import. + /// An optional cancellation token. + public virtual async Task?> Import(TModel item, ArchiveReader? archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) + { + using (var realm = ContextFactory.CreateContext()) + { + cancellationToken.ThrowIfCancellationRequested(); + + bool checkedExisting = false; + TModel? existing = null; + + if (archive != null && !HasCustomHashFunction) + { + // this is a fast bail condition to improve large import performance. + item.Hash = computeHashFast(archive); + + checkedExisting = true; + existing = CheckForExisting(item, realm); + + if (existing != null) + { + // bare minimum comparisons + // + // note that this should really be checking filesizes on disk (of existing files) for some degree of sanity. + // or alternatively doing a faster hash check. either of these require database changes and reprocessing of existing files. + if (CanSkipImport(existing, item) && + getFilenames(existing.Files).SequenceEqual(getShortenedFilenames(archive).Select(p => p.shortened).OrderBy(f => f))) + { + LogForModel(item, @$"Found existing (optimised) {HumanisedModelName} for {item} (ID {existing.ID}) – skipping import."); + + using (var transaction = realm.BeginWrite()) + { + existing.DeletePending = false; + transaction.Commit(); + } + + return existing.ToLive(); + } + + LogForModel(item, @"Found existing (optimised) but failed pre-check."); + } + } + + try + { + LogForModel(item, @"Beginning import..."); + + // TODO: do we want to make the transaction this local? not 100% sure, will need further investigation. + using (var transaction = realm.BeginWrite()) + { + if (archive != null) + // TODO: look into rollback of file additions (or delayed commit). + item.Files.AddRange(createFileInfos(archive, Files, realm)); + + item.Hash = ComputeHash(item, archive); + + // TODO: we may want to run this outside of the transaction. + await Populate(item, archive, realm, cancellationToken).ConfigureAwait(false); + + if (!checkedExisting) + existing = CheckForExisting(item, realm); + + if (existing != null) + { + if (CanReuseExisting(existing, item)) + { + LogForModel(item, @$"Found existing {HumanisedModelName} for {item} (ID {existing.ID}) – skipping import."); + existing.DeletePending = false; + + return existing.ToLive(); + } + + LogForModel(item, @"Found existing but failed re-use check."); + + existing.DeletePending = true; + + // todo: actually delete? i don't think this is required... + // ModelStore.PurgeDeletable(s => s.ID == existing.ID); + } + + PreImport(item, realm); + + // import to store + realm.Add(item); + + transaction.Commit(); + } + + LogForModel(item, @"Import successfully completed!"); + } + catch (Exception e) + { + if (!(e is TaskCanceledException)) + LogForModel(item, @"Database import or population failed and has been rolled back.", e); + + throw; + } + + return item.ToLive(); + } + } + + private string computeHashFast(ArchiveReader reader) + { + MemoryStream hashable = new MemoryStream(); + + foreach (var file in reader.Filenames.Where(f => HashableFileTypes.Any(ext => f.EndsWith(ext, StringComparison.OrdinalIgnoreCase))).OrderBy(f => f)) + { + using (Stream s = reader.GetStream(file)) + s.CopyTo(hashable); + } + + if (hashable.Length > 0) + return hashable.ComputeSHA2Hash(); + + return reader.Name.ComputeSHA2Hash(); + } + + /// + /// Create all required s for the provided archive, adding them to the global file store. + /// + private List createFileInfos(ArchiveReader reader, RealmFileStore files, Realm realm) + { + var fileInfos = new List(); + + // import files to manager + foreach (var filenames in getShortenedFilenames(reader)) + { + using (Stream s = reader.GetStream(filenames.original)) + { + var item = new RealmNamedFileUsage(files.Add(s, realm), filenames.shortened); + fileInfos.Add(item); + } + } + + return fileInfos; + } + + private IEnumerable<(string original, string shortened)> getShortenedFilenames(ArchiveReader reader) + { + string prefix = reader.Filenames.GetCommonPrefix(); + if (!(prefix.EndsWith('/') || prefix.EndsWith('\\'))) + prefix = string.Empty; + + // import files to manager + foreach (string file in reader.Filenames) + yield return (file, file.Substring(prefix.Length).ToStandardisedPath()); + } + + /// + /// Create a barebones model from the provided archive. + /// Actual expensive population should be done in ; this should just prepare for duplicate checking. + /// + /// The archive to create the model for. + /// A model populated with minimal information. Returning a null will abort importing silently. + protected abstract TModel? CreateModel(ArchiveReader archive); + + /// + /// Populate the provided model completely from the given archive. + /// After this method, the model should be in a state ready to commit to a store. + /// + /// The model to populate. + /// The archive to use as a reference for population. May be null. + /// The current realm context. + /// An optional cancellation token. + protected abstract Task Populate(TModel model, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default); + + /// + /// Perform any final actions before the import to database executes. + /// + /// The model prepared for import. + /// The current realm context. + protected virtual void PreImport(TModel model, Realm realm) + { + } + + /// + /// Check whether an existing model already exists for a new import item. + /// + /// The new model proposed for import. + /// The current realm context. + /// An existing model which matches the criteria to skip importing, else null. + protected TModel? CheckForExisting(TModel model, Realm realm) => string.IsNullOrEmpty(model.Hash) ? null : realm.All().FirstOrDefault(b => b.Hash == model.Hash); + + /// + /// Whether import can be skipped after finding an existing import early in the process. + /// Only valid when is not overridden. + /// + /// The existing model. + /// The newly imported model. + /// Whether to skip this import completely. + protected virtual bool CanSkipImport(TModel existing, TModel import) => true; + + /// + /// After an existing is found during an import process, the default behaviour is to use/restore the existing + /// item and skip the import. This method allows changing that behaviour. + /// + /// The existing model. + /// The newly imported model. + /// Whether the existing model should be restored and used. Returning false will delete the existing and force a re-import. + protected virtual bool CanReuseExisting(TModel existing, TModel import) => + // for the best or worst, we copy and import files of a new import before checking whether + // it is a duplicate. so to check if anything has changed, we can just compare all File IDs. + getIDs(existing.Files).SequenceEqual(getIDs(import.Files)) && + getFilenames(existing.Files).SequenceEqual(getFilenames(import.Files)); + + /// + /// Whether this specified path should be removed after successful import. + /// + /// The path for consideration. May be a file or a directory. + /// Whether to perform deletion. + protected virtual bool ShouldDeleteArchive(string path) => false; + + private IEnumerable getIDs(IEnumerable files) + { + foreach (var f in files.OrderBy(f => f.Filename)) + yield return f.File.Hash; + } + + private IEnumerable getFilenames(IEnumerable files) + { + foreach (var f in files.OrderBy(f => f.Filename)) + yield return f.Filename; + } + + public virtual string HumanisedModelName => $"{typeof(TModel).Name.Replace(@"Info", "").ToLower()}"; + } +} diff --git a/osu.Game/Stores/RealmFileStore.cs b/osu.Game/Stores/RealmFileStore.cs new file mode 100644 index 0000000000..f7b7471634 --- /dev/null +++ b/osu.Game/Stores/RealmFileStore.cs @@ -0,0 +1,116 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.IO; +using System.Linq; +using osu.Framework.Extensions; +using osu.Framework.IO.Stores; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Database; +using osu.Game.Models; +using Realms; + +#nullable enable + +namespace osu.Game.Stores +{ + /// + /// Handles the storing of files to the file system (and database) backing. + /// + [ExcludeFromDynamicCompile] + public class RealmFileStore + { + private readonly RealmContextFactory realmFactory; + + public readonly IResourceStore Store; + + public readonly Storage Storage; + + public RealmFileStore(RealmContextFactory realmFactory, Storage storage) + { + this.realmFactory = realmFactory; + + Storage = storage.GetStorageForDirectory(@"files"); + Store = new StorageBackedResourceStore(Storage); + } + + /// + /// Add a new file to the game-wide database, copying it to permanent storage if not already present. + /// + /// The file data stream. + /// The realm instance to add to. Should already be in a transaction. + /// + public RealmFile Add(Stream data, Realm realm) + { + string hash = data.ComputeSHA2Hash(); + + var existing = realm.Find(hash); + + var file = existing ?? new RealmFile { Hash = hash }; + + if (!checkFileExistsAndMatchesHash(file)) + copyToStore(file, data); + + if (!file.IsManaged) + realm.Add(file); + + return file; + } + + private void copyToStore(RealmFile file, Stream data) + { + data.Seek(0, SeekOrigin.Begin); + + using (var output = Storage.GetStream(file.StoragePath, FileAccess.Write)) + data.CopyTo(output); + + data.Seek(0, SeekOrigin.Begin); + } + + private bool checkFileExistsAndMatchesHash(RealmFile file) + { + string path = file.StoragePath; + + // we may be re-adding a file to fix missing store entries. + if (!Storage.Exists(path)) + return false; + + // even if the file already exists, check the existing checksum for safety. + using (var stream = Storage.GetStream(path)) + return stream.ComputeSHA2Hash() == file.Hash; + } + + public void Cleanup() + { + var realm = realmFactory.Context; + + // can potentially be run asynchronously, although we will need to consider operation order for disk deletion vs realm removal. + using (var transaction = realm.BeginWrite()) + { + // TODO: consider using a realm native query to avoid iterating all files (https://github.com/realm/realm-dotnet/issues/2659#issuecomment-927823707) + var files = realm.All().ToList(); + + foreach (var file in files) + { + if (file.BacklinksCount > 0) + continue; + + try + { + Storage.Delete(file.StoragePath); + realm.Remove(file); + } + catch (Exception e) + { + Logger.Error(e, $@"Could not delete databased file {file.Hash}"); + } + } + + transaction.Commit(); + } + } + } +} diff --git a/osu.Game/Stores/RealmRulesetStore.cs b/osu.Game/Stores/RealmRulesetStore.cs new file mode 100644 index 0000000000..27eb5d797f --- /dev/null +++ b/osu.Game/Stores/RealmRulesetStore.cs @@ -0,0 +1,263 @@ +// 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.Linq; +using System.Reflection; +using osu.Framework; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.Database; +using osu.Game.Models; +using osu.Game.Rulesets; + +#nullable enable + +namespace osu.Game.Stores +{ + public class RealmRulesetStore : IDisposable + { + private readonly RealmContextFactory realmFactory; + + private const string ruleset_library_prefix = @"osu.Game.Rulesets"; + + private readonly Dictionary loadedAssemblies = new Dictionary(); + + /// + /// All available rulesets. + /// + public IEnumerable AvailableRulesets => availableRulesets; + + private readonly List availableRulesets = new List(); + + public RealmRulesetStore(RealmContextFactory realmFactory, Storage? storage = null) + { + this.realmFactory = realmFactory; + + // On android in release configuration assemblies are loaded from the apk directly into memory. + // We cannot read assemblies from cwd, so should check loaded assemblies instead. + loadFromAppDomain(); + + // This null check prevents Android from attempting to load the rulesets from disk, + // as the underlying path "AppContext.BaseDirectory", despite being non-nullable, it returns null on android. + // See https://github.com/xamarin/xamarin-android/issues/3489. + if (RuntimeInfo.StartupDirectory != null) + loadFromDisk(); + + // the event handler contains code for resolving dependency on the game assembly for rulesets located outside the base game directory. + // It needs to be attached to the assembly lookup event before the actual call to loadUserRulesets() else rulesets located out of the base game directory will fail + // to load as unable to locate the game core assembly. + AppDomain.CurrentDomain.AssemblyResolve += resolveRulesetDependencyAssembly; + + var rulesetStorage = storage?.GetStorageForDirectory(@"rulesets"); + if (rulesetStorage != null) + loadUserRulesets(rulesetStorage); + + addMissingRulesets(); + } + + /// + /// Retrieve a ruleset using a known ID. + /// + /// The ruleset's internal ID. + /// A ruleset, if available, else null. + public IRulesetInfo? GetRuleset(int id) => AvailableRulesets.FirstOrDefault(r => r.OnlineID == id); + + /// + /// Retrieve a ruleset using a known short name. + /// + /// The ruleset's short name. + /// A ruleset, if available, else null. + public IRulesetInfo? GetRuleset(string shortName) => AvailableRulesets.FirstOrDefault(r => r.ShortName == shortName); + + private Assembly? resolveRulesetDependencyAssembly(object? sender, ResolveEventArgs args) + { + var asm = new AssemblyName(args.Name); + + // the requesting assembly may be located out of the executable's base directory, thus requiring manual resolving of its dependencies. + // this attempts resolving the ruleset dependencies on game core and framework assemblies by returning assemblies with the same assembly name + // already loaded in the AppDomain. + var domainAssembly = AppDomain.CurrentDomain.GetAssemblies() + // Given name is always going to be equally-or-more qualified than the assembly name. + .Where(a => + { + string? name = a.GetName().Name; + if (name == null) + return false; + + return args.Name.Contains(name, StringComparison.Ordinal); + }) + // Pick the greatest assembly version. + .OrderByDescending(a => a.GetName().Version) + .FirstOrDefault(); + + if (domainAssembly != null) + return domainAssembly; + + return loadedAssemblies.Keys.FirstOrDefault(a => a.FullName == asm.FullName); + } + + private void addMissingRulesets() + { + realmFactory.Context.Write(realm => + { + var rulesets = realm.All(); + + List instances = loadedAssemblies.Values + .Select(r => Activator.CreateInstance(r) as Ruleset) + .Where(r => r != null) + .Select(r => r.AsNonNull()) + .ToList(); + + // add all legacy rulesets first to ensure they have exclusive choice of primary key. + foreach (var r in instances.Where(r => r is ILegacyRuleset)) + { + if (realm.All().FirstOrDefault(rr => rr.OnlineID == r.RulesetInfo.ID) == null) + realm.Add(new RealmRuleset(r.RulesetInfo.ShortName, r.RulesetInfo.Name, r.RulesetInfo.InstantiationInfo, r.RulesetInfo.ID)); + } + + // add any other rulesets which have assemblies present but are not yet in the database. + foreach (var r in instances.Where(r => !(r is ILegacyRuleset))) + { + if (rulesets.FirstOrDefault(ri => ri.InstantiationInfo.Equals(r.RulesetInfo.InstantiationInfo, StringComparison.Ordinal)) == null) + { + var existingSameShortName = rulesets.FirstOrDefault(ri => ri.ShortName == r.RulesetInfo.ShortName); + + if (existingSameShortName != null) + { + // even if a matching InstantiationInfo was not found, there may be an existing ruleset with the same ShortName. + // this generally means the user or ruleset provider has renamed their dll but the underlying ruleset is *likely* the same one. + // in such cases, update the instantiation info of the existing entry to point to the new one. + existingSameShortName.InstantiationInfo = r.RulesetInfo.InstantiationInfo; + } + else + realm.Add(new RealmRuleset(r.RulesetInfo.ShortName, r.RulesetInfo.Name, r.RulesetInfo.InstantiationInfo, r.RulesetInfo.ID)); + } + } + + List detachedRulesets = new List(); + + // perform a consistency check and detach final rulesets from realm for cross-thread runtime usage. + foreach (var r in rulesets) + { + try + { + var type = Type.GetType(r.InstantiationInfo); + + if (type == null) + throw new InvalidOperationException(@"Type resolution failure."); + + var rInstance = (Activator.CreateInstance(type) as Ruleset)?.RulesetInfo; + + if (rInstance == null) + throw new InvalidOperationException(@"Instantiation failure."); + + r.Name = rInstance.Name; + r.ShortName = rInstance.ShortName; + r.InstantiationInfo = rInstance.InstantiationInfo; + r.Available = true; + + detachedRulesets.Add(r.Clone()); + } + catch (Exception ex) + { + r.Available = false; + Logger.Log($"Could not load ruleset {r}: {ex.Message}"); + } + } + + availableRulesets.AddRange(detachedRulesets); + }); + } + + private void loadFromAppDomain() + { + foreach (var ruleset in AppDomain.CurrentDomain.GetAssemblies()) + { + string? rulesetName = ruleset.GetName().Name; + + if (rulesetName == null) + continue; + + if (!rulesetName.StartsWith(ruleset_library_prefix, StringComparison.InvariantCultureIgnoreCase) || rulesetName.Contains(@"Tests")) + continue; + + addRuleset(ruleset); + } + } + + private void loadUserRulesets(Storage rulesetStorage) + { + var rulesets = rulesetStorage.GetFiles(@".", @$"{ruleset_library_prefix}.*.dll"); + + foreach (var ruleset in rulesets.Where(f => !f.Contains(@"Tests"))) + loadRulesetFromFile(rulesetStorage.GetFullPath(ruleset)); + } + + private void loadFromDisk() + { + try + { + var files = Directory.GetFiles(RuntimeInfo.StartupDirectory, @$"{ruleset_library_prefix}.*.dll"); + + foreach (string file in files.Where(f => !Path.GetFileName(f).Contains("Tests"))) + loadRulesetFromFile(file); + } + catch (Exception e) + { + Logger.Error(e, $"Could not load rulesets from directory {RuntimeInfo.StartupDirectory}"); + } + } + + private void loadRulesetFromFile(string file) + { + var filename = Path.GetFileNameWithoutExtension(file); + + if (loadedAssemblies.Values.Any(t => Path.GetFileNameWithoutExtension(t.Assembly.Location) == filename)) + return; + + try + { + addRuleset(Assembly.LoadFrom(file)); + } + catch (Exception e) + { + Logger.Error(e, $"Failed to load ruleset {filename}"); + } + } + + private void addRuleset(Assembly assembly) + { + if (loadedAssemblies.ContainsKey(assembly)) + return; + + // the same assembly may be loaded twice in the same AppDomain (currently a thing in certain Rider versions https://youtrack.jetbrains.com/issue/RIDER-48799). + // as a failsafe, also compare by FullName. + if (loadedAssemblies.Any(a => a.Key.FullName == assembly.FullName)) + return; + + try + { + loadedAssemblies[assembly] = assembly.GetTypes().First(t => t.IsPublic && t.IsSubclassOf(typeof(Ruleset))); + } + catch (Exception e) + { + Logger.Error(e, $"Failed to add ruleset {assembly}"); + } + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + AppDomain.CurrentDomain.AssemblyResolve -= resolveRulesetDependencyAssembly; + } + } +} diff --git a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs index 64f1ee4a7a..6d63525011 100644 --- a/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs +++ b/osu.Game/Tests/Beatmaps/BeatmapConversionTest.cs @@ -14,6 +14,7 @@ using osu.Framework.Graphics.Textures; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; using osu.Game.IO; +using osu.Game.IO.Serialization; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; @@ -109,6 +110,8 @@ namespace osu.Game.Tests.Beatmaps { var beatmap = GetBeatmap(name); + string beforeConversion = beatmap.Serialize(); + var converterResult = new Dictionary>(); var working = new ConversionWorkingBeatmap(beatmap) @@ -122,6 +125,10 @@ namespace osu.Game.Tests.Beatmaps working.GetPlayableBeatmap(CreateRuleset().RulesetInfo, mods); + string afterConversion = beatmap.Serialize(); + + Assert.AreEqual(beforeConversion, afterConversion, "Conversion altered original beatmap"); + return new ConvertResult { Mappings = converterResult.Select(r => diff --git a/osu.Game/Tests/Beatmaps/TestBeatmap.cs b/osu.Game/Tests/Beatmaps/TestBeatmap.cs index 2717146c99..15b72ce6e3 100644 --- a/osu.Game/Tests/Beatmaps/TestBeatmap.cs +++ b/osu.Game/Tests/Beatmaps/TestBeatmap.cs @@ -8,6 +8,7 @@ using System.Text; using osu.Framework.Extensions; using osu.Game.Beatmaps; using osu.Game.IO; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; using Decoder = osu.Game.Beatmaps.Formats.Decoder; @@ -31,8 +32,8 @@ namespace osu.Game.Tests.Beatmaps BeatmapInfo.BeatmapSet.Metadata = BeatmapInfo.Metadata; BeatmapInfo.BeatmapSet.Beatmaps = new List { BeatmapInfo }; BeatmapInfo.Length = 75000; - BeatmapInfo.OnlineInfo = new BeatmapOnlineInfo(); - BeatmapInfo.BeatmapSet.OnlineInfo = new BeatmapSetOnlineInfo + BeatmapInfo.OnlineInfo = new APIBeatmap(); + BeatmapInfo.BeatmapSet.OnlineInfo = new APIBeatmapSet { Status = BeatmapSetOnlineStatus.Ranked, Covers = new BeatmapSetOnlineCovers diff --git a/osu.Game/Tests/Visual/EditorClockTestScene.cs b/osu.Game/Tests/Visual/EditorClockTestScene.cs index 34393fba7d..c2e9892735 100644 --- a/osu.Game/Tests/Visual/EditorClockTestScene.cs +++ b/osu.Game/Tests/Visual/EditorClockTestScene.cs @@ -5,7 +5,6 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Input.Events; using osu.Game.Beatmaps; -using osu.Game.Beatmaps.ControlPoints; using osu.Game.Screens.Edit; namespace osu.Game.Tests.Visual @@ -23,7 +22,7 @@ namespace osu.Game.Tests.Visual protected EditorClockTestScene() { - Clock = new EditorClock(new ControlPointInfo(), BeatDivisor) { IsCoupled = false }; + Clock = new EditorClock(new Beatmap(), BeatDivisor) { IsCoupled = false }; } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) @@ -44,7 +43,7 @@ namespace osu.Game.Tests.Visual private void beatmapChanged(ValueChangedEvent e) { - Clock.ControlPointInfo = e.NewValue.Beatmap.ControlPointInfo; + Clock.Beatmap = e.NewValue.Beatmap; Clock.ChangeSource(e.NewValue.Track); Clock.ProcessFrame(); } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 2c0ca0b872..5e4e5942d9 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -53,7 +53,8 @@ namespace osu.Game.Tests.Visual.Multiplayer public MultiplayerRoomUser AddUser(User user, bool markAsPlaying = false) { var roomUser = new MultiplayerRoomUser(user.Id) { User = user }; - ((IMultiplayerClient)this).UserJoined(roomUser); + + addUser(roomUser); if (markAsPlaying) PlayingUserIds.Add(user.Id); @@ -61,7 +62,15 @@ namespace osu.Game.Tests.Visual.Multiplayer return roomUser; } - public void AddNullUser() => ((IMultiplayerClient)this).UserJoined(new MultiplayerRoomUser(TestUserLookupCache.NULL_USER_ID)); + public void AddNullUser() => addUser(new MultiplayerRoomUser(TestUserLookupCache.NULL_USER_ID)); + + private void addUser(MultiplayerRoomUser user) + { + ((IMultiplayerClient)this).UserJoined(user).Wait(); + + // We want the user to be immediately available for testing, so force a scheduler update to run the update-bound continuation. + Scheduler.Update(); + } public void RemoveUser(User user) { diff --git a/osu.Game/Tests/Visual/OsuGameTestScene.cs b/osu.Game/Tests/Visual/OsuGameTestScene.cs index 77db697cb6..6a11bd3fea 100644 --- a/osu.Game/Tests/Visual/OsuGameTestScene.cs +++ b/osu.Game/Tests/Visual/OsuGameTestScene.cs @@ -78,9 +78,11 @@ namespace osu.Game.Tests.Visual protected void CreateGame() { - AddGame(Game = new TestOsuGame(LocalStorage, API)); + AddGame(Game = CreateTestGame()); } + protected virtual TestOsuGame CreateTestGame() => new TestOsuGame(LocalStorage, API); + protected void PushAndConfirm(Func newScreen) { Screen screen = null; @@ -135,7 +137,8 @@ namespace osu.Game.Tests.Visual public new void PerformFromScreen(Action action, IEnumerable validScreens = null) => base.PerformFromScreen(action, validScreens); - public TestOsuGame(Storage storage, IAPIProvider api) + public TestOsuGame(Storage storage, IAPIProvider api, string[] args = null) + : base(args) { Storage = storage; API = api; diff --git a/osu.Game/Tests/Visual/OsuTestScene.cs b/osu.Game/Tests/Visual/OsuTestScene.cs index 03434961ea..90e85f7716 100644 --- a/osu.Game/Tests/Visual/OsuTestScene.cs +++ b/osu.Game/Tests/Visual/OsuTestScene.cs @@ -20,6 +20,7 @@ using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -174,6 +175,56 @@ namespace osu.Game.Tests.Visual protected virtual IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TestBeatmap(ruleset); + protected APIBeatmapSet CreateAPIBeatmapSet(RulesetInfo ruleset) + { + var beatmap = CreateBeatmap(ruleset).BeatmapInfo; + + return new APIBeatmapSet + { + Covers = beatmap.BeatmapSet.Covers, + OnlineID = beatmap.BeatmapSet.OnlineID, + Status = beatmap.BeatmapSet.Status, + Preview = beatmap.BeatmapSet.Preview, + HasFavourited = beatmap.BeatmapSet.HasFavourited, + PlayCount = beatmap.BeatmapSet.PlayCount, + FavouriteCount = beatmap.BeatmapSet.FavouriteCount, + BPM = beatmap.BeatmapSet.BPM, + HasExplicitContent = beatmap.BeatmapSet.HasExplicitContent, + HasVideo = beatmap.BeatmapSet.HasVideo, + HasStoryboard = beatmap.BeatmapSet.HasStoryboard, + Submitted = beatmap.BeatmapSet.Submitted, + Ranked = beatmap.BeatmapSet.Ranked, + LastUpdated = beatmap.BeatmapSet.LastUpdated, + TrackId = beatmap.BeatmapSet.TrackId, + Title = beatmap.BeatmapSet.Metadata.Title, + TitleUnicode = beatmap.BeatmapSet.Metadata.TitleUnicode, + Artist = beatmap.BeatmapSet.Metadata.Artist, + ArtistUnicode = beatmap.BeatmapSet.Metadata.ArtistUnicode, + Author = beatmap.BeatmapSet.Metadata.Author, + AuthorID = beatmap.BeatmapSet.Metadata.AuthorID, + AuthorString = beatmap.BeatmapSet.Metadata.AuthorString, + Availability = beatmap.BeatmapSet.Availability, + Genre = beatmap.BeatmapSet.Genre, + Language = beatmap.BeatmapSet.Language, + Source = beatmap.BeatmapSet.Metadata.Source, + Tags = beatmap.BeatmapSet.Metadata.Tags, + Beatmaps = new[] + { + new APIBeatmap + { + OnlineID = beatmap.OnlineID, + OnlineBeatmapSetID = beatmap.BeatmapSet.OnlineID, + Status = beatmap.Status, + Checksum = beatmap.MD5Hash, + AuthorID = beatmap.Metadata.AuthorID, + RulesetID = beatmap.RulesetID, + StarRating = beatmap.StarDifficulty, + DifficultyName = beatmap.Version, + } + } + }; + } + protected WorkingBeatmap CreateWorkingBeatmap(RulesetInfo ruleset) => CreateWorkingBeatmap(CreateBeatmap(ruleset)); diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 4877ddf725..8ba6e41d53 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -36,11 +36,12 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + diff --git a/osu.iOS.props b/osu.iOS.props index edce9d27fe..e55dbb3bfe 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -70,8 +70,8 @@ - - + + @@ -93,7 +93,7 @@ - + diff --git a/osu.sln.DotSettings b/osu.sln.DotSettings index e42b30e944..3af986543e 100644 --- a/osu.sln.DotSettings +++ b/osu.sln.DotSettings @@ -73,6 +73,7 @@ HINT WARNING HINT + DO_NOT_SHOW WARNING DO_NOT_SHOW WARNING