diff --git a/.github/workflows/diffcalc.yml b/.github/workflows/diffcalc.yml index 5fde6e2f1a..c5bac64ff2 100644 --- a/.github/workflows/diffcalc.yml +++ b/.github/workflows/diffcalc.yml @@ -14,8 +14,8 @@ # # The workflow can be run in two ways: # 1. Via workflow dispatch. -# 2. By an owner of the repository posting a pull request or issue comment containing `!diffcalc`. -# For pull requests, the workflow will assume the pull request as the target to compare against (i.e. the `OSU_B` variable). +# 2. By an owner of the repository posting a pull request or issue comment containing `!diffcalc`. +# For pull requests, the workflow will assume the pull request as the target to compare against (i.e. the `OSU_B` variable). # Any lines in the comment of the form `KEY=VALUE` are treated as variables for the generator. # # ## Google Service Account @@ -101,29 +101,30 @@ permissions: pull-requests: write env: - COMMENT_TAG: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }} + EXECUTION_ID: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }} jobs: - wait-for-queue: - name: "Wait for previous workflows" + check-permissions: + name: Check permissions runs-on: ubuntu-latest - if: ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || contains(github.event.comment.body, '!diffcalc') && github.event.comment.author_association == 'OWNER') }} - timeout-minutes: 50400 # 35 days, the maximum for jobs. + if: ${{ github.event_name == 'workflow_dispatch' || contains(github.event.comment.body, '!diffcalc') }} steps: - - uses: ahmadnassri/action-workflow-queue@v1 + - name: Check permissions + if: ${{ github.event_name != 'workflow_dispatch' }} + uses: actions-cool/check-user-permission@a0668c9aec87f3875fc56170b6452a453e9dd819 # v2.2.0 with: - timeout: 2147483647 # Around 24 days, maximum supported. - delay: 120000 # Poll every 2 minutes. API seems fairly low on this one. + require: 'write' create-comment: name: Create PR comment + needs: check-permissions runs-on: ubuntu-latest - if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '!diffcalc') && github.event.comment.author_association == 'OWNER' }} + if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }} steps: - name: Create comment - uses: thollander/actions-comment-pull-request@v2 + uses: thollander/actions-comment-pull-request@363c6f6eae92cc5c3a66e95ba016fc771bb38943 # v2.4.2 with: - comment_tag: ${{ env.COMMENT_TAG }} + comment_tag: ${{ env.EXECUTION_ID }} message: | Difficulty calculation queued -- please wait! (${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) @@ -131,42 +132,37 @@ jobs: directory: name: Prepare directory - needs: wait-for-queue + needs: check-permissions runs-on: self-hosted - if: ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || contains(github.event.comment.body, '!diffcalc') && github.event.comment.author_association == 'OWNER') }} outputs: GENERATOR_DIR: ${{ steps.set-outputs.outputs.GENERATOR_DIR }} GENERATOR_ENV: ${{ steps.set-outputs.outputs.GENERATOR_ENV }} GOOGLE_CREDS_FILE: ${{ steps.set-outputs.outputs.GOOGLE_CREDS_FILE }} steps: - - name: Checkout - uses: actions/checkout@v3 - - name: Checkout diffcalc-sheet-generator uses: actions/checkout@v3 with: - path: 'diffcalc-sheet-generator' + path: ${{ env.EXECUTION_ID }} repository: 'smoogipoo/diffcalc-sheet-generator' - name: Set outputs id: set-outputs run: | - echo "GENERATOR_DIR=${{ github.workspace }}/diffcalc-sheet-generator" >> "${GITHUB_OUTPUT}" - echo "GENERATOR_ENV=${{ github.workspace }}/diffcalc-sheet-generator/.env" >> "${GITHUB_OUTPUT}" - echo "GOOGLE_CREDS_FILE=${{ github.workspace }}/diffcalc-sheet-generator/google-credentials.json" >> "${GITHUB_OUTPUT}" + echo "GENERATOR_DIR=${{ github.workspace }}/${{ env.EXECUTION_ID }}" >> "${GITHUB_OUTPUT}" + echo "GENERATOR_ENV=${{ github.workspace }}/${{ env.EXECUTION_ID }}/.env" >> "${GITHUB_OUTPUT}" + echo "GOOGLE_CREDS_FILE=${{ github.workspace }}/${{ env.EXECUTION_ID }}/google-credentials.json" >> "${GITHUB_OUTPUT}" environment: name: Setup environment needs: directory runs-on: self-hosted - if: ${{ !cancelled() && needs.directory.result == 'success' }} env: VARS_JSON: ${{ toJSON(vars) }} steps: - name: Add base environment run: | # Required by diffcalc-sheet-generator - cp '${{ github.workspace }}/diffcalc-sheet-generator/.env.sample' "${{ needs.directory.outputs.GENERATOR_ENV }}" + cp '${{ needs.directory.outputs.GENERATOR_DIR }}/.env.sample' "${{ needs.directory.outputs.GENERATOR_ENV }}" # Add Google credentials echo '${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }}' | base64 -d > "${{ needs.directory.outputs.GOOGLE_CREDS_FILE }}" @@ -185,7 +181,7 @@ jobs: - name: Add pull-request environment if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }} run: | - sed -i "s;^OSU_B=.*$;OSU_B=${{ github.event.issue.pull_request.url }};" "${{ needs.directory.outputs.GENERATOR_ENV }}" + sed -i "s;^OSU_B=.*$;OSU_B=${{ github.event.issue.pull_request.html_url }};" "${{ needs.directory.outputs.GENERATOR_ENV }}" - name: Add comment environment if: ${{ github.event_name == 'issue_comment' }} @@ -239,7 +235,6 @@ jobs: name: Setup scores needs: [ directory, environment ] runs-on: self-hosted - if: ${{ !cancelled() && needs.environment.result == 'success' }} steps: - name: Query latest data id: query @@ -252,7 +247,7 @@ jobs: - name: Restore cache id: restore-cache - uses: maxnowack/local-cache@v1 + uses: maxnowack/local-cache@038cc090b52e4f205fbc468bf5b0756df6f68775 # v1 with: path: ${{ steps.query.outputs.DATA_NAME }}.tar.bz2 key: ${{ steps.query.outputs.DATA_NAME }} @@ -272,7 +267,6 @@ jobs: name: Setup beatmaps needs: directory runs-on: self-hosted - if: ${{ !cancelled() && needs.directory.result == 'success' }} steps: - name: Query latest data id: query @@ -284,7 +278,7 @@ jobs: - name: Restore cache id: restore-cache - uses: maxnowack/local-cache@v1 + uses: maxnowack/local-cache@038cc090b52e4f205fbc468bf5b0756df6f68775 # v1 with: path: ${{ steps.query.outputs.DATA_NAME }}.tar.bz2 key: ${{ steps.query.outputs.DATA_NAME }} @@ -305,7 +299,6 @@ jobs: needs: [ directory, environment, scores, beatmaps ] runs-on: self-hosted timeout-minutes: 720 - if: ${{ !cancelled() && needs.scores.result == 'success' && needs.beatmaps.result == 'success' }} outputs: TARGET: ${{ steps.run.outputs.TARGET }} SPREADSHEET_LINK: ${{ steps.run.outputs.SPREADSHEET_LINK }} @@ -329,25 +322,39 @@ jobs: if: ${{ always() }} run: | cd "${{ needs.directory.outputs.GENERATOR_DIR }}" - docker-compose down + docker-compose down -v + output-cli: + name: Output info + needs: generator + runs-on: ubuntu-latest + steps: - name: Output info - if: ${{ success() }} run: | - echo "Target: ${{ steps.run.outputs.TARGET }}" - echo "Spreadsheet: ${{ steps.run.outputs.SPREADSHEET_LINK }}" + echo "Target: ${{ needs.generator.outputs.TARGET }}" + echo "Spreadsheet: ${{ needs.generator.outputs.SPREADSHEET_LINK }}" + + cleanup: + name: Cleanup + needs: [ directory, generator ] + if: ${{ always() }} + runs-on: self-hosted + steps: + - name: Cleanup + run: | + rm -rf "${{ needs.directory.outputs.GENERATOR_DIR }}" update-comment: name: Update PR comment needs: [ create-comment, generator ] runs-on: ubuntu-latest - if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '!diffcalc') && github.event.comment.author_association == 'OWNER' }} + if: ${{ always() && needs.create-comment.result == 'success' }} steps: - name: Update comment on success if: ${{ needs.generator.result == 'success' }} - uses: thollander/actions-comment-pull-request@v2 + uses: thollander/actions-comment-pull-request@363c6f6eae92cc5c3a66e95ba016fc771bb38943 # v2.4.2 with: - comment_tag: ${{ env.COMMENT_TAG }} + comment_tag: ${{ env.EXECUTION_ID }} mode: upsert create_if_not_exists: false message: | @@ -356,10 +363,18 @@ jobs: - name: Update comment on failure if: ${{ needs.generator.result == 'failure' }} - uses: thollander/actions-comment-pull-request@v2 + uses: thollander/actions-comment-pull-request@363c6f6eae92cc5c3a66e95ba016fc771bb38943 # v2.4.2 with: - comment_tag: ${{ env.COMMENT_TAG }} + comment_tag: ${{ env.EXECUTION_ID }} mode: upsert create_if_not_exists: false message: | Difficulty calculation failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + + - name: Update comment on cancellation + if: ${{ needs.generator.result == 'cancelled' }} + uses: thollander/actions-comment-pull-request@363c6f6eae92cc5c3a66e95ba016fc771bb38943 # v2.4.2 + with: + comment_tag: ${{ env.EXECUTION_ID }} + mode: delete + message: '.' # Appears to be required by this action for non-error status code. diff --git a/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModFloatingFruits.cs b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModFloatingFruits.cs new file mode 100644 index 0000000000..73579d1c22 --- /dev/null +++ b/osu.Game.Rulesets.Catch.Tests/Mods/TestSceneCatchModFloatingFruits.cs @@ -0,0 +1,21 @@ +// 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.Game.Rulesets.Catch.Mods; +using osu.Game.Tests.Visual; + +namespace osu.Game.Rulesets.Catch.Tests.Mods +{ + public partial class TestSceneCatchModFloatingFruits : ModTestScene + { + protected override Ruleset CreatePlayerRuleset() => new CatchRuleset(); + + [Test] + public void TestFloating() => CreateModTest(new ModTestData + { + Mod = new CatchModFloatingFruits(), + PassCondition = () => true + }); + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/CatchBeatSnapGrid.cs b/osu.Game.Rulesets.Catch/Edit/CatchBeatSnapGrid.cs index 6862696b3a..40bd08455f 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchBeatSnapGrid.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchBeatSnapGrid.cs @@ -1,180 +1,19 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Caching; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Edit; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.UI.Scrolling; -using osu.Game.Screens.Edit; -using osuTK.Graphics; +using osu.Game.Screens.Edit.Compose.Components; namespace osu.Game.Rulesets.Catch.Edit { - /// - /// A grid which displays coloured beat divisor lines in proximity to the selection or placement cursor. - /// - /// - /// This class heavily borrows from osu!mania's implementation (ManiaBeatSnapGrid). - /// If further changes are to be made, they should also be applied there. - /// If the scale of the changes are large enough, abstracting may be a good path. - /// - public partial class CatchBeatSnapGrid : Component + public partial class CatchBeatSnapGrid : BeatSnapGrid { - private const double visible_range = 750; - - /// - /// The range of time values of the current selection. - /// - public (double start, double end)? SelectionTimeRange + protected override IEnumerable GetTargetContainers(HitObjectComposer composer) => new[] { - set - { - if (value == selectionTimeRange) - return; - - selectionTimeRange = value; - lineCache.Invalidate(); - } - } - - [Resolved] - private EditorBeatmap beatmap { get; set; } = null!; - - [Resolved] - private OsuColour colours { get; set; } = null!; - - [Resolved] - private BindableBeatDivisor beatDivisor { get; set; } = null!; - - private readonly Cached lineCache = new Cached(); - - private (double start, double end)? selectionTimeRange; - - private ScrollingHitObjectContainer lineContainer = null!; - - [BackgroundDependencyLoader] - private void load(HitObjectComposer composer) - { - lineContainer = new ScrollingHitObjectContainer(); - - ((CatchPlayfield)composer.Playfield).UnderlayElements.Add(lineContainer); - - beatDivisor.BindValueChanged(_ => createLines(), true); - } - - protected override void Update() - { - base.Update(); - - if (!lineCache.IsValid) - { - lineCache.Validate(); - createLines(); - } - } - - private readonly Stack availableLines = new Stack(); - - private void createLines() - { - foreach (var line in lineContainer.Objects.OfType()) - availableLines.Push(line); - - lineContainer.Clear(); - - if (selectionTimeRange == null) - return; - - var range = selectionTimeRange.Value; - - var timingPoint = beatmap.ControlPointInfo.TimingPointAt(range.start - visible_range); - - double time = timingPoint.Time; - int beat = 0; - - // progress time until in the visible range. - while (time < range.start - visible_range) - { - time += timingPoint.BeatLength / beatDivisor.Value; - beat++; - } - - while (time < range.end + visible_range) - { - var nextTimingPoint = beatmap.ControlPointInfo.TimingPointAt(time); - - // switch to the next timing point if we have reached it. - if (nextTimingPoint.Time > timingPoint.Time) - { - beat = 0; - time = nextTimingPoint.Time; - timingPoint = nextTimingPoint; - } - - Color4 colour = BindableBeatDivisor.GetColourFor( - BindableBeatDivisor.GetDivisorForBeatIndex(beat, beatDivisor.Value), colours); - - if (!availableLines.TryPop(out var line)) - line = new DrawableGridLine(); - - line.HitObject.StartTime = time; - line.Colour = colour; - - lineContainer.Add(line); - - beat++; - time += timingPoint.BeatLength / beatDivisor.Value; - } - - // required to update ScrollingHitObjectContainer's cache. - lineContainer.UpdateSubTree(); - - foreach (var line in lineContainer.Objects.OfType()) - { - time = line.HitObject.StartTime; - - if (time >= range.start && time <= range.end) - line.Alpha = 1; - else - { - double timeSeparation = time < range.start ? range.start - time : time - range.end; - line.Alpha = (float)Math.Max(0, 1 - timeSeparation / visible_range); - } - } - } - - private partial class DrawableGridLine : DrawableHitObject - { - public DrawableGridLine() - : base(new HitObject()) - { - RelativeSizeAxes = Axes.X; - Height = 2; - - AddInternal(new Box { RelativeSizeAxes = Axes.Both }); - } - - [BackgroundDependencyLoader] - private void load() - { - Origin = Anchor.BottomLeft; - Anchor = Anchor.BottomLeft; - } - - protected override void UpdateInitialTransforms() - { - // don't perform any fading – we are handling that ourselves. - LifetimeEnd = HitObject.StartTime + visible_range; - } - } + ((CatchPlayfield)composer.Playfield).UnderlayElements + }; } } diff --git a/osu.Game.Rulesets.Catch/Edit/CatchDistanceSnapProvider.cs b/osu.Game.Rulesets.Catch/Edit/CatchDistanceSnapProvider.cs new file mode 100644 index 0000000000..c3103bd204 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/CatchDistanceSnapProvider.cs @@ -0,0 +1,26 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; + +namespace osu.Game.Rulesets.Catch.Edit +{ + public partial class CatchDistanceSnapProvider : ComposerDistanceSnapProvider + { + protected override double ReadCurrentDistanceSnap(HitObject before, HitObject after) + { + // osu!catch's distance snap implementation is limited, in that a custom spacing cannot be specified. + // Therefore this functionality is not currently used. + // + // The implementation below is probably correct but should be checked if/when exposed via controls. + + float expectedDistance = DurationToDistance(before, after.StartTime - before.GetEndTime()); + float actualDistance = Math.Abs(((CatchHitObject)before).EffectiveX - ((CatchHitObject)after).EffectiveX); + + return actualDistance / expectedDistance; + } + } +} diff --git a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs index dc3a4416a5..6f0ee260ab 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchHitObjectComposer.cs @@ -1,17 +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.Collections.Generic; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Graphics; -using osu.Framework.Input; +using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Beatmaps; -using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.UI; @@ -20,28 +18,27 @@ 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; namespace osu.Game.Rulesets.Catch.Edit { - // we're also a ScrollingHitObjectComposer candidate, but can't be everything can we? - public partial class CatchHitObjectComposer : DistancedHitObjectComposer + public partial class CatchHitObjectComposer : ScrollingHitObjectComposer, IKeyBindingHandler { private const float distance_snap_radius = 50; private CatchDistanceSnapGrid distanceSnapGrid = null!; - private InputManager inputManager = null!; - - private CatchBeatSnapGrid beatSnapGrid = null!; - private readonly BindableDouble timeRangeMultiplier = new BindableDouble(1) { MinValue = 1, MaxValue = 10, }; + [Cached(typeof(IDistanceSnapProvider))] + protected readonly CatchDistanceSnapProvider DistanceSnapProvider = new CatchDistanceSnapProvider(); + public CatchHitObjectComposer(CatchRuleset ruleset) : base(ruleset) { @@ -50,8 +47,11 @@ namespace osu.Game.Rulesets.Catch.Edit [BackgroundDependencyLoader] private void load() { + AddInternal(DistanceSnapProvider); + DistanceSnapProvider.AttachToToolbox(RightToolbox); + // todo: enable distance spacing once catch supports applying it to its existing distance snap grid implementation. - DistanceSpacingMultiplier.Disabled = true; + DistanceSnapProvider.DistanceSpacingMultiplier.Disabled = true; LayerBelowRuleset.Add(new PlayfieldBorder { @@ -68,61 +68,30 @@ namespace osu.Game.Rulesets.Catch.Edit Catcher.BASE_DASH_SPEED, -Catcher.BASE_DASH_SPEED, Catcher.BASE_WALK_SPEED, -Catcher.BASE_WALK_SPEED, })); - - AddInternal(beatSnapGrid = new CatchBeatSnapGrid()); } - protected override void LoadComplete() - { - base.LoadComplete(); + protected override IEnumerable CreateTernaryButtons() + => base.CreateTernaryButtons() + .Concat(DistanceSnapProvider.CreateTernaryButtons()); - inputManager = GetContainingInputManager(); - } - - protected override void UpdateAfterChildren() - { - base.UpdateAfterChildren(); - - if (BlueprintContainer.CurrentTool is SelectTool) + protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods) => + new DrawableCatchEditorRuleset(ruleset, beatmap, mods) { - if (EditorBeatmap.SelectedHitObjects.Any()) - { - beatSnapGrid.SelectionTimeRange = (EditorBeatmap.SelectedHitObjects.Min(h => h.StartTime), EditorBeatmap.SelectedHitObjects.Max(h => h.GetEndTime())); - } - else - beatSnapGrid.SelectionTimeRange = null; - } - else - { - var result = FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position); - if (result.Time is double time) - beatSnapGrid.SelectionTimeRange = (time, time); - else - beatSnapGrid.SelectionTimeRange = null; - } - } + TimeRangeMultiplier = { BindTarget = timeRangeMultiplier, } + }; - protected override double ReadCurrentDistanceSnap(HitObject before, HitObject after) + protected override ComposeBlueprintContainer CreateBlueprintContainer() => new CatchBlueprintContainer(this); + + protected override BeatSnapGrid CreateBeatSnapGrid() => new CatchBeatSnapGrid(); + + protected override IReadOnlyList CompositionTools => new HitObjectCompositionTool[] { - // osu!catch's distance snap implementation is limited, in that a custom spacing cannot be specified. - // Therefore this functionality is not currently used. - // - // The implementation below is probably correct but should be checked if/when exposed via controls. + new FruitCompositionTool(), + new JuiceStreamCompositionTool(), + new BananaShowerCompositionTool() + }; - float expectedDistance = DurationToDistance(before, after.StartTime - before.GetEndTime()); - float actualDistance = Math.Abs(((CatchHitObject)before).EffectiveX - ((CatchHitObject)after).EffectiveX); - - return actualDistance / expectedDistance; - } - - protected override void Update() - { - base.Update(); - - updateDistanceSnapGrid(); - } - - public override bool OnPressed(KeyBindingPressEvent e) + public bool OnPressed(KeyBindingPressEvent e) { switch (e.Action) { @@ -131,28 +100,19 @@ namespace osu.Game.Rulesets.Catch.Edit // May be worth considering standardising "zoom" behaviour with what the timeline uses (ie. alt-wheel) but that may cause new conflicts. case GlobalAction.IncreaseScrollSpeed: this.TransformBindableTo(timeRangeMultiplier, timeRangeMultiplier.Value - 1, 200, Easing.OutQuint); - break; + return true; case GlobalAction.DecreaseScrollSpeed: this.TransformBindableTo(timeRangeMultiplier, timeRangeMultiplier.Value + 1, 200, Easing.OutQuint); - break; + return true; } - return base.OnPressed(e); + return false; } - protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods) => - new DrawableCatchEditorRuleset(ruleset, beatmap, mods) - { - TimeRangeMultiplier = { BindTarget = timeRangeMultiplier, } - }; - - protected override IReadOnlyList CompositionTools => new HitObjectCompositionTool[] + public void OnReleased(KeyBindingReleaseEvent e) { - new FruitCompositionTool(), - new JuiceStreamCompositionTool(), - new BananaShowerCompositionTool() - }; + } public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) { @@ -172,8 +132,6 @@ namespace osu.Game.Rulesets.Catch.Edit return result; } - protected override ComposeBlueprintContainer CreateBlueprintContainer() => new CatchBlueprintContainer(this); - private PalpableCatchHitObject? getLastSnappableHitObject(double time) { var hitObject = EditorBeatmap.HitObjects.OfType().LastOrDefault(h => h.GetEndTime() < time && !(h is BananaShower)); @@ -214,33 +172,12 @@ namespace osu.Game.Rulesets.Catch.Edit return null; } - double timeAtCursor = ((CatchPlayfield)Playfield).TimeAtScreenSpacePosition(inputManager.CurrentState.Mouse.Position); + 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/Mods/CatchModFloatingFruits.cs b/osu.Game.Rulesets.Catch/Mods/CatchModFloatingFruits.cs index dd6757eac9..9d88c90576 100644 --- a/osu.Game.Rulesets.Catch/Mods/CatchModFloatingFruits.cs +++ b/osu.Game.Rulesets.Catch/Mods/CatchModFloatingFruits.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.Graphics; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; using osu.Game.Rulesets.Catch.Objects; @@ -21,10 +20,8 @@ namespace osu.Game.Rulesets.Catch.Mods public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { - drawableRuleset.PlayfieldAdjustmentContainer.Anchor = Anchor.Centre; - drawableRuleset.PlayfieldAdjustmentContainer.Origin = Anchor.Centre; - drawableRuleset.PlayfieldAdjustmentContainer.Scale = new Vector2(1, -1); + drawableRuleset.PlayfieldAdjustmentContainer.Y = 1 - drawableRuleset.PlayfieldAdjustmentContainer.Y; } } } diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs b/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs index 4d45e16588..61c730912f 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs @@ -1,206 +1,23 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Caching; -using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Pooling; -using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.UI; -using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.Objects.Drawables; -using osu.Game.Rulesets.UI.Scrolling; -using osu.Game.Screens.Edit; -using osuTK.Graphics; +using osu.Game.Screens.Edit.Compose.Components; namespace osu.Game.Rulesets.Mania.Edit { - /// - /// A grid which displays coloured beat divisor lines in proximity to the selection or placement cursor. - /// - public partial class ManiaBeatSnapGrid : CompositeComponent + public partial class ManiaBeatSnapGrid : BeatSnapGrid { - private const double visible_range = 750; - - /// - /// The range of time values of the current selection. - /// - public (double start, double end)? SelectionTimeRange + protected override IEnumerable GetTargetContainers(HitObjectComposer composer) { - set - { - if (value == selectionTimeRange) - return; - - selectionTimeRange = value; - lineCache.Invalidate(); - } - } - - [Resolved] - private EditorBeatmap beatmap { get; set; } = null!; - - [Resolved] - private OsuColour colours { get; set; } = null!; - - [Resolved] - private BindableBeatDivisor beatDivisor { get; set; } = null!; - - private readonly List grids = new List(); - - private readonly DrawablePool linesPool = new DrawablePool(50); - - private readonly Cached lineCache = new Cached(); - - private (double start, double end)? selectionTimeRange; - - [BackgroundDependencyLoader] - private void load(HitObjectComposer composer) - { - AddInternal(linesPool); - - foreach (var stage in ((ManiaPlayfield)composer.Playfield).Stages) - { - foreach (var column in stage.Columns) - { - var lineContainer = new ScrollingHitObjectContainer(); - - grids.Add(lineContainer); - column.UnderlayElements.Add(lineContainer); - } - } - - beatDivisor.BindValueChanged(_ => createLines(), true); - } - - protected override void Update() - { - base.Update(); - - if (!lineCache.IsValid) - { - lineCache.Validate(); - createLines(); - } - } - - private void createLines() - { - foreach (var grid in grids) - grid.Clear(); - - if (selectionTimeRange == null) - return; - - var range = selectionTimeRange.Value; - - var timingPoint = beatmap.ControlPointInfo.TimingPointAt(range.start - visible_range); - - double time = timingPoint.Time; - int beat = 0; - - // progress time until in the visible range. - while (time < range.start - visible_range) - { - time += timingPoint.BeatLength / beatDivisor.Value; - beat++; - } - - while (time < range.end + visible_range) - { - var nextTimingPoint = beatmap.ControlPointInfo.TimingPointAt(time); - - // switch to the next timing point if we have reached it. - if (nextTimingPoint.Time > timingPoint.Time) - { - beat = 0; - time = nextTimingPoint.Time; - timingPoint = nextTimingPoint; - } - - Color4 colour = BindableBeatDivisor.GetColourFor( - BindableBeatDivisor.GetDivisorForBeatIndex(beat, beatDivisor.Value), colours); - - foreach (var grid in grids) - { - var line = linesPool.Get(); - - line.Apply(new HitObject - { - StartTime = time - }); - - line.Colour = colour; - - grid.Add(line); - } - - beat++; - time += timingPoint.BeatLength / beatDivisor.Value; - } - - foreach (var grid in grids) - { - // required to update ScrollingHitObjectContainer's cache. - grid.UpdateSubTree(); - - foreach (var line in grid.Objects.OfType()) - { - time = line.HitObject.StartTime; - - if (time >= range.start && time <= range.end) - line.Alpha = 1; - else - { - double timeSeparation = time < range.start ? range.start - time : time - range.end; - line.Alpha = (float)Math.Max(0, 1 - timeSeparation / visible_range); - } - } - } - } - - private partial class DrawableGridLine : DrawableHitObject - { - [Resolved] - private IScrollingInfo scrollingInfo { get; set; } = null!; - - private readonly IBindable direction = new Bindable(); - - public DrawableGridLine() - : base(new HitObject()) - { - RelativeSizeAxes = Axes.X; - Height = 2; - - AddInternal(new Box { RelativeSizeAxes = Axes.Both }); - } - - [BackgroundDependencyLoader] - private void load() - { - direction.BindTo(scrollingInfo.Direction); - direction.BindValueChanged(onDirectionChanged, true); - } - - private void onDirectionChanged(ValueChangedEvent direction) - { - Origin = Anchor = direction.NewValue == ScrollingDirection.Up - ? Anchor.TopLeft - : Anchor.BottomLeft; - } - - protected override void UpdateInitialTransforms() - { - // don't perform any fading – we are handling that ourselves. - LifetimeEnd = HitObject.StartTime + visible_range; - } + return ((ManiaPlayfield)composer.Playfield) + .Stages + .SelectMany(stage => stage.Columns) + .Select(column => column.UnderlayElements); } } } diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs index 8e61baca81..b9db4168f4 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaHitObjectComposer.cs @@ -5,15 +5,12 @@ using System.Collections.Generic; using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Input; using osu.Game.Beatmaps; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Screens.Edit.Compose.Components; @@ -24,32 +21,12 @@ namespace osu.Game.Rulesets.Mania.Edit public partial class ManiaHitObjectComposer : ScrollingHitObjectComposer { private DrawableManiaEditorRuleset drawableRuleset; - private ManiaBeatSnapGrid beatSnapGrid; - private InputManager inputManager; public ManiaHitObjectComposer(Ruleset ruleset) : base(ruleset) { } - [BackgroundDependencyLoader] - private void load() - { - AddInternal(beatSnapGrid = new ManiaBeatSnapGrid()); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - - inputManager = GetContainingInputManager(); - } - - private DependencyContainer dependencies; - - protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) - => dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); - public new ManiaPlayfield Playfield => ((ManiaPlayfield)drawableRuleset.Playfield); public IScrollingInfo ScrollingInfo => drawableRuleset.ScrollingInfo; @@ -57,48 +34,20 @@ namespace osu.Game.Rulesets.Mania.Edit protected override Playfield PlayfieldAtScreenSpacePosition(Vector2 screenSpacePosition) => Playfield.GetColumnByPosition(screenSpacePosition); - protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods) - { + protected override DrawableRuleset CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods) => drawableRuleset = new DrawableManiaEditorRuleset(ruleset, beatmap, mods); - // This is the earliest we can cache the scrolling info to ourselves, before masks are added to the hierarchy and inject it - dependencies.CacheAs(drawableRuleset.ScrollingInfo); - - return drawableRuleset; - } - protected override ComposeBlueprintContainer CreateBlueprintContainer() => new ManiaBlueprintContainer(this); + protected override BeatSnapGrid CreateBeatSnapGrid() => new ManiaBeatSnapGrid(); + protected override IReadOnlyList CompositionTools => new HitObjectCompositionTool[] { new NoteCompositionTool(), new HoldNoteCompositionTool() }; - protected override void UpdateAfterChildren() - { - base.UpdateAfterChildren(); - - if (BlueprintContainer.CurrentTool is SelectTool) - { - if (EditorBeatmap.SelectedHitObjects.Any()) - { - beatSnapGrid.SelectionTimeRange = (EditorBeatmap.SelectedHitObjects.Min(h => h.StartTime), EditorBeatmap.SelectedHitObjects.Max(h => h.GetEndTime())); - } - else - beatSnapGrid.SelectionTimeRange = null; - } - else - { - var result = FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position); - if (result.Time is double time) - beatSnapGrid.SelectionTimeRange = (time, time); - else - beatSnapGrid.SelectionTimeRange = null; - } - } - public override string ConvertSelectionToString() => string.Join(',', EditorBeatmap.SelectedHitObjects.Cast().OrderBy(h => h.StartTime).Select(h => $"{h.StartTime}|{h.Column}")); } diff --git a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs index 9338d5453d..b70f932913 100644 --- a/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs +++ b/osu.Game.Rulesets.Osu.Tests/Editor/TestSceneOsuDistanceSnapGrid.cs @@ -47,8 +47,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor [Cached] private readonly BindableBeatDivisor beatDivisor = new BindableBeatDivisor(); - [Cached(typeof(IDistanceSnapProvider))] - private readonly OsuHitObjectComposer snapProvider = new OsuHitObjectComposer(new OsuRuleset()) + private readonly TestHitObjectComposer composer = new TestHitObjectComposer { // Just used for the snap implementation, so let's hide from vision. AlwaysPresent = true, @@ -71,11 +70,18 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor base.Content.Children = new Drawable[] { editorClock = new EditorClock(editorBeatmap), - new PopoverContainer { Child = snapProvider }, + new PopoverContainer { Child = composer }, Content }; } + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) + { + var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); + dependencies.CacheAs(composer.DistanceSnapProvider); + return dependencies; + } + protected override Container Content { get; } = new PopoverContainer { RelativeSizeAxes = Axes.Both }; [SetUp] @@ -84,7 +90,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor editorBeatmap.Difficulty.SliderMultiplier = 1; editorBeatmap.ControlPointInfo.Clear(); editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = beat_length }); - snapProvider.DistanceSpacingMultiplier.Value = 1; + composer.DistanceSnapProvider.DistanceSpacingMultiplier.Value = 1; Children = new Drawable[] { @@ -116,7 +122,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor [TestCase(0.5f)] public void TestDistanceSpacing(float multiplier) { - AddStep($"set distance spacing = {multiplier}", () => snapProvider.DistanceSpacingMultiplier.Value = multiplier); + AddStep($"set distance spacing = {multiplier}", () => composer.DistanceSnapProvider.DistanceSpacingMultiplier.Value = multiplier); } [Test] @@ -153,7 +159,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor [TestCase(2f, beat_length * 2)] public void TestDistanceSpacingAdjust(float multiplier, float expectedDistance) { - AddStep($"Set distance spacing to {multiplier}", () => snapProvider.DistanceSpacingMultiplier.Value = multiplier); + AddStep($"Set distance spacing to {multiplier}", () => composer.DistanceSnapProvider.DistanceSpacingMultiplier.Value = multiplier); AddStep("move mouse to point", () => InputManager.MoveMouseTo(grid.ToScreenSpace(grid_position + new Vector2(beat_length, 0) * 2))); assertSnappedDistance(expectedDistance); @@ -266,5 +272,15 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor cursor.Position = LastSnappedPosition = GetSnapPosition.Invoke(inputManager.CurrentState.Mouse.Position); } } + + private partial class TestHitObjectComposer : OsuHitObjectComposer + { + public new IDistanceSnapProvider DistanceSnapProvider => base.DistanceSnapProvider; + + public TestHitObjectComposer() + : base(new OsuRuleset()) + { + } + } } } diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs index 6980438b59..ace7f23989 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModAutoplay.cs @@ -51,8 +51,9 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods FinalRate = { Value = 1.3 } }); - [Test] - public void TestPerfectScoreOnShortSliderWithRepeat() + [TestCase(6.25f)] + [TestCase(20)] + public void TestPerfectScoreOnShortSliderWithRepeat(float pathLength) { AddStep("set score to standardised", () => LocalConfig.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised)); @@ -70,7 +71,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods Path = new SliderPath(new[] { new PathControlPoint(), - new PathControlPoint(new Vector2(0, 6.25f)) + new PathControlPoint(new Vector2(0, pathLength)) }), RepeatCount = 1, SliderVelocityMultiplier = 10 diff --git a/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs index 11e69dc133..3e0a86d39c 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuBeatmapConversionTest.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using osu.Framework.Utils; using osu.Game.Rulesets.Objects; @@ -33,8 +34,21 @@ namespace osu.Game.Rulesets.Osu.Tests switch (hitObject) { case Slider slider: + var objects = new List(); + foreach (var nested in slider.NestedHitObjects) - yield return createConvertValue((OsuHitObject)nested); + objects.Add(createConvertValue((OsuHitObject)nested, slider)); + + // stable does slider tail leniency by offsetting the last tick 36ms back. + // based on player feedback, we're doing this a little different in lazer, + // and the lazer method does not require offsetting the last tick + // (see `DrawableSliderTail.CheckForResult()`). + // however, in conversion tests, just so the output matches, we're bringing + // the 36ms offset back locally. + // in particular, on some sliders, this may rearrange nested objects, + // so we sort them again by start time to prevent test failures. + foreach (var obj in objects.OrderBy(cv => cv.StartTime)) + yield return obj; break; @@ -44,13 +58,29 @@ namespace osu.Game.Rulesets.Osu.Tests break; } - static ConvertValue createConvertValue(OsuHitObject obj) => new ConvertValue + static ConvertValue createConvertValue(OsuHitObject obj, OsuHitObject? parent = null) { - StartTime = obj.StartTime, - EndTime = obj.GetEndTime(), - X = obj.StackedPosition.X, - Y = obj.StackedPosition.Y - }; + double startTime = obj.StartTime; + double endTime = obj.GetEndTime(); + + // as stated in the inline comment above, this is locally bringing back + // the stable treatment of the "legacy last tick" just to make sure + // that the conversion output matches. + // compare: `SliderEventGenerator.Generate()`, and the calculation of `legacyLastTickTime`. + if (obj is SliderTailCircle && parent is Slider slider) + { + startTime = Math.Max(startTime + SliderEventGenerator.TAIL_LENIENCY, slider.StartTime + slider.Duration / 2); + endTime = Math.Max(endTime + SliderEventGenerator.TAIL_LENIENCY, slider.StartTime + slider.Duration / 2); + } + + return new ConvertValue + { + StartTime = startTime, + EndTime = endTime, + X = obj.StackedPosition.X, + Y = obj.StackedPosition.Y + }; + } } protected override Ruleset CreateRuleset() => new OsuRuleset(); diff --git a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs index 9c6449cfa9..f1c1c4734d 100644 --- a/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs +++ b/osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs @@ -17,16 +17,19 @@ namespace osu.Game.Rulesets.Osu.Tests [TestCase(6.7115569159190587d, 206, "diffcalc-test")] [TestCase(1.4391311903612753d, 45, "zero-length-sliders")] + [TestCase(0.42506480230838789d, 2, "very-fast-slider")] [TestCase(0.14102693012101306d, 1, "nan-slider")] public void Test(double expectedStarRating, int expectedMaxCombo, string name) => base.Test(expectedStarRating, expectedMaxCombo, name); [TestCase(8.9757300665532966d, 206, "diffcalc-test")] + [TestCase(0.55071082800473514d, 2, "very-fast-slider")] [TestCase(1.7437232654020756d, 45, "zero-length-sliders")] public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime()); [TestCase(6.7115569159190587d, 239, "diffcalc-test")] + [TestCase(0.42506480230838789d, 4, "very-fast-slider")] [TestCase(1.4391311903612753d, 54, "zero-length-sliders")] public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name) => Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic()); diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs index 644524f532..247aa4f445 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs @@ -1,13 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Screens; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Replays; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; @@ -33,7 +32,100 @@ namespace osu.Game.Rulesets.Osu.Tests private const double time_during_slide_4 = 3800; private const double time_slider_end = 4000; - private List judgementResults; + private ScoreAccessibleReplayPlayer currentPlayer = null!; + + private const float slider_path_length = 25; + + private readonly List judgementResults = new List(); + + // Making these too short causes breakage from frames not being processed fast enough. + // To keep things simple, these tests are crafted to always be >16ms length. + // If sliders shorter than this are ever used in gameplay it will probably break things and we can revisit. + [TestCase(30, 0)] + [TestCase(30, 1)] + [TestCase(40, 0)] + [TestCase(40, 1)] + [TestCase(50, 1)] + [TestCase(60, 1)] + [TestCase(70, 1)] + [TestCase(80, 1)] + [TestCase(80, 0)] + [TestCase(80, 10)] + [TestCase(90, 1)] + [Ignore("headless test doesn't run at high enough precision for this to always enter a tracking state in time.")] + public void TestVeryShortSlider(float sliderLength, int repeatCount) + { + Slider slider; + + performTest(new List + { + new OsuReplayFrame { Position = new Vector2(10, 0), Actions = { OsuAction.LeftButton, OsuAction.RightButton }, Time = time_slider_start - 10 }, + new OsuReplayFrame { Position = new Vector2(10, 0), Actions = { OsuAction.LeftButton, OsuAction.RightButton }, Time = time_slider_start + 2000 }, + }, slider = new Slider + { + StartTime = time_slider_start, + Position = new Vector2(0, 0), + SliderVelocityMultiplier = 10f, + RepeatCount = repeatCount, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(sliderLength, 0), + }), + }, 240, 1); + + assertAllMaxJudgements(); + + // Even if the last tick is hit early, the slider should always execute its final judgement at its endtime. + // If not, hitsounds will not play on time. + AddAssert("Judgement offset is zero", () => judgementResults.Last().TimeOffset == 0); + AddAssert("Slider judged at end time", () => judgementResults.Last().TimeAbsolute, () => Is.EqualTo(slider.EndTime)); + + AddAssert("Slider is last judgement", () => judgementResults[^1].HitObject, Is.TypeOf); + AddAssert("Tail is second last judgement", () => judgementResults[^2].HitObject, Is.TypeOf); + } + + [TestCase(300, false)] + [TestCase(200, true)] + [TestCase(150, true)] + [TestCase(120, true)] + [TestCase(60, true)] + [TestCase(10, true)] + [TestCase(0, true)] + [TestCase(-30, false)] + [Ignore("headless test doesn't run at high enough precision for this to always enter a tracking state in time.")] + public void TestTailLeniency(float finalPosition, bool hit) + { + Slider slider; + + performTest(new List + { + new OsuReplayFrame { Position = Vector2.Zero, Actions = { OsuAction.LeftButton, OsuAction.RightButton }, Time = time_slider_start }, + new OsuReplayFrame { Position = new Vector2(finalPosition, slider_path_length * 3), Actions = { OsuAction.LeftButton, OsuAction.RightButton }, Time = time_slider_start + 20 }, + }, slider = new Slider + { + StartTime = time_slider_start, + Position = new Vector2(0, 0), + SliderVelocityMultiplier = 10f, + Path = new SliderPath(PathType.Linear, new[] + { + Vector2.Zero, + new Vector2(slider_path_length * 10, 0), + new Vector2(slider_path_length * 10, slider_path_length * 3), + new Vector2(0, slider_path_length * 3), + }), + }, 240, 1); + + if (hit) + assertAllMaxJudgements(); + else + AddAssert("Tracking dropped", assertMidSliderJudgementFail); + + // Even if the last tick is hit early, the slider should always execute its final judgement at its endtime. + // If not, hitsounds will not play on time. + AddAssert("Judgement offset is zero", () => judgementResults.Last().TimeOffset == 0); + AddAssert("Slider judged at end time", () => judgementResults.Last().TimeAbsolute, () => Is.EqualTo(slider.EndTime)); + } [Test] public void TestPressBothKeysSimultaneouslyAndReleaseOne() @@ -44,7 +136,7 @@ namespace osu.Game.Rulesets.Osu.Tests new OsuReplayFrame { Position = Vector2.Zero, Actions = { OsuAction.RightButton }, Time = time_during_slide_1 }, }); - AddAssert("Tracking retained", assertMaxJudge); + assertAllMaxJudgements(); } /// @@ -86,7 +178,7 @@ namespace osu.Game.Rulesets.Osu.Tests new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.RightButton }, Time = time_during_slide_2 }, }); - AddAssert("Tracking retained", assertMaxJudge); + assertAllMaxJudgements(); } /// @@ -107,7 +199,7 @@ namespace osu.Game.Rulesets.Osu.Tests new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.RightButton }, Time = time_during_slide_1 }, }); - AddAssert("Tracking retained", assertMaxJudge); + assertAllMaxJudgements(); } /// @@ -128,7 +220,7 @@ namespace osu.Game.Rulesets.Osu.Tests new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.RightButton }, Time = time_during_slide_1 }, }); - AddAssert("Tracking retained", assertMaxJudge); + assertAllMaxJudgements(); } /// @@ -301,7 +393,7 @@ namespace osu.Game.Rulesets.Osu.Tests new OsuReplayFrame { Position = new Vector2(slider_path_length, OsuHitObject.OBJECT_RADIUS * 1.199f), Actions = { OsuAction.LeftButton }, Time = time_slider_end }, }); - AddAssert("Tracking kept", assertMaxJudge); + assertAllMaxJudgements(); } /// @@ -325,7 +417,13 @@ namespace osu.Game.Rulesets.Osu.Tests AddAssert("Tracking dropped", assertMidSliderJudgementFail); } - private bool assertMaxJudge() => judgementResults.Any() && judgementResults.All(t => t.Type == t.Judgement.MaxResult); + private void assertAllMaxJudgements() + { + AddAssert("All judgements max", () => + { + return judgementResults.Select(j => (j.HitObject, j.Type)); + }, () => Is.EqualTo(judgementResults.Select(j => (j.HitObject, j.Judgement.MaxResult)))); + } private bool assertHeadMissTailTracked() => judgementResults[^2].Type == HitResult.SmallTickHit && !judgementResults.First().IsHit; @@ -333,35 +431,36 @@ namespace osu.Game.Rulesets.Osu.Tests private bool assertMidSliderJudgementFail() => judgementResults[^2].Type == HitResult.SmallTickMiss; - private ScoreAccessibleReplayPlayer currentPlayer; - - private const float slider_path_length = 25; - - private void performTest(List frames) + private void performTest(List frames, Slider? slider = null, double? bpm = null, int? tickRate = null) { + slider ??= new Slider + { + StartTime = time_slider_start, + Position = new Vector2(0, 0), + SliderVelocityMultiplier = 0.1f, + Path = new SliderPath(PathType.PerfectCurve, new[] + { + Vector2.Zero, + new Vector2(slider_path_length, 0), + }, slider_path_length), + }; + AddStep("load player", () => { + var cpi = new ControlPointInfo(); + + if (bpm != null) + cpi.Add(0, new TimingControlPoint { BeatLength = 60000 / bpm.Value }); + Beatmap.Value = CreateWorkingBeatmap(new Beatmap { - HitObjects = - { - new Slider - { - StartTime = time_slider_start, - Position = new Vector2(0, 0), - SliderVelocityMultiplier = 0.1f, - Path = new SliderPath(PathType.PerfectCurve, new[] - { - Vector2.Zero, - new Vector2(slider_path_length, 0), - }, slider_path_length), - } - }, + HitObjects = { slider }, BeatmapInfo = { - Difficulty = new BeatmapDifficulty { SliderTickRate = 3 }, - Ruleset = new OsuRuleset().RulesetInfo + Difficulty = new BeatmapDifficulty { SliderTickRate = tickRate ?? 3 }, + Ruleset = new OsuRuleset().RulesetInfo, }, + ControlPointInfo = cpi, }); var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); @@ -375,7 +474,7 @@ namespace osu.Game.Rulesets.Osu.Tests }; LoadScreen(currentPlayer = p); - judgementResults = new List(); + judgementResults.Clear(); }); AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0); diff --git a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs index cf35b08822..3d1939acac 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Evaluators/AimEvaluator.cs @@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators /// and slider difficulty. /// /// - public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliders) + public static double EvaluateDifficultyOf(DifficultyHitObject current, bool withSliderTravelDistance) { if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner) return 0; @@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.StrainTime; // But if the last object is a slider, then we extend the travel velocity through the slider into the current object. - if (osuLastObj.BaseObject is Slider && withSliders) + if (osuLastObj.BaseObject is Slider && withSliderTravelDistance) { double travelVelocity = osuLastObj.TravelDistance / osuLastObj.TravelTime; // calculate the slider velocity from slider head to slider end. double movementVelocity = osuCurrObj.MinimumJumpDistance / osuCurrObj.MinimumJumpTime; // calculate the movement velocity from slider end to current object @@ -48,7 +48,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators // As above, do the same for the previous hitobject. double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.StrainTime; - if (osuLastLastObj.BaseObject is Slider && withSliders) + if (osuLastLastObj.BaseObject is Slider && withSliderTravelDistance) { double travelVelocity = osuLastLastObj.TravelDistance / osuLastLastObj.TravelTime; double movementVelocity = osuLastObj.MinimumJumpDistance / osuLastObj.MinimumJumpTime; @@ -122,7 +122,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier + velocityChangeBonus * velocity_change_multiplier); // Add in additional slider velocity bonus. - if (withSliders) + if (withSliderTravelDistance) aimStrain += sliderBonus * slider_multiplier; return aimStrain; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index e627c9ad67..488d1e2e9f 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Mods; @@ -214,7 +215,45 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing if (slider.LazyEndPosition != null) return; - slider.LazyTravelTime = slider.NestedHitObjects[^1].StartTime - slider.StartTime; + // TODO: This commented version is actually correct by the new lazer implementation, but intentionally held back from + // difficulty calculator to preserve known behaviour. + // double trackingEndTime = Math.Max( + // // SliderTailCircle always occurs at the final end time of the slider, but the player only needs to hold until within a lenience before it. + // slider.Duration + SliderEventGenerator.TAIL_LENIENCY, + // // There's an edge case where one or more ticks/repeats fall within that leniency range. + // // In such a case, the player needs to track until the final tick or repeat. + // slider.NestedHitObjects.LastOrDefault(n => n is not SliderTailCircle)?.StartTime ?? double.MinValue + // ); + + double trackingEndTime = Math.Max( + slider.StartTime + slider.Duration + SliderEventGenerator.TAIL_LENIENCY, + slider.StartTime + slider.Duration / 2 + ); + + IList nestedObjects = slider.NestedHitObjects; + + SliderTick? lastRealTick = slider.NestedHitObjects.OfType().LastOrDefault(); + + if (lastRealTick?.StartTime > trackingEndTime) + { + trackingEndTime = lastRealTick.StartTime; + + // When the last tick falls after the tracking end time, we need to re-sort the nested objects + // based on time. This creates a somewhat weird ordering which is counter to how a user would + // understand the slider, but allows a zero-diff with known diffcalc output. + // + // To reiterate, this is definitely not correct from a difficulty calculation perspective + // and should be revisited at a later date (likely by replacing this whole code with the commented + // version above). + List reordered = nestedObjects.ToList(); + + reordered.Remove(lastRealTick); + reordered.Add(lastRealTick); + + nestedObjects = reordered; + } + + slider.LazyTravelTime = trackingEndTime - slider.StartTime; double endTimeMin = slider.LazyTravelTime / slider.SpanDuration; if (endTimeMin % 2 >= 1) @@ -223,12 +262,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing endTimeMin %= 1; slider.LazyEndPosition = slider.StackedPosition + slider.Path.PositionAt(endTimeMin); // temporary lazy end position until a real result can be derived. - var currCursorPosition = slider.StackedPosition; + + Vector2 currCursorPosition = slider.StackedPosition; + double scalingFactor = NORMALISED_RADIUS / slider.Radius; // lazySliderDistance is coded to be sensitive to scaling, this makes the maths easier with the thresholds being used. - for (int i = 1; i < slider.NestedHitObjects.Count; i++) + for (int i = 1; i < nestedObjects.Count; i++) { - var currMovementObj = (OsuHitObject)slider.NestedHitObjects[i]; + var currMovementObj = (OsuHitObject)nestedObjects[i]; Vector2 currMovement = Vector2.Subtract(currMovementObj.StackedPosition, currCursorPosition); double currMovementLength = scalingFactor * currMovement.Length; @@ -236,7 +277,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing // Amount of movement required so that the cursor position needs to be updated. double requiredMovement = assumed_slider_radius; - if (i == slider.NestedHitObjects.Count - 1) + if (i == nestedObjects.Count - 1) { // The end of a slider has special aim rules due to the relaxed time constraint on position. // There is both a lazy end position as well as the actual end slider position. We assume the player takes the simpler movement. @@ -263,7 +304,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing slider.LazyTravelDistance += (float)currMovementLength; } - if (i == slider.NestedHitObjects.Count - 1) + if (i == nestedObjects.Count - 1) slider.LazyEndPosition = currCursorPosition; } } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs index f624ebc8b5..f891d23bbd 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/Components/PathControlPointVisualiser.cs @@ -47,7 +47,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components public Action> SplitControlPointsRequested; [Resolved(CanBeNull = true)] - private IDistanceSnapProvider snapProvider { get; set; } + private IPositionSnapProvider positionSnapProvider { get; set; } + + [Resolved(CanBeNull = true)] + private IDistanceSnapProvider distanceSnapProvider { get; set; } public PathControlPointVisualiser(T hitObject, bool allowSelection) { @@ -289,7 +292,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components { // Special handling for selections containing head control point - the position of the hit object changes which means the snapped position and time have to be taken into account Vector2 newHeadPosition = Parent!.ToScreenSpace(e.MousePosition + (dragStartPositions[0] - dragStartPositions[draggedControlPointIndex])); - var result = snapProvider?.FindSnappedPositionAndTime(newHeadPosition); + var result = positionSnapProvider?.FindSnappedPositionAndTime(newHeadPosition); Vector2 movementDelta = Parent!.ToLocalSpace(result?.ScreenSpacePosition ?? newHeadPosition) - hitObject.Position; @@ -309,7 +312,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components } else { - var result = snapProvider?.FindSnappedPositionAndTime(Parent!.ToScreenSpace(e.MousePosition), SnapType.GlobalGrids); + var result = positionSnapProvider?.FindSnappedPositionAndTime(Parent!.ToScreenSpace(e.MousePosition), SnapType.GlobalGrids); Vector2 movementDelta = Parent!.ToLocalSpace(result?.ScreenSpacePosition ?? Parent!.ToScreenSpace(e.MousePosition)) - dragStartPositions[draggedControlPointIndex] - hitObject.Position; @@ -322,7 +325,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components } // Snap the path to the current beat divisor before checking length validity. - hitObject.SnapTo(snapProvider); + hitObject.SnapTo(distanceSnapProvider); if (!hitObject.Path.HasValidLength) { @@ -332,7 +335,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components hitObject.Position = oldPosition; hitObject.StartTime = oldStartTime; // Snap the path length again to undo the invalid length. - hitObject.SnapTo(snapProvider); + hitObject.SnapTo(distanceSnapProvider); return; } diff --git a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs index ba7b855511..9b6adc04cf 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderPlacementBlueprint.cs @@ -39,7 +39,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders private int currentSegmentLength; [Resolved(CanBeNull = true)] - private IDistanceSnapProvider snapProvider { get; set; } + private IPositionSnapProvider positionSnapProvider { get; set; } + + [Resolved(CanBeNull = true)] + private IDistanceSnapProvider distanceSnapProvider { get; set; } protected override bool IsValidForPlacement => HitObject.Path.HasValidLength; @@ -198,7 +201,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders } // Update the cursor position. - var result = snapProvider?.FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position, state == SliderPlacementState.Body ? SnapType.GlobalGrids : SnapType.All); + var result = positionSnapProvider?.FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position, state == SliderPlacementState.Body ? SnapType.GlobalGrids : SnapType.All); cursor.Position = ToLocalSpace(result?.ScreenSpacePosition ?? inputManager.CurrentState.Mouse.Position) - HitObject.Position; } else if (cursor != null) @@ -230,7 +233,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders private void updateSlider() { - HitObject.Path.ExpectedDistance.Value = snapProvider?.FindSnappedDistance(HitObject, (float)HitObject.Path.CalculatedDistance) ?? (float)HitObject.Path.CalculatedDistance; + HitObject.Path.ExpectedDistance.Value = distanceSnapProvider?.FindSnappedDistance(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 02023decd6..80c4cee7f2 100644 --- a/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs +++ b/osu.Game.Rulesets.Osu/Edit/Blueprints/Sliders/SliderSelectionBlueprint.cs @@ -40,7 +40,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders protected PathControlPointVisualiser ControlPointVisualiser { get; private set; } [Resolved(CanBeNull = true)] - private IDistanceSnapProvider snapProvider { get; set; } + private IPositionSnapProvider positionSnapProvider { get; set; } + + [Resolved(CanBeNull = true)] + private IDistanceSnapProvider distanceSnapProvider { get; set; } [Resolved(CanBeNull = true)] private IPlacementHandler placementHandler { get; set; } @@ -194,7 +197,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders { if (placementControlPoint != null) { - var result = snapProvider?.FindSnappedPositionAndTime(ToScreenSpace(e.MousePosition)); + var result = positionSnapProvider?.FindSnappedPositionAndTime(ToScreenSpace(e.MousePosition)); placementControlPoint.Position = ToLocalSpace(result?.ScreenSpacePosition ?? ToScreenSpace(e.MousePosition)) - HitObject.Position; } } @@ -245,7 +248,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders // Move the control points from the insertion index onwards to make room for the insertion controlPoints.Insert(insertionIndex, pathControlPoint); - HitObject.SnapTo(snapProvider); + HitObject.SnapTo(distanceSnapProvider); return pathControlPoint; } @@ -267,7 +270,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders } // Snap the slider to the current beat divisor before checking length validity. - HitObject.SnapTo(snapProvider); + HitObject.SnapTo(distanceSnapProvider); // If there are 0 or 1 remaining control points, or the slider has an invalid length, it is in a degenerate form and should be deleted if (controlPoints.Count <= 1 || !HitObject.Path.HasValidLength) diff --git a/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapProvider.cs b/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapProvider.cs new file mode 100644 index 0000000000..522943df7d --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/OsuDistanceSnapProvider.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 osu.Game.Graphics.UserInterface; +using osu.Game.Input.Bindings; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu.Objects; +using osuTK; + +namespace osu.Game.Rulesets.Osu.Edit +{ + public partial class OsuDistanceSnapProvider : ComposerDistanceSnapProvider + { + protected override double ReadCurrentDistanceSnap(HitObject before, HitObject after) + { + float expectedDistance = DurationToDistance(before, after.StartTime - before.GetEndTime()); + float actualDistance = Vector2.Distance(((OsuHitObject)before).EndPosition, ((OsuHitObject)after).Position); + + return actualDistance / expectedDistance; + } + + protected override bool AdjustDistanceSpacing(GlobalAction action, float amount) + { + // To allow better visualisation, ensure that the spacing grid is visible before adjusting. + DistanceSnapToggle.Value = TernaryState.True; + + return base.AdjustDistanceSpacing(action, amount); + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs index fdc11be42c..0f8c960b65 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuHitObjectComposer.cs @@ -17,7 +17,6 @@ using osu.Framework.Input.Events; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; -using osu.Game.Input.Bindings; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Mods; @@ -30,7 +29,7 @@ using osuTK; namespace osu.Game.Rulesets.Osu.Edit { - public partial class OsuHitObjectComposer : DistancedHitObjectComposer + public partial class OsuHitObjectComposer : HitObjectComposer { public OsuHitObjectComposer(Ruleset ruleset) : base(ruleset) @@ -49,18 +48,27 @@ namespace osu.Game.Rulesets.Osu.Edit private readonly Bindable rectangularGridSnapToggle = new Bindable(); - protected override IEnumerable CreateTernaryButtons() => base.CreateTernaryButtons().Concat(new[] - { - new TernaryButton(rectangularGridSnapToggle, "Grid Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Th }) - }); + protected override IEnumerable CreateTernaryButtons() + => base.CreateTernaryButtons() + .Concat(DistanceSnapProvider.CreateTernaryButtons()) + .Concat(new[] + { + new TernaryButton(rectangularGridSnapToggle, "Grid Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Th }) + }); private BindableList selectedHitObjects; private Bindable placementObject; + [Cached(typeof(IDistanceSnapProvider))] + protected readonly OsuDistanceSnapProvider DistanceSnapProvider = new OsuDistanceSnapProvider(); + [BackgroundDependencyLoader] private void load() { + AddInternal(DistanceSnapProvider); + DistanceSnapProvider.AttachToToolbox(RightToolbox); + // Give a bit of breathing room around the playfield content. PlayfieldContentContainer.Padding = new MarginPadding(10); @@ -81,7 +89,7 @@ namespace osu.Game.Rulesets.Osu.Edit placementObject = EditorBeatmap.PlacementObject.GetBoundCopy(); placementObject.ValueChanged += _ => updateDistanceSnapGrid(); - DistanceSnapToggle.ValueChanged += _ => updateDistanceSnapGrid(); + DistanceSnapProvider.DistanceSnapToggle.ValueChanged += _ => updateDistanceSnapGrid(); // we may be entering the screen with a selection already active updateDistanceSnapGrid(); @@ -106,14 +114,6 @@ namespace osu.Game.Rulesets.Osu.Edit private RectangularPositionSnapGrid rectangularPositionSnapGrid; - protected override double ReadCurrentDistanceSnap(HitObject before, HitObject after) - { - float expectedDistance = DurationToDistance(before, after.StartTime - before.GetEndTime()); - float actualDistance = Vector2.Distance(((OsuHitObject)before).EndPosition, ((OsuHitObject)after).Position); - - return actualDistance / expectedDistance; - } - protected override void Update() { base.Update(); @@ -143,7 +143,7 @@ namespace osu.Game.Rulesets.Osu.Edit // We want to ensure that in this particular case, the time-snapping component of distance snap is still applied. // The easiest way to ensure this is to attempt application of distance snap after a nearby object is found, and copy over // the time value if the proposed positions are roughly the same. - if (snapType.HasFlagFast(SnapType.RelativeGrids) && DistanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) + if (snapType.HasFlagFast(SnapType.RelativeGrids) && DistanceSnapProvider.DistanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) { (Vector2 distanceSnappedPosition, double distanceSnappedTime) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(snapResult.ScreenSpacePosition)); if (Precision.AlmostEquals(distanceSnapGrid.ToScreenSpace(distanceSnappedPosition), snapResult.ScreenSpacePosition, 1)) @@ -157,7 +157,7 @@ namespace osu.Game.Rulesets.Osu.Edit if (snapType.HasFlagFast(SnapType.RelativeGrids)) { - if (DistanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) + if (DistanceSnapProvider.DistanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null) { (Vector2 pos, double time) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(screenSpacePosition)); @@ -220,7 +220,7 @@ namespace osu.Game.Rulesets.Osu.Edit distanceSnapGridCache.Invalidate(); distanceSnapGrid = null; - if (DistanceSnapToggle.Value != TernaryState.True) + if (DistanceSnapProvider.DistanceSnapToggle.Value != TernaryState.True) return; switch (BlueprintContainer.CurrentTool) @@ -262,14 +262,6 @@ namespace osu.Game.Rulesets.Osu.Edit base.OnKeyUp(e); } - protected override bool AdjustDistanceSpacing(GlobalAction action, float amount) - { - // To allow better visualisation, ensure that the spacing grid is visible before adjusting. - DistanceSnapToggle.Value = TernaryState.True; - - return base.AdjustDistanceSpacing(action, amount); - } - private bool gridSnapMomentary; private void handleToggleViaKey(KeyboardEvent key) diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs index 78062a0632..c465ab8732 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModStrictTracking.cs @@ -129,7 +129,7 @@ namespace osu.Game.Rulesets.Osu.Mods }); break; - case SliderEventType.LastTick: + case SliderEventType.Tail: AddNested(TailCircle = new StrictTrackingSliderTailCircle(this) { RepeatIndex = e.SpanIndex, diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs index 789755652f..09973ce5fd 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSlider.cs @@ -264,7 +264,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables protected override void CheckForResult(bool userTriggered, double timeOffset) { - if (userTriggered || Time.Current < HitObject.EndTime) + if (userTriggered || !TailCircle.Judged || Time.Current < HitObject.EndTime) return; // If only the nested hitobjects are judged, then the slider's own judgement is ignored for scoring purposes. diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs index 47214f1e53..292f2ffd7d 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderBall.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input; using osu.Framework.Input.Events; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Skinning.Default; @@ -153,9 +154,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables Tracking = // in valid time range - Time.Current >= drawableSlider.HitObject.StartTime && Time.Current < drawableSlider.HitObject.EndTime && + Time.Current >= drawableSlider.HitObject.StartTime + // even in an edge case where current time has exceeded the slider's time, we may not have finished judging. + // we don't want to potentially update from Tracking=true to Tracking=false at this point. + && (!drawableSlider.AllJudged || Time.Current <= drawableSlider.HitObject.GetEndTime()) // in valid position range - lastScreenSpaceMousePosition.HasValue && followCircleReceptor.ReceivePositionalInputAt(lastScreenSpaceMousePosition.Value) && + && lastScreenSpaceMousePosition.HasValue && followCircleReceptor.ReceivePositionalInputAt(lastScreenSpaceMousePosition.Value) && // valid action (actions?.Any(isValidTrackingAction) ?? false); diff --git a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs index 9fbc97c484..df55f3e73f 100644 --- a/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs +++ b/osu.Game.Rulesets.Osu/Objects/Drawables/DrawableSliderTail.cs @@ -8,6 +8,7 @@ using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; using osu.Game.Skinning; @@ -125,8 +126,22 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables protected override void CheckForResult(bool userTriggered, double timeOffset) { - if (!userTriggered && timeOffset >= 0) - ApplyResult(r => r.Type = Tracking ? r.Judgement.MaxResult : r.Judgement.MinResult); + if (userTriggered) + return; + + // Ensure the tail can only activate after all previous ticks already have. + // + // This covers the edge case where the lenience may allow the tail to activate before + // the last tick, changing ordering of score/combo awarding. + if (DrawableSlider.NestedHitObjects.Count > 1 && !DrawableSlider.NestedHitObjects[^2].Judged) + return; + + // The player needs to have engaged in tracking at any point after the tail leniency cutoff. + // An actual tick miss should only occur if reaching the tick itself. + if (timeOffset >= SliderEventGenerator.TAIL_LENIENCY && Tracking) + ApplyResult(r => r.Type = r.Judgement.MaxResult); + else if (timeOffset > 0) + ApplyResult(r => r.Type = r.Judgement.MinResult); } protected override void OnApply() diff --git a/osu.Game.Rulesets.Osu/Objects/Slider.cs b/osu.Game.Rulesets.Osu/Objects/Slider.cs index 443e4229d2..c62659d67a 100644 --- a/osu.Game.Rulesets.Osu/Objects/Slider.cs +++ b/osu.Game.Rulesets.Osu/Objects/Slider.cs @@ -204,11 +204,7 @@ namespace osu.Game.Rulesets.Osu.Objects }); break; - case SliderEventType.LastTick: - // Of note, we are directly mapping LastTick (instead of `SliderEventType.Tail`) to SliderTailCircle. - // It is required as difficulty calculation and gameplay relies on reading this value. - // (although it is displayed in classic skins, which may be a concern). - // If this is to change, we should revisit this. + case SliderEventType.Tail: AddNested(TailCircle = new SliderTailCircle(this) { RepeatIndex = e.SpanIndex, diff --git a/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs b/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs index fb81936837..54d2afb444 100644 --- a/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs +++ b/osu.Game.Rulesets.Osu/Objects/SliderTailCircle.cs @@ -2,16 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Rulesets.Judgements; -using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Osu.Objects { - /// - /// Note that this should not be used for timing correctness. - /// See usage in for more information. - /// public class SliderTailCircle : SliderEndCircle { public SliderTailCircle(Slider slider) diff --git a/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/very-fast-slider.osu b/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/very-fast-slider.osu new file mode 100644 index 0000000000..58ef36e70c --- /dev/null +++ b/osu.Game.Rulesets.Osu/Resources/Testing/Beatmaps/very-fast-slider.osu @@ -0,0 +1,21 @@ +osu file format v128 + +[Difficulty] +HPDrainRate: 3 +CircleSize: 4 +OverallDifficulty: 9 +ApproachRate: 9.3 +SliderMultiplier: 3.59999990463257 +SliderTickRate: 1 + +[TimingPoints] +812,342.857142857143,4,1,1,70,1,0 +57383,-28.5714285714286,4,1,1,70,0,0 + +[HitObjects] +// Taken from https://osu.ppy.sh/beatmapsets/881996#osu/1844019 +// This slider is 42 ms in length, triggering the LegacyLastTick edge case. +// The tick will be at 21.5 ms (sliderDuration / 2) instead of 6 ms (sliderDuration - LAST_TICK_LENIENCE). +416,41,57383,6,0,L|467:217,1,157.499997329712,2|0,3:3|3:0,3:0:0:0: +// Include the next slider as well to cover the jump back to the start position. +407,73,57469,2,0,L|470:215,1,129.599999730835,2|0,0:0|0:0,0:0:0:0: diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoBeatSnapGrid.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoBeatSnapGrid.cs new file mode 100644 index 0000000000..1b6794974f --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoBeatSnapGrid.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 System.Collections.Generic; +using osu.Framework.Graphics.Containers; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Taiko.UI; +using osu.Game.Screens.Edit.Compose.Components; + +namespace osu.Game.Rulesets.Taiko.Edit +{ + public partial class TaikoBeatSnapGrid : BeatSnapGrid + { + protected override IEnumerable GetTargetContainers(HitObjectComposer composer) => new[] + { + ((TaikoPlayfield)composer.Playfield).UnderlayElements + }; + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs index 5ae4757b8f..6020f6e04c 100644 --- a/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoHitObjectComposer.cs @@ -33,5 +33,7 @@ namespace osu.Game.Rulesets.Taiko.Edit protected override ComposeBlueprintContainer CreateBlueprintContainer() => new TaikoBlueprintContainer(this); + + protected override BeatSnapGrid CreateBeatSnapGrid() => new TaikoBeatSnapGrid(); } } diff --git a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs index 23ffac1f63..31f8171290 100644 --- a/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs +++ b/osu.Game.Rulesets.Taiko/UI/TaikoPlayfield.cs @@ -40,6 +40,8 @@ namespace osu.Game.Rulesets.Taiko.UI /// public Bindable ClassicHitTargetPosition = new BindableBool(); + public Container UnderlayElements { get; private set; } = null!; + private Container hitExplosionContainer; private Container kiaiExplosionContainer; private JudgementContainer judgementContainer; @@ -130,7 +132,14 @@ namespace osu.Game.Rulesets.Taiko.UI { Name = "Bar line content", RelativeSizeAxes = Axes.Both, - Child = barLinePlayfield = new BarLinePlayfield(), + Children = new Drawable[] + { + UnderlayElements = new Container + { + RelativeSizeAxes = Axes.Both, + }, + barLinePlayfield = new BarLinePlayfield(), + } }, hitObjectContent = new Container { diff --git a/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs b/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs index 37a91c8611..c7cf3fe956 100644 --- a/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs +++ b/osu.Game.Tests/Beatmaps/SliderEventGenerationTest.cs @@ -87,8 +87,8 @@ namespace osu.Game.Tests.Beatmaps { var events = SliderEventGenerator.Generate(start_time, span_duration, 1, span_duration / 2, span_duration, 1).ToArray(); - Assert.That(events[2].Type, Is.EqualTo(SliderEventType.LastTick)); - Assert.That(events[2].Time, Is.EqualTo(span_duration + SliderEventGenerator.LAST_TICK_OFFSET)); + Assert.That(events[2].Type, Is.EqualTo(SliderEventType.LegacyLastTick)); + Assert.That(events[2].Time, Is.EqualTo(span_duration + SliderEventGenerator.TAIL_LENIENCY)); } [Test] diff --git a/osu.Game.Tests/Database/RealmTest.cs b/osu.Game.Tests/Database/RealmTest.cs index d2779e3038..2812a97268 100644 --- a/osu.Game.Tests/Database/RealmTest.cs +++ b/osu.Game.Tests/Database/RealmTest.cs @@ -35,6 +35,7 @@ namespace osu.Game.Tests.Database Logger.Log($"Running test using realm file {testStorage.GetFullPath(realm.Filename)}"); testAction(realm, testStorage); + // ReSharper disable once DisposeOnUsingVariable realm.Dispose(); Logger.Log($"Final database size: {getFileSize(testStorage, realm)}"); @@ -58,6 +59,7 @@ namespace osu.Game.Tests.Database Logger.Log($"Running test using realm file {testStorage.GetFullPath(realm.Filename)}"); await testAction(realm, testStorage); + // ReSharper disable once DisposeOnUsingVariable realm.Dispose(); Logger.Log($"Final database size: {getFileSize(testStorage, realm)}"); diff --git a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs index 6b78eea009..463287fb35 100644 --- a/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs +++ b/osu.Game.Tests/Editing/TestSceneHitObjectComposerDistanceSnapping.cs @@ -3,7 +3,6 @@ using NUnit.Framework; using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Cursor; @@ -230,25 +229,25 @@ namespace osu.Game.Tests.Editing } private void assertSnapDistance(float expectedDistance, HitObject? referenceObject, bool includeSliderVelocity) - => AddAssert($"distance is {expectedDistance}", () => composer.GetBeatSnapDistanceAt(referenceObject ?? new HitObject(), includeSliderVelocity), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); + => AddAssert($"distance is {expectedDistance}", () => composer.DistanceSnapProvider.GetBeatSnapDistanceAt(referenceObject ?? new HitObject(), includeSliderVelocity), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); private void assertDurationToDistance(double duration, float expectedDistance, HitObject? referenceObject = null) - => AddAssert($"duration = {duration} -> distance = {expectedDistance}", () => composer.DurationToDistance(referenceObject ?? new HitObject(), duration), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); + => AddAssert($"duration = {duration} -> distance = {expectedDistance}", () => composer.DistanceSnapProvider.DurationToDistance(referenceObject ?? new HitObject(), duration), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); private void assertDistanceToDuration(float distance, double expectedDuration, HitObject? referenceObject = null) - => AddAssert($"distance = {distance} -> duration = {expectedDuration}", () => composer.DistanceToDuration(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDuration).Within(Precision.FLOAT_EPSILON)); + => AddAssert($"distance = {distance} -> duration = {expectedDuration}", () => composer.DistanceSnapProvider.DistanceToDuration(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDuration).Within(Precision.FLOAT_EPSILON)); private void assertSnappedDuration(float distance, double expectedDuration, HitObject? referenceObject = null) - => AddAssert($"distance = {distance} -> duration = {expectedDuration} (snapped)", () => composer.FindSnappedDuration(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDuration).Within(Precision.FLOAT_EPSILON)); + => AddAssert($"distance = {distance} -> duration = {expectedDuration} (snapped)", () => composer.DistanceSnapProvider.FindSnappedDuration(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDuration).Within(Precision.FLOAT_EPSILON)); private void assertSnappedDistance(float distance, float expectedDistance, HitObject? referenceObject = null) - => AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)", () => composer.FindSnappedDistance(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); + => AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)", () => composer.DistanceSnapProvider.FindSnappedDistance(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON)); private partial class TestHitObjectComposer : OsuHitObjectComposer { public new EditorBeatmap EditorBeatmap => base.EditorBeatmap; - public new Bindable DistanceSpacingMultiplier => base.DistanceSpacingMultiplier; + public new IDistanceSnapProvider DistanceSnapProvider => base.DistanceSnapProvider; public TestHitObjectComposer() : base(new OsuRuleset()) diff --git a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs index 70e4420a45..f2a015402a 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneDistanceSnapGrid.cs @@ -187,11 +187,9 @@ namespace osu.Game.Tests.Visual.Editing private class SnapProvider : IDistanceSnapProvider { - public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.AllGrids) => new SnapResult(screenSpacePosition, 0); - public Bindable DistanceSpacingMultiplier { get; } = new BindableDouble(1); - IBindable IDistanceSnapProvider.DistanceSpacingMultiplier => DistanceSpacingMultiplier; + Bindable IDistanceSnapProvider.DistanceSpacingMultiplier => DistanceSpacingMultiplier; public float GetBeatSnapDistanceAt(HitObject referenceObject, bool useReferenceSliderVelocity = true) => beat_snap_distance; diff --git a/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs b/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs similarity index 81% rename from osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs rename to osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs index 70b79c30f0..0b1809e7d9 100644 --- a/osu.Game/Rulesets/Edit/DistancedHitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/ComposerDistanceSnapProvider.cs @@ -1,8 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -24,16 +23,13 @@ using osu.Game.Overlays.OSD; using osu.Game.Overlays.Settings.Sections; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.UI; +using osu.Game.Screens.Edit; using osu.Game.Screens.Edit.Components.TernaryButtons; namespace osu.Game.Rulesets.Edit { - /// - /// Represents a for rulesets with the concept of distances between objects. - /// - /// The base type of supported objects. - public abstract partial class DistancedHitObjectComposer : HitObjectComposer, IDistanceSnapProvider, IScrollBindingHandler - where TObject : HitObject + public abstract partial class ComposerDistanceSnapProvider : Component, IDistanceSnapProvider, IScrollBindingHandler { private const float adjust_step = 0.1f; @@ -44,27 +40,38 @@ namespace osu.Game.Rulesets.Edit Precision = 0.01, }; - IBindable IDistanceSnapProvider.DistanceSpacingMultiplier => DistanceSpacingMultiplier; + Bindable IDistanceSnapProvider.DistanceSpacingMultiplier => DistanceSpacingMultiplier; - private ExpandableSlider> distanceSpacingSlider; - private ExpandableButton currentDistanceSpacingButton; + private ExpandableSlider> distanceSpacingSlider = null!; + private ExpandableButton currentDistanceSpacingButton = null!; - [Resolved(canBeNull: true)] - private OnScreenDisplay onScreenDisplay { get; set; } + [Resolved] + private Playfield playfield { get; set; } = null!; - protected readonly Bindable DistanceSnapToggle = new Bindable(); + [Resolved] + private EditorClock editorClock { get; set; } = null!; + + [Resolved] + private EditorBeatmap editorBeatmap { get; set; } = null!; + + [Resolved] + private IBeatSnapProvider beatSnapProvider { get; set; } = null!; + + [Resolved] + private OnScreenDisplay? onScreenDisplay { get; set; } + + public readonly Bindable DistanceSnapToggle = new Bindable(); private bool distanceSnapMomentary; - protected DistancedHitObjectComposer(Ruleset ruleset) - : base(ruleset) - { - } + private EditorToolboxGroup? toolboxGroup; - [BackgroundDependencyLoader] - private void load() + public void AttachToToolbox(ExpandingToolboxContainer toolboxContainer) { - RightToolbox.Add(new EditorToolboxGroup("snapping") + if (toolboxGroup != null) + throw new InvalidOperationException($"{nameof(AttachToToolbox)} may be called only once for a single {nameof(ComposerDistanceSnapProvider)} instance."); + + toolboxContainer.Add(toolboxGroup = new EditorToolboxGroup("snapping") { Alpha = DistanceSpacingMultiplier.Disabled ? 0 : 1, Children = new Drawable[] @@ -90,16 +97,42 @@ namespace osu.Game.Rulesets.Edit } } }); + + if (DistanceSpacingMultiplier.Disabled) + { + distanceSpacingSlider.Hide(); + return; + } + + DistanceSpacingMultiplier.Value = editorBeatmap.BeatmapInfo.DistanceSpacing; + DistanceSpacingMultiplier.BindValueChanged(multiplier => + { + distanceSpacingSlider.ContractedLabelText = $"D. S. ({multiplier.NewValue:0.##x})"; + distanceSpacingSlider.ExpandedLabelText = $"Distance Spacing ({multiplier.NewValue:0.##x})"; + + if (multiplier.NewValue != multiplier.OldValue) + onScreenDisplay?.Display(new DistanceSpacingToast(multiplier.NewValue.ToLocalisableString(@"0.##x"), multiplier)); + + editorBeatmap.BeatmapInfo.DistanceSpacing = multiplier.NewValue; + }, true); + + // Manual binding to handle enabling distance spacing when the slider is interacted with. + distanceSpacingSlider.Current.BindValueChanged(spacing => + { + DistanceSpacingMultiplier.Value = spacing.NewValue; + DistanceSnapToggle.Value = TernaryState.True; + }); + DistanceSpacingMultiplier.BindValueChanged(spacing => distanceSpacingSlider.Current.Value = spacing.NewValue); } private (HitObject before, HitObject after)? getObjectsOnEitherSideOfCurrentTime() { - HitObject lastBefore = Playfield.HitObjectContainer.AliveObjects.LastOrDefault(h => h.HitObject.StartTime < EditorClock.CurrentTime)?.HitObject; + HitObject? lastBefore = playfield.HitObjectContainer.AliveObjects.LastOrDefault(h => h.HitObject.StartTime < editorClock.CurrentTime)?.HitObject; if (lastBefore == null) return null; - HitObject firstAfter = Playfield.HitObjectContainer.AliveObjects.FirstOrDefault(h => h.HitObject.StartTime >= EditorClock.CurrentTime)?.HitObject; + HitObject? firstAfter = playfield.HitObjectContainer.AliveObjects.FirstOrDefault(h => h.HitObject.StartTime >= editorClock.CurrentTime)?.HitObject; if (firstAfter == null) return null; @@ -138,41 +171,10 @@ namespace osu.Game.Rulesets.Edit } } - protected override void LoadComplete() - { - base.LoadComplete(); - - if (DistanceSpacingMultiplier.Disabled) - { - distanceSpacingSlider.Hide(); - return; - } - - DistanceSpacingMultiplier.Value = EditorBeatmap.BeatmapInfo.DistanceSpacing; - DistanceSpacingMultiplier.BindValueChanged(multiplier => - { - distanceSpacingSlider.ContractedLabelText = $"D. S. ({multiplier.NewValue:0.##x})"; - distanceSpacingSlider.ExpandedLabelText = $"Distance Spacing ({multiplier.NewValue:0.##x})"; - - if (multiplier.NewValue != multiplier.OldValue) - onScreenDisplay?.Display(new DistanceSpacingToast(multiplier.NewValue.ToLocalisableString(@"0.##x"), multiplier)); - - EditorBeatmap.BeatmapInfo.DistanceSpacing = multiplier.NewValue; - }, true); - - // Manual binding to handle enabling distance spacing when the slider is interacted with. - distanceSpacingSlider.Current.BindValueChanged(spacing => - { - DistanceSpacingMultiplier.Value = spacing.NewValue; - DistanceSnapToggle.Value = TernaryState.True; - }); - DistanceSpacingMultiplier.BindValueChanged(spacing => distanceSpacingSlider.Current.Value = spacing.NewValue); - } - - protected override IEnumerable CreateTernaryButtons() => base.CreateTernaryButtons().Concat(new[] + public IEnumerable CreateTernaryButtons() => new[] { new TernaryButton(DistanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Ruler }) - }); + }; protected override bool OnKeyDown(KeyDownEvent e) { @@ -242,26 +244,28 @@ namespace osu.Game.Rulesets.Edit return true; } + #region IDistanceSnapProvider + public virtual float GetBeatSnapDistanceAt(HitObject referenceObject, bool useReferenceSliderVelocity = true) { - return (float)(100 * (useReferenceSliderVelocity && referenceObject is IHasSliderVelocity hasSliderVelocity ? hasSliderVelocity.SliderVelocityMultiplier : 1) * EditorBeatmap.Difficulty.SliderMultiplier * 1 - / BeatSnapProvider.BeatDivisor); + return (float)(100 * (useReferenceSliderVelocity && referenceObject is IHasSliderVelocity hasSliderVelocity ? hasSliderVelocity.SliderVelocityMultiplier : 1) * editorBeatmap.Difficulty.SliderMultiplier * 1 + / beatSnapProvider.BeatDivisor); } public virtual float DurationToDistance(HitObject referenceObject, double duration) { - double beatLength = BeatSnapProvider.GetBeatLengthAtTime(referenceObject.StartTime); + double beatLength = beatSnapProvider.GetBeatLengthAtTime(referenceObject.StartTime); return (float)(duration / beatLength * GetBeatSnapDistanceAt(referenceObject)); } public virtual double DistanceToDuration(HitObject referenceObject, float distance) { - double beatLength = BeatSnapProvider.GetBeatLengthAtTime(referenceObject.StartTime); + double beatLength = beatSnapProvider.GetBeatLengthAtTime(referenceObject.StartTime); return distance / GetBeatSnapDistanceAt(referenceObject) * beatLength; } public virtual double FindSnappedDuration(HitObject referenceObject, float distance) - => BeatSnapProvider.SnapTime(referenceObject.StartTime + DistanceToDuration(referenceObject, distance), referenceObject.StartTime) - referenceObject.StartTime; + => beatSnapProvider.SnapTime(referenceObject.StartTime + DistanceToDuration(referenceObject, distance), referenceObject.StartTime) - referenceObject.StartTime; public virtual float FindSnappedDistance(HitObject referenceObject, float distance) { @@ -269,9 +273,9 @@ namespace osu.Game.Rulesets.Edit double actualDuration = startTime + DistanceToDuration(referenceObject, distance); - double snappedEndTime = BeatSnapProvider.SnapTime(actualDuration, startTime); + double snappedEndTime = beatSnapProvider.SnapTime(actualDuration, startTime); - double beatLength = BeatSnapProvider.GetBeatLengthAtTime(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. @@ -281,6 +285,8 @@ namespace osu.Game.Rulesets.Edit return DurationToDistance(referenceObject, snappedEndTime - startTime); } + #endregion + private partial class DistanceSpacingToast : Toast { private readonly ValueChangedEvent change; diff --git a/osu.Game/Rulesets/Edit/HitObjectComposer.cs b/osu.Game/Rulesets/Edit/HitObjectComposer.cs index f9a6b5083e..07e5869e28 100644 --- a/osu.Game/Rulesets/Edit/HitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/HitObjectComposer.cs @@ -76,7 +76,7 @@ namespace osu.Game.Rulesets.Edit protected readonly Container LayerBelowRuleset = new Container { RelativeSizeAxes = Axes.Both }; - private InputManager inputManager; + protected InputManager InputManager { get; private set; } private EditorRadioButtonCollection toolboxCollection; @@ -119,9 +119,12 @@ namespace osu.Game.Rulesets.Edit return; } + if (DrawableRuleset is IDrawableScrollingRuleset scrollingRuleset) + dependencies.CacheAs(scrollingRuleset.ScrollingInfo); + dependencies.CacheAs(Playfield); - InternalChildren = new Drawable[] + InternalChildren = new[] { PlayfieldContentContainer = new Container { @@ -201,7 +204,7 @@ namespace osu.Game.Rulesets.Edit }, } } - } + }, }; toolboxCollection.Items = CompositionTools @@ -232,7 +235,7 @@ namespace osu.Game.Rulesets.Edit { base.LoadComplete(); - inputManager = GetContainingInputManager(); + InputManager = GetContainingInputManager(); hasTiming = EditorBeatmap.HasTiming.GetBoundCopy(); hasTiming.BindValueChanged(timing => @@ -270,7 +273,7 @@ namespace osu.Game.Rulesets.Edit public override IEnumerable HitObjects => drawableRulesetWrapper.Playfield.AllHitObjects; - public override bool CursorInPlacementArea => drawableRulesetWrapper.Playfield.ReceivePositionalInputAt(inputManager.CurrentState.Mouse.Position); + public override bool CursorInPlacementArea => drawableRulesetWrapper.Playfield.ReceivePositionalInputAt(InputManager.CurrentState.Mouse.Position); /// /// Defines all available composition tools, listed on the left side of the editor screen as button controls. diff --git a/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs b/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs index da44b42831..380038eadf 100644 --- a/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs +++ b/osu.Game/Rulesets/Edit/IDistanceSnapProvider.cs @@ -12,14 +12,14 @@ namespace osu.Game.Rulesets.Edit /// A snap provider which given a reference hit object and proposed distance from it, offers a more correct duration or distance value. /// [Cached] - public interface IDistanceSnapProvider : IPositionSnapProvider + public interface IDistanceSnapProvider { /// /// A multiplier which changes the ratio of distance travelled per time unit. /// Importantly, this is provided for manual usage, and not multiplied into any of the methods exposed by this interface. /// /// - IBindable DistanceSpacingMultiplier { get; } + Bindable DistanceSpacingMultiplier { get; } /// /// Retrieves the distance between two points within a timing point that are one beat length apart. diff --git a/osu.Game/Rulesets/Edit/ScrollingHitObjectComposer.cs b/osu.Game/Rulesets/Edit/ScrollingHitObjectComposer.cs index cb92ebbd15..eb73cef01a 100644 --- a/osu.Game/Rulesets/Edit/ScrollingHitObjectComposer.cs +++ b/osu.Game/Rulesets/Edit/ScrollingHitObjectComposer.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.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -8,9 +9,11 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Sprites; using osu.Game.Configuration; using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Screens.Edit.Components.TernaryButtons; +using osu.Game.Screens.Edit.Compose.Components; using osuTK; namespace osu.Game.Rulesets.Edit @@ -21,6 +24,13 @@ namespace osu.Game.Rulesets.Edit private readonly Bindable showSpeedChanges = new Bindable(); private Bindable configShowSpeedChanges = null!; + private BeatSnapGrid? beatSnapGrid; + + /// + /// Construct an optional beat snap grid. + /// + protected virtual BeatSnapGrid? CreateBeatSnapGrid() => null; + protected ScrollingHitObjectComposer(Ruleset ruleset) : base(ruleset) { @@ -57,6 +67,42 @@ namespace osu.Game.Rulesets.Edit configShowSpeedChanges.Value = enabled; }, true); } + + beatSnapGrid = CreateBeatSnapGrid(); + + if (beatSnapGrid != null) + AddInternal(beatSnapGrid); + } + + protected override void UpdateAfterChildren() + { + base.UpdateAfterChildren(); + + updateBeatSnapGrid(); + } + + private void updateBeatSnapGrid() + { + if (beatSnapGrid == null) + return; + + if (BlueprintContainer.CurrentTool is SelectTool) + { + if (EditorBeatmap.SelectedHitObjects.Any()) + { + beatSnapGrid.SelectionTimeRange = (EditorBeatmap.SelectedHitObjects.Min(h => h.StartTime), EditorBeatmap.SelectedHitObjects.Max(h => h.GetEndTime())); + } + else + beatSnapGrid.SelectionTimeRange = null; + } + else + { + var result = FindSnappedPositionAndTime(InputManager.CurrentState.Mouse.Position); + if (result.Time is double time) + beatSnapGrid.SelectionTimeRange = (time, time); + else + beatSnapGrid.SelectionTimeRange = null; + } } } } diff --git a/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs b/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs index f62ba21827..607e6b8399 100644 --- a/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs +++ b/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs @@ -71,7 +71,6 @@ namespace osu.Game.Rulesets.Mods // Apply a fixed rate change when missing, allowing the player to catch up when the rate is too fast. private const double rate_change_on_miss = 0.95d; - private IAdjustableAudioComponent? track; private double targetRate = 1d; /// @@ -123,24 +122,27 @@ namespace osu.Game.Rulesets.Mods /// private readonly Dictionary ratesForRewinding = new Dictionary(); + private readonly RateAdjustModHelper rateAdjustHelper; + public ModAdaptiveSpeed() { + rateAdjustHelper = new RateAdjustModHelper(SpeedChange); + rateAdjustHelper.HandleAudioAdjustments(AdjustPitch); + InitialRate.BindValueChanged(val => { SpeedChange.Value = val.NewValue; targetRate = val.NewValue; }); - AdjustPitch.BindValueChanged(adjustPitchChanged); } public void ApplyToTrack(IAdjustableAudioComponent track) { - this.track = track; - InitialRate.TriggerChange(); - AdjustPitch.TriggerChange(); recentRates.Clear(); recentRates.AddRange(Enumerable.Repeat(InitialRate.Value, recent_rate_count)); + + rateAdjustHelper.ApplyToTrack(track); } public void ApplyToSample(IAdjustableAudioComponent sample) @@ -199,15 +201,6 @@ namespace osu.Game.Rulesets.Mods } } - private void adjustPitchChanged(ValueChangedEvent adjustPitchSetting) - { - track?.RemoveAdjustment(adjustmentForPitchSetting(adjustPitchSetting.OldValue), SpeedChange); - track?.AddAdjustment(adjustmentForPitchSetting(adjustPitchSetting.NewValue), SpeedChange); - } - - private AdjustableProperty adjustmentForPitchSetting(bool adjustPitchSettingValue) - => adjustPitchSettingValue ? AdjustableProperty.Frequency : AdjustableProperty.Tempo; - private IEnumerable getAllApplicableHitObjects(IEnumerable hitObjects) { foreach (var hitObject in hitObjects) diff --git a/osu.Game/Rulesets/Mods/ModDaycore.cs b/osu.Game/Rulesets/Mods/ModDaycore.cs index de1a5ab56c..39ebd1fe4c 100644 --- a/osu.Game/Rulesets/Mods/ModDaycore.cs +++ b/osu.Game/Rulesets/Mods/ModDaycore.cs @@ -5,21 +5,36 @@ using osu.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Game.Configuration; namespace osu.Game.Rulesets.Mods { - public abstract class ModDaycore : ModHalfTime + public abstract class ModDaycore : ModRateAdjust { public override string Name => "Daycore"; public override string Acronym => "DC"; public override IconUsage? Icon => null; + public override ModType Type => ModType.DifficultyReduction; public override LocalisableString Description => "Whoaaaaa..."; + [SettingSource("Speed decrease", "The actual decrease to apply")] + public override BindableNumber SpeedChange { get; } = new BindableDouble(0.75) + { + MinValue = 0.5, + MaxValue = 0.99, + Precision = 0.01, + }; + private readonly BindableNumber tempoAdjust = new BindableDouble(1); private readonly BindableNumber freqAdjust = new BindableDouble(1); + private readonly RateAdjustModHelper rateAdjustHelper; protected ModDaycore() { + rateAdjustHelper = new RateAdjustModHelper(SpeedChange); + + // intentionally not deferring the speed change handling to `RateAdjustModHelper` + // as the expected result of operation is not the same (daycore should preserve constant pitch). SpeedChange.BindValueChanged(val => { freqAdjust.Value = SpeedChange.Default; @@ -29,9 +44,10 @@ namespace osu.Game.Rulesets.Mods public override void ApplyToTrack(IAdjustableAudioComponent track) { - // base.ApplyToTrack() intentionally not called (different tempo adjustment is applied) track.AddAdjustment(AdjustableProperty.Frequency, freqAdjust); track.AddAdjustment(AdjustableProperty.Tempo, tempoAdjust); } + + public override double ScoreMultiplier => rateAdjustHelper.ScoreMultiplier; } } diff --git a/osu.Game/Rulesets/Mods/ModDoubleTime.cs b/osu.Game/Rulesets/Mods/ModDoubleTime.cs index 27e594edfe..789291772d 100644 --- a/osu.Game/Rulesets/Mods/ModDoubleTime.cs +++ b/osu.Game/Rulesets/Mods/ModDoubleTime.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.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; @@ -26,21 +27,22 @@ namespace osu.Game.Rulesets.Mods Precision = 0.01, }; - public override double ScoreMultiplier + [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] + public virtual BindableBool AdjustPitch { get; } = new BindableBool(); + + private readonly RateAdjustModHelper rateAdjustHelper; + + protected ModDoubleTime() { - get - { - // Round to the nearest multiple of 0.1. - double value = (int)(SpeedChange.Value * 10) / 10.0; - - // Offset back to 0. - value -= 1; - - // Each 0.1 multiple changes score multiplier by 0.02. - value /= 5; - - return 1 + value; - } + rateAdjustHelper = new RateAdjustModHelper(SpeedChange); + rateAdjustHelper.HandleAudioAdjustments(AdjustPitch); } + + public override void ApplyToTrack(IAdjustableAudioComponent track) + { + rateAdjustHelper.ApplyToTrack(track); + } + + public override double ScoreMultiplier => rateAdjustHelper.ScoreMultiplier; } } diff --git a/osu.Game/Rulesets/Mods/ModHalfTime.cs b/osu.Game/Rulesets/Mods/ModHalfTime.cs index 7415c94cd8..8b5dd39584 100644 --- a/osu.Game/Rulesets/Mods/ModHalfTime.cs +++ b/osu.Game/Rulesets/Mods/ModHalfTime.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.Framework.Audio; using osu.Framework.Bindables; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; @@ -26,18 +27,22 @@ namespace osu.Game.Rulesets.Mods Precision = 0.01, }; - public override double ScoreMultiplier + [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] + public virtual BindableBool AdjustPitch { get; } = new BindableBool(); + + private readonly RateAdjustModHelper rateAdjustHelper; + + protected ModHalfTime() { - get - { - // Round to the nearest multiple of 0.1. - double value = (int)(SpeedChange.Value * 10) / 10.0; - - // Offset back to 0. - value -= 1; - - return 1 + value; - } + rateAdjustHelper = new RateAdjustModHelper(SpeedChange); + rateAdjustHelper.HandleAudioAdjustments(AdjustPitch); } + + public override void ApplyToTrack(IAdjustableAudioComponent track) + { + rateAdjustHelper.ApplyToTrack(track); + } + + public override double ScoreMultiplier => rateAdjustHelper.ScoreMultiplier; } } diff --git a/osu.Game/Rulesets/Mods/ModNightcore.cs b/osu.Game/Rulesets/Mods/ModNightcore.cs index 9b1f7d5cf7..b519ab4db7 100644 --- a/osu.Game/Rulesets/Mods/ModNightcore.cs +++ b/osu.Game/Rulesets/Mods/ModNightcore.cs @@ -11,6 +11,7 @@ using osu.Framework.Localisation; using osu.Game.Audio; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Timing; +using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Rulesets.Objects; @@ -19,22 +20,33 @@ using osu.Game.Skinning; namespace osu.Game.Rulesets.Mods { - public abstract class ModNightcore : ModDoubleTime + public abstract class ModNightcore : ModRateAdjust { public override string Name => "Nightcore"; public override string Acronym => "NC"; public override IconUsage? Icon => OsuIcon.ModNightcore; + public override ModType Type => ModType.DifficultyIncrease; public override LocalisableString Description => "Uguuuuuuuu..."; - } - public abstract partial class ModNightcore : ModNightcore, IApplicableToDrawableRuleset - where TObject : HitObject - { + [SettingSource("Speed increase", "The actual increase to apply")] + public override BindableNumber SpeedChange { get; } = new BindableDouble(1.5) + { + MinValue = 1.01, + MaxValue = 2, + Precision = 0.01, + }; + private readonly BindableNumber tempoAdjust = new BindableDouble(1); private readonly BindableNumber freqAdjust = new BindableDouble(1); + private readonly RateAdjustModHelper rateAdjustHelper; + protected ModNightcore() { + rateAdjustHelper = new RateAdjustModHelper(SpeedChange); + + // intentionally not deferring the speed change handling to `RateAdjustModHelper` + // as the expected result of operation is not the same (nightcore should preserve constant pitch). SpeedChange.BindValueChanged(val => { freqAdjust.Value = SpeedChange.Default; @@ -44,11 +56,16 @@ namespace osu.Game.Rulesets.Mods public override void ApplyToTrack(IAdjustableAudioComponent track) { - // base.ApplyToTrack() intentionally not called (different tempo adjustment is applied) track.AddAdjustment(AdjustableProperty.Frequency, freqAdjust); track.AddAdjustment(AdjustableProperty.Tempo, tempoAdjust); } + public override double ScoreMultiplier => rateAdjustHelper.ScoreMultiplier; + } + + public abstract partial class ModNightcore : ModNightcore, IApplicableToDrawableRuleset + where TObject : HitObject + { public void ApplyToDrawableRuleset(DrawableRuleset drawableRuleset) { drawableRuleset.Overlays.Add(new NightcoreBeatContainer()); diff --git a/osu.Game/Rulesets/Mods/ModRateAdjust.cs b/osu.Game/Rulesets/Mods/ModRateAdjust.cs index 5dad01e015..fa1c143585 100644 --- a/osu.Game/Rulesets/Mods/ModRateAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModRateAdjust.cs @@ -13,10 +13,7 @@ namespace osu.Game.Rulesets.Mods public abstract BindableNumber SpeedChange { get; } - public virtual void ApplyToTrack(IAdjustableAudioComponent track) - { - track.AddAdjustment(AdjustableProperty.Tempo, SpeedChange); - } + public abstract void ApplyToTrack(IAdjustableAudioComponent track); public virtual void ApplyToSample(IAdjustableAudioComponent sample) { diff --git a/osu.Game/Rulesets/Mods/ModTimeRamp.cs b/osu.Game/Rulesets/Mods/ModTimeRamp.cs index 54ee0cd3bf..d2772417db 100644 --- a/osu.Game/Rulesets/Mods/ModTimeRamp.cs +++ b/osu.Game/Rulesets/Mods/ModTimeRamp.cs @@ -44,21 +44,21 @@ namespace osu.Game.Rulesets.Mods Precision = 0.01, }; - private IAdjustableAudioComponent? track; + private readonly RateAdjustModHelper rateAdjustHelper; protected ModTimeRamp() { + rateAdjustHelper = new RateAdjustModHelper(SpeedChange); + rateAdjustHelper.HandleAudioAdjustments(AdjustPitch); + // for preview purpose at song select. eventually we'll want to be able to update every frame. FinalRate.BindValueChanged(_ => applyRateAdjustment(double.PositiveInfinity), true); - AdjustPitch.BindValueChanged(applyPitchAdjustment); } public void ApplyToTrack(IAdjustableAudioComponent track) { - this.track = track; - + rateAdjustHelper.ApplyToTrack(track); FinalRate.TriggerChange(); - AdjustPitch.TriggerChange(); } public void ApplyToSample(IAdjustableAudioComponent sample) @@ -95,16 +95,5 @@ namespace osu.Game.Rulesets.Mods /// Adjust the rate along the specified ramp. /// private void applyRateAdjustment(double time) => SpeedChange.Value = ApplyToRate(time); - - private void applyPitchAdjustment(ValueChangedEvent adjustPitchSetting) - { - // remove existing old adjustment - track?.RemoveAdjustment(adjustmentForPitchSetting(adjustPitchSetting.OldValue), SpeedChange); - - track?.AddAdjustment(adjustmentForPitchSetting(adjustPitchSetting.NewValue), SpeedChange); - } - - private AdjustableProperty adjustmentForPitchSetting(bool adjustPitchSettingValue) - => adjustPitchSettingValue ? AdjustableProperty.Frequency : AdjustableProperty.Tempo; } } diff --git a/osu.Game/Rulesets/Mods/RateAdjustModHelper.cs b/osu.Game/Rulesets/Mods/RateAdjustModHelper.cs new file mode 100644 index 0000000000..ffd4de0e90 --- /dev/null +++ b/osu.Game/Rulesets/Mods/RateAdjustModHelper.cs @@ -0,0 +1,84 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Framework.Audio; +using osu.Framework.Bindables; + +namespace osu.Game.Rulesets.Mods +{ + /// + /// Provides common functionality shared across various rate adjust mods. + /// + public class RateAdjustModHelper : IApplicableToTrack + { + public readonly IBindableNumber SpeedChange; + + private IAdjustableAudioComponent? track; + + private BindableBool? adjustPitch; + + /// + /// The score multiplier for the current . + /// + public double ScoreMultiplier + { + get + { + // Round to the nearest multiple of 0.1. + double value = (int)(SpeedChange.Value * 10) / 10.0; + + // Offset back to 0. + value -= 1; + + if (SpeedChange.Value >= 1) + value /= 5; + + return 1 + value; + } + } + + /// + /// Construct a new . + /// + /// The main speed adjust parameter which is exposed to the user. + public RateAdjustModHelper(IBindableNumber speedChange) + { + SpeedChange = speedChange; + } + + /// + /// Setup audio track adjustments for a rate adjust mod. + /// Importantly, must be called when a track is obtained/changed for this to work. + /// + /// The "adjust pitch" setting as exposed to the user. + public void HandleAudioAdjustments(BindableBool adjustPitch) + { + this.adjustPitch = adjustPitch; + + // When switching between pitch adjust, we need to update adjustments to time-shift or frequency-scale. + adjustPitch.BindValueChanged(adjustPitchSetting => + { + track?.RemoveAdjustment(adjustmentForPitchSetting(adjustPitchSetting.OldValue), SpeedChange); + track?.AddAdjustment(adjustmentForPitchSetting(adjustPitchSetting.NewValue), SpeedChange); + + AdjustableProperty adjustmentForPitchSetting(bool adjustPitchSettingValue) + => adjustPitchSettingValue ? AdjustableProperty.Frequency : AdjustableProperty.Tempo; + }); + } + + /// + /// Should be invoked when a track is obtained / changed. + /// + /// The new track. + /// If this method is called before . + public void ApplyToTrack(IAdjustableAudioComponent track) + { + if (adjustPitch == null) + throw new InvalidOperationException($"Must call {nameof(HandleAudioAdjustments)} first"); + + this.track = track; + adjustPitch.TriggerChange(); + } + } +} diff --git a/osu.Game/Rulesets/Objects/SliderEventGenerator.cs b/osu.Game/Rulesets/Objects/SliderEventGenerator.cs index b3477a5fde..9b8375f208 100644 --- a/osu.Game/Rulesets/Objects/SliderEventGenerator.cs +++ b/osu.Game/Rulesets/Objects/SliderEventGenerator.cs @@ -16,8 +16,12 @@ namespace osu.Game.Rulesets.Objects /// until the true end of the slider. This very small amount of leniency makes it easier to jump away from fast sliders to the next hit object. /// /// After discussion on how this should be handled going forward, players have unanimously stated that this lenience should remain in some way. + /// These days, this is implemented in the drawable implementation of Slider in the osu! ruleset. + /// + /// We need to keep the *only* for osu!catch conversion, which relies on it to generate tiny ticks + /// correctly. /// - public const double LAST_TICK_OFFSET = -36; + public const double TAIL_LENIENCY = -36; public static IEnumerable Generate(double startTime, double spanDuration, double velocity, double tickDistance, double totalDistance, int spanCount, CancellationToken cancellationToken = default) @@ -84,18 +88,27 @@ namespace osu.Game.Rulesets.Objects int finalSpanIndex = spanCount - 1; double finalSpanStartTime = startTime + finalSpanIndex * spanDuration; - double finalSpanEndTime = Math.Max(startTime + totalDuration / 2, (finalSpanStartTime + spanDuration) + LAST_TICK_OFFSET); - double finalProgress = (finalSpanEndTime - finalSpanStartTime) / spanDuration; - if (spanCount % 2 == 0) finalProgress = 1 - finalProgress; + // Note that `finalSpanStartTime + spanDuration ≈ startTime + totalDuration`, but we write it like this to match floating point precision + // of stable. + // + // So thinking about this in a saner way, the time of the LegacyLastTick is + // + // `slider.StartTime + max(slider.Duration / 2, slider.Duration - 36)` + // + // As a slider gets shorter than 72 ms, the leniency offered falls below the 36 ms `TAIL_LENIENCY` constant. + double legacyLastTickTime = Math.Max(startTime + totalDuration / 2, (finalSpanStartTime + spanDuration) + TAIL_LENIENCY); + double legacyLastTickProgress = (legacyLastTickTime - finalSpanStartTime) / spanDuration; + + if (spanCount % 2 == 0) legacyLastTickProgress = 1 - legacyLastTickProgress; yield return new SliderEventDescriptor { - Type = SliderEventType.LastTick, + Type = SliderEventType.LegacyLastTick, SpanIndex = finalSpanIndex, SpanStartTime = finalSpanStartTime, - Time = finalSpanEndTime, - PathProgress = finalProgress, + Time = legacyLastTickTime, + PathProgress = legacyLastTickProgress, }; yield return new SliderEventDescriptor @@ -183,9 +196,10 @@ namespace osu.Game.Rulesets.Objects Tick, /// - /// Occurs just before the tail. See . + /// Occurs just before the tail. See . + /// Should generally be ignored. /// - LastTick, + LegacyLastTick, Head, Tail, Repeat diff --git a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs index 6abfc6ee49..d23658ac33 100644 --- a/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs +++ b/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs @@ -81,7 +81,7 @@ namespace osu.Game.Rulesets.UI.Scrolling /// protected readonly SortedList ControlPoints = new SortedList(Comparer.Default); - protected IScrollingInfo ScrollingInfo => scrollingInfo; + public IScrollingInfo ScrollingInfo => scrollingInfo; [Cached(Type = typeof(IScrollingInfo))] private readonly LocalScrollingInfo scrollingInfo; diff --git a/osu.Game/Rulesets/UI/Scrolling/IDrawableScrollingRuleset.cs b/osu.Game/Rulesets/UI/Scrolling/IDrawableScrollingRuleset.cs index f3a3bb18bd..b348a22009 100644 --- a/osu.Game/Rulesets/UI/Scrolling/IDrawableScrollingRuleset.cs +++ b/osu.Game/Rulesets/UI/Scrolling/IDrawableScrollingRuleset.cs @@ -11,5 +11,7 @@ namespace osu.Game.Rulesets.UI.Scrolling public interface IDrawableScrollingRuleset { ScrollVisualisationMethod VisualisationMethod { get; } + + IScrollingInfo ScrollingInfo { get; } } } diff --git a/osu.Game/Screens/Edit/Compose/Components/BeatSnapGrid.cs b/osu.Game/Screens/Edit/Compose/Components/BeatSnapGrid.cs new file mode 100644 index 0000000000..766d5b5601 --- /dev/null +++ b/osu.Game/Screens/Edit/Compose/Components/BeatSnapGrid.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.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Caching; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Pooling; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Drawables; +using osu.Game.Rulesets.UI.Scrolling; +using osuTK.Graphics; + +namespace osu.Game.Screens.Edit.Compose.Components +{ + /// + /// A grid which displays coloured beat divisor lines in proximity to the selection or placement cursor. + /// + public abstract partial class BeatSnapGrid : CompositeComponent + { + private const double visible_range = 750; + + /// + /// The range of time values of the current selection. + /// + public (double start, double end)? SelectionTimeRange + { + set + { + if (value == selectionTimeRange) + return; + + selectionTimeRange = value; + lineCache.Invalidate(); + } + } + + [Resolved] + private EditorBeatmap beatmap { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + [Resolved] + private BindableBeatDivisor beatDivisor { get; set; } = null!; + + private readonly List grids = new List(); + + private readonly DrawablePool linesPool = new DrawablePool(50); + + private readonly Cached lineCache = new Cached(); + + private (double start, double end)? selectionTimeRange; + + [BackgroundDependencyLoader] + private void load(HitObjectComposer composer) + { + AddInternal(linesPool); + + foreach (var target in GetTargetContainers(composer)) + { + var lineContainer = new ScrollingHitObjectContainer(); + + grids.Add(lineContainer); + target.Add(lineContainer); + } + + beatDivisor.BindValueChanged(_ => createLines(), true); + } + + protected abstract IEnumerable GetTargetContainers(HitObjectComposer composer); + + protected override void Update() + { + base.Update(); + + if (!lineCache.IsValid) + { + lineCache.Validate(); + createLines(); + } + } + + private void createLines() + { + foreach (var grid in grids) + grid.Clear(); + + if (selectionTimeRange == null) + return; + + var range = selectionTimeRange.Value; + + var timingPoint = beatmap.ControlPointInfo.TimingPointAt(range.start - visible_range); + + double time = timingPoint.Time; + int beat = 0; + + // progress time until in the visible range. + while (time < range.start - visible_range) + { + time += timingPoint.BeatLength / beatDivisor.Value; + beat++; + } + + while (time < range.end + visible_range) + { + var nextTimingPoint = beatmap.ControlPointInfo.TimingPointAt(time); + + // switch to the next timing point if we have reached it. + if (nextTimingPoint.Time > timingPoint.Time) + { + beat = 0; + time = nextTimingPoint.Time; + timingPoint = nextTimingPoint; + } + + Color4 colour = BindableBeatDivisor.GetColourFor( + BindableBeatDivisor.GetDivisorForBeatIndex(beat, beatDivisor.Value), colours); + + foreach (var grid in grids) + { + var line = linesPool.Get(); + + line.Apply(new HitObject + { + StartTime = time + }); + + line.Colour = colour; + + grid.Add(line); + } + + beat++; + time += timingPoint.BeatLength / beatDivisor.Value; + } + + foreach (var grid in grids) + { + // required to update ScrollingHitObjectContainer's cache. + grid.UpdateSubTree(); + + foreach (var line in grid.Objects.OfType()) + { + time = line.HitObject.StartTime; + + if (time >= range.start && time <= range.end) + line.Alpha = 1; + else + { + double timeSeparation = time < range.start ? range.start - time : time - range.end; + line.Alpha = (float)Math.Max(0, 1 - timeSeparation / visible_range); + } + } + } + } + + private partial class DrawableGridLine : DrawableHitObject + { + [Resolved] + private IScrollingInfo scrollingInfo { get; set; } = null!; + + private readonly IBindable direction = new Bindable(); + + public DrawableGridLine() + : base(new HitObject()) + { + AddInternal(new Box { RelativeSizeAxes = Axes.Both }); + } + + [BackgroundDependencyLoader] + private void load() + { + direction.BindTo(scrollingInfo.Direction); + direction.BindValueChanged(onDirectionChanged, true); + } + + private void onDirectionChanged(ValueChangedEvent direction) + { + Origin = Anchor = direction.NewValue == ScrollingDirection.Up + ? Anchor.TopLeft + : Anchor.BottomLeft; + + bool isHorizontal = direction.NewValue == ScrollingDirection.Left || direction.NewValue == ScrollingDirection.Right; + + if (isHorizontal) + { + RelativeSizeAxes = Axes.Y; + Width = 2; + } + else + { + RelativeSizeAxes = Axes.X; + Height = 2; + } + } + + protected override void UpdateInitialTransforms() + { + // don't perform any fading – we are handling that ourselves. + LifetimeEnd = HitObject.StartTime + visible_range; + } + } + } +}