1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-26 12:45:09 +08:00

Merge branch 'master' into fix-circle-radius

This commit is contained in:
Dean Herbert 2023-10-20 18:53:36 +09:00
commit 2ba6286470
No known key found for this signature in database
50 changed files with 1091 additions and 821 deletions

View File

@ -14,8 +14,8 @@
# #
# The workflow can be run in two ways: # The workflow can be run in two ways:
# 1. Via workflow dispatch. # 1. Via workflow dispatch.
# 2. By an owner of the repository posting a pull request or issue comment containing `!diffcalc`. # 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). # 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. # Any lines in the comment of the form `KEY=VALUE` are treated as variables for the generator.
# #
# ## Google Service Account # ## Google Service Account
@ -101,29 +101,30 @@ permissions:
pull-requests: write pull-requests: write
env: 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: jobs:
wait-for-queue: check-permissions:
name: "Wait for previous workflows" name: Check permissions
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || contains(github.event.comment.body, '!diffcalc') && github.event.comment.author_association == 'OWNER') }} if: ${{ github.event_name == 'workflow_dispatch' || contains(github.event.comment.body, '!diffcalc') }}
timeout-minutes: 50400 # 35 days, the maximum for jobs.
steps: 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: with:
timeout: 2147483647 # Around 24 days, maximum supported. require: 'write'
delay: 120000 # Poll every 2 minutes. API seems fairly low on this one.
create-comment: create-comment:
name: Create PR comment name: Create PR comment
needs: check-permissions
runs-on: ubuntu-latest 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: steps:
- name: Create comment - name: Create comment
uses: thollander/actions-comment-pull-request@v2 uses: thollander/actions-comment-pull-request@363c6f6eae92cc5c3a66e95ba016fc771bb38943 # v2.4.2
with: with:
comment_tag: ${{ env.COMMENT_TAG }} comment_tag: ${{ env.EXECUTION_ID }}
message: | message: |
Difficulty calculation queued -- please wait! (${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) Difficulty calculation queued -- please wait! (${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
@ -131,42 +132,37 @@ jobs:
directory: directory:
name: Prepare directory name: Prepare directory
needs: wait-for-queue needs: check-permissions
runs-on: self-hosted runs-on: self-hosted
if: ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || contains(github.event.comment.body, '!diffcalc') && github.event.comment.author_association == 'OWNER') }}
outputs: outputs:
GENERATOR_DIR: ${{ steps.set-outputs.outputs.GENERATOR_DIR }} GENERATOR_DIR: ${{ steps.set-outputs.outputs.GENERATOR_DIR }}
GENERATOR_ENV: ${{ steps.set-outputs.outputs.GENERATOR_ENV }} GENERATOR_ENV: ${{ steps.set-outputs.outputs.GENERATOR_ENV }}
GOOGLE_CREDS_FILE: ${{ steps.set-outputs.outputs.GOOGLE_CREDS_FILE }} GOOGLE_CREDS_FILE: ${{ steps.set-outputs.outputs.GOOGLE_CREDS_FILE }}
steps: steps:
- name: Checkout
uses: actions/checkout@v3
- name: Checkout diffcalc-sheet-generator - name: Checkout diffcalc-sheet-generator
uses: actions/checkout@v3 uses: actions/checkout@v3
with: with:
path: 'diffcalc-sheet-generator' path: ${{ env.EXECUTION_ID }}
repository: 'smoogipoo/diffcalc-sheet-generator' repository: 'smoogipoo/diffcalc-sheet-generator'
- name: Set outputs - name: Set outputs
id: set-outputs id: set-outputs
run: | run: |
echo "GENERATOR_DIR=${{ github.workspace }}/diffcalc-sheet-generator" >> "${GITHUB_OUTPUT}" echo "GENERATOR_DIR=${{ github.workspace }}/${{ env.EXECUTION_ID }}" >> "${GITHUB_OUTPUT}"
echo "GENERATOR_ENV=${{ github.workspace }}/diffcalc-sheet-generator/.env" >> "${GITHUB_OUTPUT}" echo "GENERATOR_ENV=${{ github.workspace }}/${{ env.EXECUTION_ID }}/.env" >> "${GITHUB_OUTPUT}"
echo "GOOGLE_CREDS_FILE=${{ github.workspace }}/diffcalc-sheet-generator/google-credentials.json" >> "${GITHUB_OUTPUT}" echo "GOOGLE_CREDS_FILE=${{ github.workspace }}/${{ env.EXECUTION_ID }}/google-credentials.json" >> "${GITHUB_OUTPUT}"
environment: environment:
name: Setup environment name: Setup environment
needs: directory needs: directory
runs-on: self-hosted runs-on: self-hosted
if: ${{ !cancelled() && needs.directory.result == 'success' }}
env: env:
VARS_JSON: ${{ toJSON(vars) }} VARS_JSON: ${{ toJSON(vars) }}
steps: steps:
- name: Add base environment - name: Add base environment
run: | run: |
# Required by diffcalc-sheet-generator # 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 # Add Google credentials
echo '${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }}' | base64 -d > "${{ needs.directory.outputs.GOOGLE_CREDS_FILE }}" echo '${{ secrets.DIFFCALC_GOOGLE_CREDENTIALS }}' | base64 -d > "${{ needs.directory.outputs.GOOGLE_CREDS_FILE }}"
@ -185,7 +181,7 @@ jobs:
- name: Add pull-request environment - name: Add pull-request environment
if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }} if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }}
run: | 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 - name: Add comment environment
if: ${{ github.event_name == 'issue_comment' }} if: ${{ github.event_name == 'issue_comment' }}
@ -239,7 +235,6 @@ jobs:
name: Setup scores name: Setup scores
needs: [ directory, environment ] needs: [ directory, environment ]
runs-on: self-hosted runs-on: self-hosted
if: ${{ !cancelled() && needs.environment.result == 'success' }}
steps: steps:
- name: Query latest data - name: Query latest data
id: query id: query
@ -252,7 +247,7 @@ jobs:
- name: Restore cache - name: Restore cache
id: restore-cache id: restore-cache
uses: maxnowack/local-cache@v1 uses: maxnowack/local-cache@038cc090b52e4f205fbc468bf5b0756df6f68775 # v1
with: with:
path: ${{ steps.query.outputs.DATA_NAME }}.tar.bz2 path: ${{ steps.query.outputs.DATA_NAME }}.tar.bz2
key: ${{ steps.query.outputs.DATA_NAME }} key: ${{ steps.query.outputs.DATA_NAME }}
@ -272,7 +267,6 @@ jobs:
name: Setup beatmaps name: Setup beatmaps
needs: directory needs: directory
runs-on: self-hosted runs-on: self-hosted
if: ${{ !cancelled() && needs.directory.result == 'success' }}
steps: steps:
- name: Query latest data - name: Query latest data
id: query id: query
@ -284,7 +278,7 @@ jobs:
- name: Restore cache - name: Restore cache
id: restore-cache id: restore-cache
uses: maxnowack/local-cache@v1 uses: maxnowack/local-cache@038cc090b52e4f205fbc468bf5b0756df6f68775 # v1
with: with:
path: ${{ steps.query.outputs.DATA_NAME }}.tar.bz2 path: ${{ steps.query.outputs.DATA_NAME }}.tar.bz2
key: ${{ steps.query.outputs.DATA_NAME }} key: ${{ steps.query.outputs.DATA_NAME }}
@ -305,7 +299,6 @@ jobs:
needs: [ directory, environment, scores, beatmaps ] needs: [ directory, environment, scores, beatmaps ]
runs-on: self-hosted runs-on: self-hosted
timeout-minutes: 720 timeout-minutes: 720
if: ${{ !cancelled() && needs.scores.result == 'success' && needs.beatmaps.result == 'success' }}
outputs: outputs:
TARGET: ${{ steps.run.outputs.TARGET }} TARGET: ${{ steps.run.outputs.TARGET }}
SPREADSHEET_LINK: ${{ steps.run.outputs.SPREADSHEET_LINK }} SPREADSHEET_LINK: ${{ steps.run.outputs.SPREADSHEET_LINK }}
@ -329,25 +322,39 @@ jobs:
if: ${{ always() }} if: ${{ always() }}
run: | run: |
cd "${{ needs.directory.outputs.GENERATOR_DIR }}" 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 - name: Output info
if: ${{ success() }}
run: | run: |
echo "Target: ${{ steps.run.outputs.TARGET }}" echo "Target: ${{ needs.generator.outputs.TARGET }}"
echo "Spreadsheet: ${{ steps.run.outputs.SPREADSHEET_LINK }}" 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: update-comment:
name: Update PR comment name: Update PR comment
needs: [ create-comment, generator ] needs: [ create-comment, generator ]
runs-on: ubuntu-latest 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: steps:
- name: Update comment on success - name: Update comment on success
if: ${{ needs.generator.result == 'success' }} if: ${{ needs.generator.result == 'success' }}
uses: thollander/actions-comment-pull-request@v2 uses: thollander/actions-comment-pull-request@363c6f6eae92cc5c3a66e95ba016fc771bb38943 # v2.4.2
with: with:
comment_tag: ${{ env.COMMENT_TAG }} comment_tag: ${{ env.EXECUTION_ID }}
mode: upsert mode: upsert
create_if_not_exists: false create_if_not_exists: false
message: | message: |
@ -356,10 +363,18 @@ jobs:
- name: Update comment on failure - name: Update comment on failure
if: ${{ needs.generator.result == 'failure' }} if: ${{ needs.generator.result == 'failure' }}
uses: thollander/actions-comment-pull-request@v2 uses: thollander/actions-comment-pull-request@363c6f6eae92cc5c3a66e95ba016fc771bb38943 # v2.4.2
with: with:
comment_tag: ${{ env.COMMENT_TAG }} comment_tag: ${{ env.EXECUTION_ID }}
mode: upsert mode: upsert
create_if_not_exists: false create_if_not_exists: false
message: | message: |
Difficulty calculation failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} 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.

View File

@ -0,0 +1,21 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
});
}
}

View File

@ -1,180 +1,19 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using osu.Framework.Graphics.Containers;
using osu.Framework.Allocation;
using osu.Framework.Caching;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Catch.UI;
using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects; using osu.Game.Screens.Edit.Compose.Components;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Screens.Edit;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Catch.Edit namespace osu.Game.Rulesets.Catch.Edit
{ {
/// <summary> public partial class CatchBeatSnapGrid : BeatSnapGrid
/// A grid which displays coloured beat divisor lines in proximity to the selection or placement cursor.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public partial class CatchBeatSnapGrid : Component
{ {
private const double visible_range = 750; protected override IEnumerable<Container> GetTargetContainers(HitObjectComposer composer) => new[]
/// <summary>
/// The range of time values of the current selection.
/// </summary>
public (double start, double end)? SelectionTimeRange
{ {
set ((CatchPlayfield)composer.Playfield).UnderlayElements
{ };
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<DrawableGridLine> availableLines = new Stack<DrawableGridLine>();
private void createLines()
{
foreach (var line in lineContainer.Objects.OfType<DrawableGridLine>())
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<DrawableGridLine>())
{
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;
}
}
} }
} }

View File

@ -0,0 +1,26 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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;
}
}
}

View File

@ -1,17 +1,15 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Extensions.EnumExtensions; using osu.Framework.Extensions.EnumExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Input; using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings; using osu.Game.Input.Bindings;
using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.UI; 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.Mods;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
using osu.Game.Screens.Edit.Components.TernaryButtons;
using osu.Game.Screens.Edit.Compose.Components; using osu.Game.Screens.Edit.Compose.Components;
using osuTK; using osuTK;
namespace osu.Game.Rulesets.Catch.Edit namespace osu.Game.Rulesets.Catch.Edit
{ {
// we're also a ScrollingHitObjectComposer candidate, but can't be everything can we? public partial class CatchHitObjectComposer : ScrollingHitObjectComposer<CatchHitObject>, IKeyBindingHandler<GlobalAction>
public partial class CatchHitObjectComposer : DistancedHitObjectComposer<CatchHitObject>
{ {
private const float distance_snap_radius = 50; private const float distance_snap_radius = 50;
private CatchDistanceSnapGrid distanceSnapGrid = null!; private CatchDistanceSnapGrid distanceSnapGrid = null!;
private InputManager inputManager = null!;
private CatchBeatSnapGrid beatSnapGrid = null!;
private readonly BindableDouble timeRangeMultiplier = new BindableDouble(1) private readonly BindableDouble timeRangeMultiplier = new BindableDouble(1)
{ {
MinValue = 1, MinValue = 1,
MaxValue = 10, MaxValue = 10,
}; };
[Cached(typeof(IDistanceSnapProvider))]
protected readonly CatchDistanceSnapProvider DistanceSnapProvider = new CatchDistanceSnapProvider();
public CatchHitObjectComposer(CatchRuleset ruleset) public CatchHitObjectComposer(CatchRuleset ruleset)
: base(ruleset) : base(ruleset)
{ {
@ -50,8 +47,11 @@ namespace osu.Game.Rulesets.Catch.Edit
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load()
{ {
AddInternal(DistanceSnapProvider);
DistanceSnapProvider.AttachToToolbox(RightToolbox);
// todo: enable distance spacing once catch supports applying it to its existing distance snap grid implementation. // 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 LayerBelowRuleset.Add(new PlayfieldBorder
{ {
@ -68,61 +68,30 @@ namespace osu.Game.Rulesets.Catch.Edit
Catcher.BASE_DASH_SPEED, -Catcher.BASE_DASH_SPEED, Catcher.BASE_DASH_SPEED, -Catcher.BASE_DASH_SPEED,
Catcher.BASE_WALK_SPEED, -Catcher.BASE_WALK_SPEED, Catcher.BASE_WALK_SPEED, -Catcher.BASE_WALK_SPEED,
})); }));
AddInternal(beatSnapGrid = new CatchBeatSnapGrid());
} }
protected override void LoadComplete() protected override IEnumerable<TernaryButton> CreateTernaryButtons()
{ => base.CreateTernaryButtons()
base.LoadComplete(); .Concat(DistanceSnapProvider.CreateTernaryButtons());
inputManager = GetContainingInputManager(); protected override DrawableRuleset<CatchHitObject> CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods) =>
} new DrawableCatchEditorRuleset(ruleset, beatmap, mods)
protected override void UpdateAfterChildren()
{
base.UpdateAfterChildren();
if (BlueprintContainer.CurrentTool is SelectTool)
{ {
if (EditorBeatmap.SelectedHitObjects.Any()) TimeRangeMultiplier = { BindTarget = timeRangeMultiplier, }
{ };
beatSnapGrid.SelectionTimeRange = (EditorBeatmap.SelectedHitObjects.Min(h => h.StartTime), EditorBeatmap.SelectedHitObjects.Max(h => h.GetEndTime()));
}
else
beatSnapGrid.SelectionTimeRange = null;
}
else
{
var result = FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position);
if (result.Time is double time)
beatSnapGrid.SelectionTimeRange = (time, time);
else
beatSnapGrid.SelectionTimeRange = null;
}
}
protected override double ReadCurrentDistanceSnap(HitObject before, HitObject after) protected override ComposeBlueprintContainer CreateBlueprintContainer() => new CatchBlueprintContainer(this);
protected override BeatSnapGrid CreateBeatSnapGrid() => new CatchBeatSnapGrid();
protected override IReadOnlyList<HitObjectCompositionTool> CompositionTools => new HitObjectCompositionTool[]
{ {
// osu!catch's distance snap implementation is limited, in that a custom spacing cannot be specified. new FruitCompositionTool(),
// Therefore this functionality is not currently used. new JuiceStreamCompositionTool(),
// new BananaShowerCompositionTool()
// The implementation below is probably correct but should be checked if/when exposed via controls. };
float expectedDistance = DurationToDistance(before, after.StartTime - before.GetEndTime()); public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
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<GlobalAction> e)
{ {
switch (e.Action) 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. // May be worth considering standardising "zoom" behaviour with what the timeline uses (ie. alt-wheel) but that may cause new conflicts.
case GlobalAction.IncreaseScrollSpeed: case GlobalAction.IncreaseScrollSpeed:
this.TransformBindableTo(timeRangeMultiplier, timeRangeMultiplier.Value - 1, 200, Easing.OutQuint); this.TransformBindableTo(timeRangeMultiplier, timeRangeMultiplier.Value - 1, 200, Easing.OutQuint);
break; return true;
case GlobalAction.DecreaseScrollSpeed: case GlobalAction.DecreaseScrollSpeed:
this.TransformBindableTo(timeRangeMultiplier, timeRangeMultiplier.Value + 1, 200, Easing.OutQuint); this.TransformBindableTo(timeRangeMultiplier, timeRangeMultiplier.Value + 1, 200, Easing.OutQuint);
break; return true;
} }
return base.OnPressed(e); return false;
} }
protected override DrawableRuleset<CatchHitObject> CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods) => public void OnReleased(KeyBindingReleaseEvent<GlobalAction> e)
new DrawableCatchEditorRuleset(ruleset, beatmap, mods)
{
TimeRangeMultiplier = { BindTarget = timeRangeMultiplier, }
};
protected override IReadOnlyList<HitObjectCompositionTool> CompositionTools => new HitObjectCompositionTool[]
{ {
new FruitCompositionTool(), }
new JuiceStreamCompositionTool(),
new BananaShowerCompositionTool()
};
public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All) public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All)
{ {
@ -172,8 +132,6 @@ namespace osu.Game.Rulesets.Catch.Edit
return result; return result;
} }
protected override ComposeBlueprintContainer CreateBlueprintContainer() => new CatchBlueprintContainer(this);
private PalpableCatchHitObject? getLastSnappableHitObject(double time) private PalpableCatchHitObject? getLastSnappableHitObject(double time)
{ {
var hitObject = EditorBeatmap.HitObjects.OfType<CatchHitObject>().LastOrDefault(h => h.GetEndTime() < time && !(h is BananaShower)); var hitObject = EditorBeatmap.HitObjects.OfType<CatchHitObject>().LastOrDefault(h => h.GetEndTime() < time && !(h is BananaShower));
@ -214,33 +172,12 @@ namespace osu.Game.Rulesets.Catch.Edit
return null; return null;
} }
double timeAtCursor = ((CatchPlayfield)Playfield).TimeAtScreenSpacePosition(inputManager.CurrentState.Mouse.Position); double timeAtCursor = ((CatchPlayfield)Playfield).TimeAtScreenSpacePosition(InputManager.CurrentState.Mouse.Position);
return getLastSnappableHitObject(timeAtCursor); return getLastSnappableHitObject(timeAtCursor);
default: default:
return null; 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;
}
} }
} }

View File

@ -1,7 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation; using osu.Framework.Localisation;
using osu.Game.Rulesets.Catch.Objects; using osu.Game.Rulesets.Catch.Objects;
@ -21,10 +20,8 @@ namespace osu.Game.Rulesets.Catch.Mods
public void ApplyToDrawableRuleset(DrawableRuleset<CatchHitObject> drawableRuleset) public void ApplyToDrawableRuleset(DrawableRuleset<CatchHitObject> drawableRuleset)
{ {
drawableRuleset.PlayfieldAdjustmentContainer.Anchor = Anchor.Centre;
drawableRuleset.PlayfieldAdjustmentContainer.Origin = Anchor.Centre;
drawableRuleset.PlayfieldAdjustmentContainer.Scale = new Vector2(1, -1); drawableRuleset.PlayfieldAdjustmentContainer.Scale = new Vector2(1, -1);
drawableRuleset.PlayfieldAdjustmentContainer.Y = 1 - drawableRuleset.PlayfieldAdjustmentContainer.Y;
} }
} }
} }

View File

@ -1,206 +1,23 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; 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.Containers;
using osu.Framework.Graphics.Pooling;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Objects; using osu.Game.Screens.Edit.Compose.Components;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Screens.Edit;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Mania.Edit namespace osu.Game.Rulesets.Mania.Edit
{ {
/// <summary> public partial class ManiaBeatSnapGrid : BeatSnapGrid
/// A grid which displays coloured beat divisor lines in proximity to the selection or placement cursor.
/// </summary>
public partial class ManiaBeatSnapGrid : CompositeComponent
{ {
private const double visible_range = 750; protected override IEnumerable<Container> GetTargetContainers(HitObjectComposer composer)
/// <summary>
/// The range of time values of the current selection.
/// </summary>
public (double start, double end)? SelectionTimeRange
{ {
set return ((ManiaPlayfield)composer.Playfield)
{ .Stages
if (value == selectionTimeRange) .SelectMany(stage => stage.Columns)
return; .Select(column => column.UnderlayElements);
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<ScrollingHitObjectContainer> grids = new List<ScrollingHitObjectContainer>();
private readonly DrawablePool<DrawableGridLine> linesPool = new DrawablePool<DrawableGridLine>(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<DrawableGridLine>())
{
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<ScrollingDirection> direction = new Bindable<ScrollingDirection>();
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<ScrollingDirection> 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;
}
} }
} }
} }

View File

@ -5,15 +5,12 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Input;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.UI; using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Screens.Edit.Compose.Components; using osu.Game.Screens.Edit.Compose.Components;
@ -24,32 +21,12 @@ namespace osu.Game.Rulesets.Mania.Edit
public partial class ManiaHitObjectComposer : ScrollingHitObjectComposer<ManiaHitObject> public partial class ManiaHitObjectComposer : ScrollingHitObjectComposer<ManiaHitObject>
{ {
private DrawableManiaEditorRuleset drawableRuleset; private DrawableManiaEditorRuleset drawableRuleset;
private ManiaBeatSnapGrid beatSnapGrid;
private InputManager inputManager;
public ManiaHitObjectComposer(Ruleset ruleset) public ManiaHitObjectComposer(Ruleset ruleset)
: base(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 new ManiaPlayfield Playfield => ((ManiaPlayfield)drawableRuleset.Playfield);
public IScrollingInfo ScrollingInfo => drawableRuleset.ScrollingInfo; public IScrollingInfo ScrollingInfo => drawableRuleset.ScrollingInfo;
@ -57,48 +34,20 @@ namespace osu.Game.Rulesets.Mania.Edit
protected override Playfield PlayfieldAtScreenSpacePosition(Vector2 screenSpacePosition) => protected override Playfield PlayfieldAtScreenSpacePosition(Vector2 screenSpacePosition) =>
Playfield.GetColumnByPosition(screenSpacePosition); Playfield.GetColumnByPosition(screenSpacePosition);
protected override DrawableRuleset<ManiaHitObject> CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods) protected override DrawableRuleset<ManiaHitObject> CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods) =>
{
drawableRuleset = new DrawableManiaEditorRuleset(ruleset, beatmap, 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() protected override ComposeBlueprintContainer CreateBlueprintContainer()
=> new ManiaBlueprintContainer(this); => new ManiaBlueprintContainer(this);
protected override BeatSnapGrid CreateBeatSnapGrid() => new ManiaBeatSnapGrid();
protected override IReadOnlyList<HitObjectCompositionTool> CompositionTools => new HitObjectCompositionTool[] protected override IReadOnlyList<HitObjectCompositionTool> CompositionTools => new HitObjectCompositionTool[]
{ {
new NoteCompositionTool(), new NoteCompositionTool(),
new HoldNoteCompositionTool() 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() public override string ConvertSelectionToString()
=> string.Join(',', EditorBeatmap.SelectedHitObjects.Cast<ManiaHitObject>().OrderBy(h => h.StartTime).Select(h => $"{h.StartTime}|{h.Column}")); => string.Join(',', EditorBeatmap.SelectedHitObjects.Cast<ManiaHitObject>().OrderBy(h => h.StartTime).Select(h => $"{h.StartTime}|{h.Column}"));
} }

View File

@ -47,8 +47,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
[Cached] [Cached]
private readonly BindableBeatDivisor beatDivisor = new BindableBeatDivisor(); private readonly BindableBeatDivisor beatDivisor = new BindableBeatDivisor();
[Cached(typeof(IDistanceSnapProvider))] private readonly TestHitObjectComposer composer = new TestHitObjectComposer
private readonly OsuHitObjectComposer snapProvider = new OsuHitObjectComposer(new OsuRuleset())
{ {
// Just used for the snap implementation, so let's hide from vision. // Just used for the snap implementation, so let's hide from vision.
AlwaysPresent = true, AlwaysPresent = true,
@ -71,11 +70,18 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
base.Content.Children = new Drawable[] base.Content.Children = new Drawable[]
{ {
editorClock = new EditorClock(editorBeatmap), editorClock = new EditorClock(editorBeatmap),
new PopoverContainer { Child = snapProvider }, new PopoverContainer { Child = composer },
Content Content
}; };
} }
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
{
var dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
dependencies.CacheAs(composer.DistanceSnapProvider);
return dependencies;
}
protected override Container<Drawable> Content { get; } = new PopoverContainer { RelativeSizeAxes = Axes.Both }; protected override Container<Drawable> Content { get; } = new PopoverContainer { RelativeSizeAxes = Axes.Both };
[SetUp] [SetUp]
@ -84,7 +90,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
editorBeatmap.Difficulty.SliderMultiplier = 1; editorBeatmap.Difficulty.SliderMultiplier = 1;
editorBeatmap.ControlPointInfo.Clear(); editorBeatmap.ControlPointInfo.Clear();
editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = beat_length }); editorBeatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = beat_length });
snapProvider.DistanceSpacingMultiplier.Value = 1; composer.DistanceSnapProvider.DistanceSpacingMultiplier.Value = 1;
Children = new Drawable[] Children = new Drawable[]
{ {
@ -116,7 +122,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
[TestCase(0.5f)] [TestCase(0.5f)]
public void TestDistanceSpacing(float multiplier) 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] [Test]
@ -153,7 +159,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
[TestCase(2f, beat_length * 2)] [TestCase(2f, beat_length * 2)]
public void TestDistanceSpacingAdjust(float multiplier, float expectedDistance) 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))); AddStep("move mouse to point", () => InputManager.MoveMouseTo(grid.ToScreenSpace(grid_position + new Vector2(beat_length, 0) * 2)));
assertSnappedDistance(expectedDistance); assertSnappedDistance(expectedDistance);
@ -266,5 +272,15 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
cursor.Position = LastSnappedPosition = GetSnapPosition.Invoke(inputManager.CurrentState.Mouse.Position); 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())
{
}
}
} }
} }

View File

@ -51,8 +51,9 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods
FinalRate = { Value = 1.3 } FinalRate = { Value = 1.3 }
}); });
[Test] [TestCase(6.25f)]
public void TestPerfectScoreOnShortSliderWithRepeat() [TestCase(20)]
public void TestPerfectScoreOnShortSliderWithRepeat(float pathLength)
{ {
AddStep("set score to standardised", () => LocalConfig.SetValue(OsuSetting.ScoreDisplayMode, ScoringMode.Standardised)); 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[] Path = new SliderPath(new[]
{ {
new PathControlPoint(), new PathControlPoint(),
new PathControlPoint(new Vector2(0, 6.25f)) new PathControlPoint(new Vector2(0, pathLength))
}), }),
RepeatCount = 1, RepeatCount = 1,
SliderVelocityMultiplier = 10 SliderVelocityMultiplier = 10

View File

@ -3,6 +3,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
@ -33,8 +34,21 @@ namespace osu.Game.Rulesets.Osu.Tests
switch (hitObject) switch (hitObject)
{ {
case Slider slider: case Slider slider:
var objects = new List<ConvertValue>();
foreach (var nested in slider.NestedHitObjects) 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; break;
@ -44,13 +58,29 @@ namespace osu.Game.Rulesets.Osu.Tests
break; break;
} }
static ConvertValue createConvertValue(OsuHitObject obj) => new ConvertValue static ConvertValue createConvertValue(OsuHitObject obj, OsuHitObject? parent = null)
{ {
StartTime = obj.StartTime, double startTime = obj.StartTime;
EndTime = obj.GetEndTime(), double endTime = obj.GetEndTime();
X = obj.StackedPosition.X,
Y = obj.StackedPosition.Y // 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(); protected override Ruleset CreateRuleset() => new OsuRuleset();

View File

@ -17,16 +17,19 @@ namespace osu.Game.Rulesets.Osu.Tests
[TestCase(6.7115569159190587d, 206, "diffcalc-test")] [TestCase(6.7115569159190587d, 206, "diffcalc-test")]
[TestCase(1.4391311903612753d, 45, "zero-length-sliders")] [TestCase(1.4391311903612753d, 45, "zero-length-sliders")]
[TestCase(0.42506480230838789d, 2, "very-fast-slider")]
[TestCase(0.14102693012101306d, 1, "nan-slider")] [TestCase(0.14102693012101306d, 1, "nan-slider")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name) public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name); => base.Test(expectedStarRating, expectedMaxCombo, name);
[TestCase(8.9757300665532966d, 206, "diffcalc-test")] [TestCase(8.9757300665532966d, 206, "diffcalc-test")]
[TestCase(0.55071082800473514d, 2, "very-fast-slider")]
[TestCase(1.7437232654020756d, 45, "zero-length-sliders")] [TestCase(1.7437232654020756d, 45, "zero-length-sliders")]
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime()); => Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime());
[TestCase(6.7115569159190587d, 239, "diffcalc-test")] [TestCase(6.7115569159190587d, 239, "diffcalc-test")]
[TestCase(0.42506480230838789d, 4, "very-fast-slider")]
[TestCase(1.4391311903612753d, 54, "zero-length-sliders")] [TestCase(1.4391311903612753d, 54, "zero-length-sliders")]
public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name) public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic()); => Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic());

View File

@ -1,13 +1,12 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
#nullable disable
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Screens; using osu.Framework.Screens;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Replays; using osu.Game.Replays;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects; 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_during_slide_4 = 3800;
private const double time_slider_end = 4000; private const double time_slider_end = 4000;
private List<JudgementResult> judgementResults; private ScoreAccessibleReplayPlayer currentPlayer = null!;
private const float slider_path_length = 25;
private readonly List<JudgementResult> judgementResults = new List<JudgementResult>();
// 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<ReplayFrame>
{
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<Slider>);
AddAssert("Tail is second last judgement", () => judgementResults[^2].HitObject, Is.TypeOf<SliderTailCircle>);
}
[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<ReplayFrame>
{
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] [Test]
public void TestPressBothKeysSimultaneouslyAndReleaseOne() 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 }, new OsuReplayFrame { Position = Vector2.Zero, Actions = { OsuAction.RightButton }, Time = time_during_slide_1 },
}); });
AddAssert("Tracking retained", assertMaxJudge); assertAllMaxJudgements();
} }
/// <summary> /// <summary>
@ -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 }, new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.RightButton }, Time = time_during_slide_2 },
}); });
AddAssert("Tracking retained", assertMaxJudge); assertAllMaxJudgements();
} }
/// <summary> /// <summary>
@ -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 }, new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.RightButton }, Time = time_during_slide_1 },
}); });
AddAssert("Tracking retained", assertMaxJudge); assertAllMaxJudgements();
} }
/// <summary> /// <summary>
@ -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 }, new OsuReplayFrame { Position = new Vector2(0, 0), Actions = { OsuAction.RightButton }, Time = time_during_slide_1 },
}); });
AddAssert("Tracking retained", assertMaxJudge); assertAllMaxJudgements();
} }
/// <summary> /// <summary>
@ -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 }, 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();
} }
/// <summary> /// <summary>
@ -325,7 +417,13 @@ namespace osu.Game.Rulesets.Osu.Tests
AddAssert("Tracking dropped", assertMidSliderJudgementFail); 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; 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 bool assertMidSliderJudgementFail() => judgementResults[^2].Type == HitResult.SmallTickMiss;
private ScoreAccessibleReplayPlayer currentPlayer; private void performTest(List<ReplayFrame> frames, Slider? slider = null, double? bpm = null, int? tickRate = null)
private const float slider_path_length = 25;
private void performTest(List<ReplayFrame> frames)
{ {
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", () => AddStep("load player", () =>
{ {
var cpi = new ControlPointInfo();
if (bpm != null)
cpi.Add(0, new TimingControlPoint { BeatLength = 60000 / bpm.Value });
Beatmap.Value = CreateWorkingBeatmap(new Beatmap<OsuHitObject> Beatmap.Value = CreateWorkingBeatmap(new Beatmap<OsuHitObject>
{ {
HitObjects = HitObjects = { 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),
}
},
BeatmapInfo = BeatmapInfo =
{ {
Difficulty = new BeatmapDifficulty { SliderTickRate = 3 }, Difficulty = new BeatmapDifficulty { SliderTickRate = tickRate ?? 3 },
Ruleset = new OsuRuleset().RulesetInfo Ruleset = new OsuRuleset().RulesetInfo,
}, },
ControlPointInfo = cpi,
}); });
var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } }); var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } });
@ -375,7 +474,7 @@ namespace osu.Game.Rulesets.Osu.Tests
}; };
LoadScreen(currentPlayer = p); LoadScreen(currentPlayer = p);
judgementResults = new List<JudgementResult>(); judgementResults.Clear();
}); });
AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0); AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0);

View File

@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
/// <item><description>and slider difficulty.</description></item> /// <item><description>and slider difficulty.</description></item>
/// </list> /// </list>
/// </summary> /// </summary>
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) if (current.BaseObject is Spinner || current.Index <= 1 || current.Previous(0).BaseObject is Spinner)
return 0; return 0;
@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.StrainTime; 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. // 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 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 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. // As above, do the same for the previous hitobject.
double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.StrainTime; 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 travelVelocity = osuLastLastObj.TravelDistance / osuLastLastObj.TravelTime;
double movementVelocity = osuLastObj.MinimumJumpDistance / osuLastObj.MinimumJumpTime; 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); aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier + velocityChangeBonus * velocity_change_multiplier);
// Add in additional slider velocity bonus. // Add in additional slider velocity bonus.
if (withSliders) if (withSliderTravelDistance)
aimStrain += sliderBonus * slider_multiplier; aimStrain += sliderBonus * slider_multiplier;
return aimStrain; return aimStrain;

View File

@ -3,6 +3,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Mods;
@ -214,7 +215,45 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
if (slider.LazyEndPosition != null) if (slider.LazyEndPosition != null)
return; 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<HitObject> nestedObjects = slider.NestedHitObjects;
SliderTick? lastRealTick = slider.NestedHitObjects.OfType<SliderTick>().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<HitObject> reordered = nestedObjects.ToList();
reordered.Remove(lastRealTick);
reordered.Add(lastRealTick);
nestedObjects = reordered;
}
slider.LazyTravelTime = trackingEndTime - slider.StartTime;
double endTimeMin = slider.LazyTravelTime / slider.SpanDuration; double endTimeMin = slider.LazyTravelTime / slider.SpanDuration;
if (endTimeMin % 2 >= 1) if (endTimeMin % 2 >= 1)
@ -223,12 +262,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
endTimeMin %= 1; endTimeMin %= 1;
slider.LazyEndPosition = slider.StackedPosition + slider.Path.PositionAt(endTimeMin); // temporary lazy end position until a real result can be derived. 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. 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); Vector2 currMovement = Vector2.Subtract(currMovementObj.StackedPosition, currCursorPosition);
double currMovementLength = scalingFactor * currMovement.Length; 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. // Amount of movement required so that the cursor position needs to be updated.
double requiredMovement = assumed_slider_radius; 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. // 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. // 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; slider.LazyTravelDistance += (float)currMovementLength;
} }
if (i == slider.NestedHitObjects.Count - 1) if (i == nestedObjects.Count - 1)
slider.LazyEndPosition = currCursorPosition; slider.LazyEndPosition = currCursorPosition;
} }
} }

View File

@ -47,7 +47,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
public Action<List<PathControlPoint>> SplitControlPointsRequested; public Action<List<PathControlPoint>> SplitControlPointsRequested;
[Resolved(CanBeNull = true)] [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) 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 // 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])); 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; Vector2 movementDelta = Parent!.ToLocalSpace(result?.ScreenSpacePosition ?? newHeadPosition) - hitObject.Position;
@ -309,7 +312,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
} }
else 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; 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. // Snap the path to the current beat divisor before checking length validity.
hitObject.SnapTo(snapProvider); hitObject.SnapTo(distanceSnapProvider);
if (!hitObject.Path.HasValidLength) if (!hitObject.Path.HasValidLength)
{ {
@ -332,7 +335,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
hitObject.Position = oldPosition; hitObject.Position = oldPosition;
hitObject.StartTime = oldStartTime; hitObject.StartTime = oldStartTime;
// Snap the path length again to undo the invalid length. // Snap the path length again to undo the invalid length.
hitObject.SnapTo(snapProvider); hitObject.SnapTo(distanceSnapProvider);
return; return;
} }

View File

@ -39,7 +39,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
private int currentSegmentLength; private int currentSegmentLength;
[Resolved(CanBeNull = true)] [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; protected override bool IsValidForPlacement => HitObject.Path.HasValidLength;
@ -198,7 +201,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
} }
// Update the cursor position. // 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; cursor.Position = ToLocalSpace(result?.ScreenSpacePosition ?? inputManager.CurrentState.Mouse.Position) - HitObject.Position;
} }
else if (cursor != null) else if (cursor != null)
@ -230,7 +233,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
private void updateSlider() 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); bodyPiece.UpdateFrom(HitObject);
headCirclePiece.UpdateFrom(HitObject.HeadCircle); headCirclePiece.UpdateFrom(HitObject.HeadCircle);

View File

@ -40,7 +40,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
protected PathControlPointVisualiser<Slider> ControlPointVisualiser { get; private set; } protected PathControlPointVisualiser<Slider> ControlPointVisualiser { get; private set; }
[Resolved(CanBeNull = true)] [Resolved(CanBeNull = true)]
private IDistanceSnapProvider snapProvider { get; set; } private IPositionSnapProvider positionSnapProvider { get; set; }
[Resolved(CanBeNull = true)]
private IDistanceSnapProvider distanceSnapProvider { get; set; }
[Resolved(CanBeNull = true)] [Resolved(CanBeNull = true)]
private IPlacementHandler placementHandler { get; set; } private IPlacementHandler placementHandler { get; set; }
@ -194,7 +197,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
{ {
if (placementControlPoint != null) 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; 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 // Move the control points from the insertion index onwards to make room for the insertion
controlPoints.Insert(insertionIndex, pathControlPoint); controlPoints.Insert(insertionIndex, pathControlPoint);
HitObject.SnapTo(snapProvider); HitObject.SnapTo(distanceSnapProvider);
return pathControlPoint; 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. // 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 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) if (controlPoints.Count <= 1 || !HitObject.Path.HasValidLength)

View File

@ -0,0 +1,31 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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);
}
}
}

View File

@ -17,7 +17,6 @@ using osu.Framework.Input.Events;
using osu.Framework.Utils; using osu.Framework.Utils;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools; using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
@ -30,7 +29,7 @@ using osuTK;
namespace osu.Game.Rulesets.Osu.Edit namespace osu.Game.Rulesets.Osu.Edit
{ {
public partial class OsuHitObjectComposer : DistancedHitObjectComposer<OsuHitObject> public partial class OsuHitObjectComposer : HitObjectComposer<OsuHitObject>
{ {
public OsuHitObjectComposer(Ruleset ruleset) public OsuHitObjectComposer(Ruleset ruleset)
: base(ruleset) : base(ruleset)
@ -49,18 +48,27 @@ namespace osu.Game.Rulesets.Osu.Edit
private readonly Bindable<TernaryState> rectangularGridSnapToggle = new Bindable<TernaryState>(); private readonly Bindable<TernaryState> rectangularGridSnapToggle = new Bindable<TernaryState>();
protected override IEnumerable<TernaryButton> CreateTernaryButtons() => base.CreateTernaryButtons().Concat(new[] protected override IEnumerable<TernaryButton> CreateTernaryButtons()
{ => base.CreateTernaryButtons()
new TernaryButton(rectangularGridSnapToggle, "Grid Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Th }) .Concat(DistanceSnapProvider.CreateTernaryButtons())
}); .Concat(new[]
{
new TernaryButton(rectangularGridSnapToggle, "Grid Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Th })
});
private BindableList<HitObject> selectedHitObjects; private BindableList<HitObject> selectedHitObjects;
private Bindable<HitObject> placementObject; private Bindable<HitObject> placementObject;
[Cached(typeof(IDistanceSnapProvider))]
protected readonly OsuDistanceSnapProvider DistanceSnapProvider = new OsuDistanceSnapProvider();
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load()
{ {
AddInternal(DistanceSnapProvider);
DistanceSnapProvider.AttachToToolbox(RightToolbox);
// Give a bit of breathing room around the playfield content. // Give a bit of breathing room around the playfield content.
PlayfieldContentContainer.Padding = new MarginPadding(10); PlayfieldContentContainer.Padding = new MarginPadding(10);
@ -81,7 +89,7 @@ namespace osu.Game.Rulesets.Osu.Edit
placementObject = EditorBeatmap.PlacementObject.GetBoundCopy(); placementObject = EditorBeatmap.PlacementObject.GetBoundCopy();
placementObject.ValueChanged += _ => updateDistanceSnapGrid(); placementObject.ValueChanged += _ => updateDistanceSnapGrid();
DistanceSnapToggle.ValueChanged += _ => updateDistanceSnapGrid(); DistanceSnapProvider.DistanceSnapToggle.ValueChanged += _ => updateDistanceSnapGrid();
// we may be entering the screen with a selection already active // we may be entering the screen with a selection already active
updateDistanceSnapGrid(); updateDistanceSnapGrid();
@ -106,14 +114,6 @@ namespace osu.Game.Rulesets.Osu.Edit
private RectangularPositionSnapGrid rectangularPositionSnapGrid; 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() protected override void Update()
{ {
base.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. // 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 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. // 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)); (Vector2 distanceSnappedPosition, double distanceSnappedTime) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(snapResult.ScreenSpacePosition));
if (Precision.AlmostEquals(distanceSnapGrid.ToScreenSpace(distanceSnappedPosition), snapResult.ScreenSpacePosition, 1)) 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 (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)); (Vector2 pos, double time) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(screenSpacePosition));
@ -220,7 +220,7 @@ namespace osu.Game.Rulesets.Osu.Edit
distanceSnapGridCache.Invalidate(); distanceSnapGridCache.Invalidate();
distanceSnapGrid = null; distanceSnapGrid = null;
if (DistanceSnapToggle.Value != TernaryState.True) if (DistanceSnapProvider.DistanceSnapToggle.Value != TernaryState.True)
return; return;
switch (BlueprintContainer.CurrentTool) switch (BlueprintContainer.CurrentTool)
@ -262,14 +262,6 @@ namespace osu.Game.Rulesets.Osu.Edit
base.OnKeyUp(e); 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 bool gridSnapMomentary;
private void handleToggleViaKey(KeyboardEvent key) private void handleToggleViaKey(KeyboardEvent key)

View File

@ -129,7 +129,7 @@ namespace osu.Game.Rulesets.Osu.Mods
}); });
break; break;
case SliderEventType.LastTick: case SliderEventType.Tail:
AddNested(TailCircle = new StrictTrackingSliderTailCircle(this) AddNested(TailCircle = new StrictTrackingSliderTailCircle(this)
{ {
RepeatIndex = e.SpanIndex, RepeatIndex = e.SpanIndex,

View File

@ -264,7 +264,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
protected override void CheckForResult(bool userTriggered, double timeOffset) protected override void CheckForResult(bool userTriggered, double timeOffset)
{ {
if (userTriggered || Time.Current < HitObject.EndTime) if (userTriggered || !TailCircle.Judged || Time.Current < HitObject.EndTime)
return; return;
// If only the nested hitobjects are judged, then the slider's own judgement is ignored for scoring purposes. // If only the nested hitobjects are judged, then the slider's own judgement is ignored for scoring purposes.

View File

@ -11,6 +11,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Input; using osu.Framework.Input;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Skinning.Default; using osu.Game.Rulesets.Osu.Skinning.Default;
@ -153,9 +154,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
Tracking = Tracking =
// in valid time range // 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 // in valid position range
lastScreenSpaceMousePosition.HasValue && followCircleReceptor.ReceivePositionalInputAt(lastScreenSpaceMousePosition.Value) && && lastScreenSpaceMousePosition.HasValue && followCircleReceptor.ReceivePositionalInputAt(lastScreenSpaceMousePosition.Value) &&
// valid action // valid action
(actions?.Any(isValidTrackingAction) ?? false); (actions?.Any(isValidTrackingAction) ?? false);

View File

@ -8,6 +8,7 @@ using JetBrains.Annotations;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
using osu.Game.Skinning; using osu.Game.Skinning;
@ -125,8 +126,22 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
protected override void CheckForResult(bool userTriggered, double timeOffset) protected override void CheckForResult(bool userTriggered, double timeOffset)
{ {
if (!userTriggered && timeOffset >= 0) if (userTriggered)
ApplyResult(r => r.Type = Tracking ? r.Judgement.MaxResult : r.Judgement.MinResult); 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() protected override void OnApply()

View File

@ -204,11 +204,7 @@ namespace osu.Game.Rulesets.Osu.Objects
}); });
break; break;
case SliderEventType.LastTick: case SliderEventType.Tail:
// 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.
AddNested(TailCircle = new SliderTailCircle(this) AddNested(TailCircle = new SliderTailCircle(this)
{ {
RepeatIndex = e.SpanIndex, RepeatIndex = e.SpanIndex,

View File

@ -2,16 +2,11 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Osu.Judgements;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Osu.Objects namespace osu.Game.Rulesets.Osu.Objects
{ {
/// <summary>
/// Note that this should not be used for timing correctness.
/// See <see cref="SliderEventType.LastTick"/> usage in <see cref="Slider"/> for more information.
/// </summary>
public class SliderTailCircle : SliderEndCircle public class SliderTailCircle : SliderEndCircle
{ {
public SliderTailCircle(Slider slider) public SliderTailCircle(Slider slider)

View File

@ -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:

View File

@ -0,0 +1,19 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<Container> GetTargetContainers(HitObjectComposer composer) => new[]
{
((TaikoPlayfield)composer.Playfield).UnderlayElements
};
}
}

View File

@ -33,5 +33,7 @@ namespace osu.Game.Rulesets.Taiko.Edit
protected override ComposeBlueprintContainer CreateBlueprintContainer() protected override ComposeBlueprintContainer CreateBlueprintContainer()
=> new TaikoBlueprintContainer(this); => new TaikoBlueprintContainer(this);
protected override BeatSnapGrid CreateBeatSnapGrid() => new TaikoBeatSnapGrid();
} }
} }

View File

@ -40,6 +40,8 @@ namespace osu.Game.Rulesets.Taiko.UI
/// </summary> /// </summary>
public Bindable<bool> ClassicHitTargetPosition = new BindableBool(); public Bindable<bool> ClassicHitTargetPosition = new BindableBool();
public Container UnderlayElements { get; private set; } = null!;
private Container<HitExplosion> hitExplosionContainer; private Container<HitExplosion> hitExplosionContainer;
private Container<KiaiHitExplosion> kiaiExplosionContainer; private Container<KiaiHitExplosion> kiaiExplosionContainer;
private JudgementContainer<DrawableTaikoJudgement> judgementContainer; private JudgementContainer<DrawableTaikoJudgement> judgementContainer;
@ -130,7 +132,14 @@ namespace osu.Game.Rulesets.Taiko.UI
{ {
Name = "Bar line content", Name = "Bar line content",
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Child = barLinePlayfield = new BarLinePlayfield(), Children = new Drawable[]
{
UnderlayElements = new Container
{
RelativeSizeAxes = Axes.Both,
},
barLinePlayfield = new BarLinePlayfield(),
}
}, },
hitObjectContent = new Container hitObjectContent = new Container
{ {

View File

@ -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(); 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].Type, Is.EqualTo(SliderEventType.LegacyLastTick));
Assert.That(events[2].Time, Is.EqualTo(span_duration + SliderEventGenerator.LAST_TICK_OFFSET)); Assert.That(events[2].Time, Is.EqualTo(span_duration + SliderEventGenerator.TAIL_LENIENCY));
} }
[Test] [Test]

View File

@ -35,6 +35,7 @@ namespace osu.Game.Tests.Database
Logger.Log($"Running test using realm file {testStorage.GetFullPath(realm.Filename)}"); Logger.Log($"Running test using realm file {testStorage.GetFullPath(realm.Filename)}");
testAction(realm, testStorage); testAction(realm, testStorage);
// ReSharper disable once DisposeOnUsingVariable
realm.Dispose(); realm.Dispose();
Logger.Log($"Final database size: {getFileSize(testStorage, realm)}"); 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)}"); Logger.Log($"Running test using realm file {testStorage.GetFullPath(realm.Filename)}");
await testAction(realm, testStorage); await testAction(realm, testStorage);
// ReSharper disable once DisposeOnUsingVariable
realm.Dispose(); realm.Dispose();
Logger.Log($"Final database size: {getFileSize(testStorage, realm)}"); Logger.Log($"Final database size: {getFileSize(testStorage, realm)}");

View File

@ -3,7 +3,6 @@
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Cursor;
@ -230,25 +229,25 @@ namespace osu.Game.Tests.Editing
} }
private void assertSnapDistance(float expectedDistance, HitObject? referenceObject, bool includeSliderVelocity) 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) 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) 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) 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) 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 private partial class TestHitObjectComposer : OsuHitObjectComposer
{ {
public new EditorBeatmap EditorBeatmap => base.EditorBeatmap; public new EditorBeatmap EditorBeatmap => base.EditorBeatmap;
public new Bindable<double> DistanceSpacingMultiplier => base.DistanceSpacingMultiplier; public new IDistanceSnapProvider DistanceSnapProvider => base.DistanceSnapProvider;
public TestHitObjectComposer() public TestHitObjectComposer()
: base(new OsuRuleset()) : base(new OsuRuleset())

View File

@ -187,11 +187,9 @@ namespace osu.Game.Tests.Visual.Editing
private class SnapProvider : IDistanceSnapProvider private class SnapProvider : IDistanceSnapProvider
{ {
public SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.AllGrids) => new SnapResult(screenSpacePosition, 0);
public Bindable<double> DistanceSpacingMultiplier { get; } = new BindableDouble(1); public Bindable<double> DistanceSpacingMultiplier { get; } = new BindableDouble(1);
IBindable<double> IDistanceSnapProvider.DistanceSpacingMultiplier => DistanceSpacingMultiplier; Bindable<double> IDistanceSnapProvider.DistanceSpacingMultiplier => DistanceSpacingMultiplier;
public float GetBeatSnapDistanceAt(HitObject referenceObject, bool useReferenceSliderVelocity = true) => beat_snap_distance; public float GetBeatSnapDistanceAt(HitObject referenceObject, bool useReferenceSliderVelocity = true) => beat_snap_distance;

View File

@ -1,8 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
#nullable disable using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
@ -24,16 +23,13 @@ using osu.Game.Overlays.OSD;
using osu.Game.Overlays.Settings.Sections; using osu.Game.Overlays.Settings.Sections;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.UI;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Components.TernaryButtons; using osu.Game.Screens.Edit.Components.TernaryButtons;
namespace osu.Game.Rulesets.Edit namespace osu.Game.Rulesets.Edit
{ {
/// <summary> public abstract partial class ComposerDistanceSnapProvider : Component, IDistanceSnapProvider, IScrollBindingHandler<GlobalAction>
/// Represents a <see cref="HitObjectComposer{TObject}"/> for rulesets with the concept of distances between objects.
/// </summary>
/// <typeparam name="TObject">The base type of supported objects.</typeparam>
public abstract partial class DistancedHitObjectComposer<TObject> : HitObjectComposer<TObject>, IDistanceSnapProvider, IScrollBindingHandler<GlobalAction>
where TObject : HitObject
{ {
private const float adjust_step = 0.1f; private const float adjust_step = 0.1f;
@ -44,27 +40,38 @@ namespace osu.Game.Rulesets.Edit
Precision = 0.01, Precision = 0.01,
}; };
IBindable<double> IDistanceSnapProvider.DistanceSpacingMultiplier => DistanceSpacingMultiplier; Bindable<double> IDistanceSnapProvider.DistanceSpacingMultiplier => DistanceSpacingMultiplier;
private ExpandableSlider<double, SizeSlider<double>> distanceSpacingSlider; private ExpandableSlider<double, SizeSlider<double>> distanceSpacingSlider = null!;
private ExpandableButton currentDistanceSpacingButton; private ExpandableButton currentDistanceSpacingButton = null!;
[Resolved(canBeNull: true)] [Resolved]
private OnScreenDisplay onScreenDisplay { get; set; } private Playfield playfield { get; set; } = null!;
protected readonly Bindable<TernaryState> DistanceSnapToggle = new Bindable<TernaryState>(); [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<TernaryState> DistanceSnapToggle = new Bindable<TernaryState>();
private bool distanceSnapMomentary; private bool distanceSnapMomentary;
protected DistancedHitObjectComposer(Ruleset ruleset) private EditorToolboxGroup? toolboxGroup;
: base(ruleset)
{
}
[BackgroundDependencyLoader] public void AttachToToolbox(ExpandingToolboxContainer toolboxContainer)
private void load()
{ {
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, Alpha = DistanceSpacingMultiplier.Disabled ? 0 : 1,
Children = new Drawable[] 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() 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) if (lastBefore == null)
return 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) if (firstAfter == null)
return null; return null;
@ -138,41 +171,10 @@ namespace osu.Game.Rulesets.Edit
} }
} }
protected override void LoadComplete() public IEnumerable<TernaryButton> CreateTernaryButtons() => new[]
{
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<TernaryButton> CreateTernaryButtons() => base.CreateTernaryButtons().Concat(new[]
{ {
new TernaryButton(DistanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Ruler }) new TernaryButton(DistanceSnapToggle, "Distance Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Ruler })
}); };
protected override bool OnKeyDown(KeyDownEvent e) protected override bool OnKeyDown(KeyDownEvent e)
{ {
@ -242,26 +244,28 @@ namespace osu.Game.Rulesets.Edit
return true; return true;
} }
#region IDistanceSnapProvider
public virtual float GetBeatSnapDistanceAt(HitObject referenceObject, bool useReferenceSliderVelocity = true) public virtual float GetBeatSnapDistanceAt(HitObject referenceObject, bool useReferenceSliderVelocity = true)
{ {
return (float)(100 * (useReferenceSliderVelocity && referenceObject is IHasSliderVelocity hasSliderVelocity ? hasSliderVelocity.SliderVelocityMultiplier : 1) * EditorBeatmap.Difficulty.SliderMultiplier * 1 return (float)(100 * (useReferenceSliderVelocity && referenceObject is IHasSliderVelocity hasSliderVelocity ? hasSliderVelocity.SliderVelocityMultiplier : 1) * editorBeatmap.Difficulty.SliderMultiplier * 1
/ BeatSnapProvider.BeatDivisor); / beatSnapProvider.BeatDivisor);
} }
public virtual float DurationToDistance(HitObject referenceObject, double duration) 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)); return (float)(duration / beatLength * GetBeatSnapDistanceAt(referenceObject));
} }
public virtual double DistanceToDuration(HitObject referenceObject, float distance) 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; return distance / GetBeatSnapDistanceAt(referenceObject) * beatLength;
} }
public virtual double FindSnappedDuration(HitObject referenceObject, float distance) 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) public virtual float FindSnappedDistance(HitObject referenceObject, float distance)
{ {
@ -269,9 +273,9 @@ namespace osu.Game.Rulesets.Edit
double actualDuration = startTime + DistanceToDuration(referenceObject, distance); 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. // 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. // 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); return DurationToDistance(referenceObject, snappedEndTime - startTime);
} }
#endregion
private partial class DistanceSpacingToast : Toast private partial class DistanceSpacingToast : Toast
{ {
private readonly ValueChangedEvent<double> change; private readonly ValueChangedEvent<double> change;

View File

@ -76,7 +76,7 @@ namespace osu.Game.Rulesets.Edit
protected readonly Container LayerBelowRuleset = new Container { RelativeSizeAxes = Axes.Both }; protected readonly Container LayerBelowRuleset = new Container { RelativeSizeAxes = Axes.Both };
private InputManager inputManager; protected InputManager InputManager { get; private set; }
private EditorRadioButtonCollection toolboxCollection; private EditorRadioButtonCollection toolboxCollection;
@ -119,9 +119,12 @@ namespace osu.Game.Rulesets.Edit
return; return;
} }
if (DrawableRuleset is IDrawableScrollingRuleset scrollingRuleset)
dependencies.CacheAs(scrollingRuleset.ScrollingInfo);
dependencies.CacheAs(Playfield); dependencies.CacheAs(Playfield);
InternalChildren = new Drawable[] InternalChildren = new[]
{ {
PlayfieldContentContainer = new Container PlayfieldContentContainer = new Container
{ {
@ -201,7 +204,7 @@ namespace osu.Game.Rulesets.Edit
}, },
} }
} }
} },
}; };
toolboxCollection.Items = CompositionTools toolboxCollection.Items = CompositionTools
@ -232,7 +235,7 @@ namespace osu.Game.Rulesets.Edit
{ {
base.LoadComplete(); base.LoadComplete();
inputManager = GetContainingInputManager(); InputManager = GetContainingInputManager();
hasTiming = EditorBeatmap.HasTiming.GetBoundCopy(); hasTiming = EditorBeatmap.HasTiming.GetBoundCopy();
hasTiming.BindValueChanged(timing => hasTiming.BindValueChanged(timing =>
@ -270,7 +273,7 @@ namespace osu.Game.Rulesets.Edit
public override IEnumerable<DrawableHitObject> HitObjects => drawableRulesetWrapper.Playfield.AllHitObjects; public override IEnumerable<DrawableHitObject> 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);
/// <summary> /// <summary>
/// Defines all available composition tools, listed on the left side of the editor screen as button controls. /// Defines all available composition tools, listed on the left side of the editor screen as button controls.

View File

@ -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. /// A snap provider which given a reference hit object and proposed distance from it, offers a more correct duration or distance value.
/// </summary> /// </summary>
[Cached] [Cached]
public interface IDistanceSnapProvider : IPositionSnapProvider public interface IDistanceSnapProvider
{ {
/// <summary> /// <summary>
/// A multiplier which changes the ratio of distance travelled per time unit. /// 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. /// Importantly, this is provided for manual usage, and not multiplied into any of the methods exposed by this interface.
/// </summary> /// </summary>
/// <seealso cref="BeatmapInfo.DistanceSpacing"/> /// <seealso cref="BeatmapInfo.DistanceSpacing"/>
IBindable<double> DistanceSpacingMultiplier { get; } Bindable<double> DistanceSpacingMultiplier { get; }
/// <summary> /// <summary>
/// Retrieves the distance between two points within a timing point that are one beat length apart. /// Retrieves the distance between two points within a timing point that are one beat length apart.

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System.Linq;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
@ -8,9 +9,11 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Screens.Edit.Components.TernaryButtons; using osu.Game.Screens.Edit.Components.TernaryButtons;
using osu.Game.Screens.Edit.Compose.Components;
using osuTK; using osuTK;
namespace osu.Game.Rulesets.Edit namespace osu.Game.Rulesets.Edit
@ -21,6 +24,13 @@ namespace osu.Game.Rulesets.Edit
private readonly Bindable<TernaryState> showSpeedChanges = new Bindable<TernaryState>(); private readonly Bindable<TernaryState> showSpeedChanges = new Bindable<TernaryState>();
private Bindable<bool> configShowSpeedChanges = null!; private Bindable<bool> configShowSpeedChanges = null!;
private BeatSnapGrid? beatSnapGrid;
/// <summary>
/// Construct an optional beat snap grid.
/// </summary>
protected virtual BeatSnapGrid? CreateBeatSnapGrid() => null;
protected ScrollingHitObjectComposer(Ruleset ruleset) protected ScrollingHitObjectComposer(Ruleset ruleset)
: base(ruleset) : base(ruleset)
{ {
@ -57,6 +67,42 @@ namespace osu.Game.Rulesets.Edit
configShowSpeedChanges.Value = enabled; configShowSpeedChanges.Value = enabled;
}, true); }, 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;
}
} }
} }
} }

View File

@ -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. // 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 const double rate_change_on_miss = 0.95d;
private IAdjustableAudioComponent? track;
private double targetRate = 1d; private double targetRate = 1d;
/// <summary> /// <summary>
@ -123,24 +122,27 @@ namespace osu.Game.Rulesets.Mods
/// </summary> /// </summary>
private readonly Dictionary<HitObject, double> ratesForRewinding = new Dictionary<HitObject, double>(); private readonly Dictionary<HitObject, double> ratesForRewinding = new Dictionary<HitObject, double>();
private readonly RateAdjustModHelper rateAdjustHelper;
public ModAdaptiveSpeed() public ModAdaptiveSpeed()
{ {
rateAdjustHelper = new RateAdjustModHelper(SpeedChange);
rateAdjustHelper.HandleAudioAdjustments(AdjustPitch);
InitialRate.BindValueChanged(val => InitialRate.BindValueChanged(val =>
{ {
SpeedChange.Value = val.NewValue; SpeedChange.Value = val.NewValue;
targetRate = val.NewValue; targetRate = val.NewValue;
}); });
AdjustPitch.BindValueChanged(adjustPitchChanged);
} }
public void ApplyToTrack(IAdjustableAudioComponent track) public void ApplyToTrack(IAdjustableAudioComponent track)
{ {
this.track = track;
InitialRate.TriggerChange(); InitialRate.TriggerChange();
AdjustPitch.TriggerChange();
recentRates.Clear(); recentRates.Clear();
recentRates.AddRange(Enumerable.Repeat(InitialRate.Value, recent_rate_count)); recentRates.AddRange(Enumerable.Repeat(InitialRate.Value, recent_rate_count));
rateAdjustHelper.ApplyToTrack(track);
} }
public void ApplyToSample(IAdjustableAudioComponent sample) public void ApplyToSample(IAdjustableAudioComponent sample)
@ -199,15 +201,6 @@ namespace osu.Game.Rulesets.Mods
} }
} }
private void adjustPitchChanged(ValueChangedEvent<bool> 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<HitObject> getAllApplicableHitObjects(IEnumerable<HitObject> hitObjects) private IEnumerable<HitObject> getAllApplicableHitObjects(IEnumerable<HitObject> hitObjects)
{ {
foreach (var hitObject in hitObjects) foreach (var hitObject in hitObjects)

View File

@ -5,21 +5,36 @@ using osu.Framework.Audio;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation; using osu.Framework.Localisation;
using osu.Game.Configuration;
namespace osu.Game.Rulesets.Mods namespace osu.Game.Rulesets.Mods
{ {
public abstract class ModDaycore : ModHalfTime public abstract class ModDaycore : ModRateAdjust
{ {
public override string Name => "Daycore"; public override string Name => "Daycore";
public override string Acronym => "DC"; public override string Acronym => "DC";
public override IconUsage? Icon => null; public override IconUsage? Icon => null;
public override ModType Type => ModType.DifficultyReduction;
public override LocalisableString Description => "Whoaaaaa..."; public override LocalisableString Description => "Whoaaaaa...";
[SettingSource("Speed decrease", "The actual decrease to apply")]
public override BindableNumber<double> SpeedChange { get; } = new BindableDouble(0.75)
{
MinValue = 0.5,
MaxValue = 0.99,
Precision = 0.01,
};
private readonly BindableNumber<double> tempoAdjust = new BindableDouble(1); private readonly BindableNumber<double> tempoAdjust = new BindableDouble(1);
private readonly BindableNumber<double> freqAdjust = new BindableDouble(1); private readonly BindableNumber<double> freqAdjust = new BindableDouble(1);
private readonly RateAdjustModHelper rateAdjustHelper;
protected ModDaycore() 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 => SpeedChange.BindValueChanged(val =>
{ {
freqAdjust.Value = SpeedChange.Default; freqAdjust.Value = SpeedChange.Default;
@ -29,9 +44,10 @@ namespace osu.Game.Rulesets.Mods
public override void ApplyToTrack(IAdjustableAudioComponent track) public override void ApplyToTrack(IAdjustableAudioComponent track)
{ {
// base.ApplyToTrack() intentionally not called (different tempo adjustment is applied)
track.AddAdjustment(AdjustableProperty.Frequency, freqAdjust); track.AddAdjustment(AdjustableProperty.Frequency, freqAdjust);
track.AddAdjustment(AdjustableProperty.Tempo, tempoAdjust); track.AddAdjustment(AdjustableProperty.Tempo, tempoAdjust);
} }
public override double ScoreMultiplier => rateAdjustHelper.ScoreMultiplier;
} }
} }

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using osu.Framework.Audio;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation; using osu.Framework.Localisation;
@ -26,21 +27,22 @@ namespace osu.Game.Rulesets.Mods
Precision = 0.01, 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 rateAdjustHelper = new RateAdjustModHelper(SpeedChange);
{ rateAdjustHelper.HandleAudioAdjustments(AdjustPitch);
// 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;
}
} }
public override void ApplyToTrack(IAdjustableAudioComponent track)
{
rateAdjustHelper.ApplyToTrack(track);
}
public override double ScoreMultiplier => rateAdjustHelper.ScoreMultiplier;
} }
} }

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using osu.Framework.Audio;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation; using osu.Framework.Localisation;
@ -26,18 +27,22 @@ namespace osu.Game.Rulesets.Mods
Precision = 0.01, 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 rateAdjustHelper = new RateAdjustModHelper(SpeedChange);
{ rateAdjustHelper.HandleAudioAdjustments(AdjustPitch);
// 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;
}
} }
public override void ApplyToTrack(IAdjustableAudioComponent track)
{
rateAdjustHelper.ApplyToTrack(track);
}
public override double ScoreMultiplier => rateAdjustHelper.ScoreMultiplier;
} }
} }

View File

@ -11,6 +11,7 @@ using osu.Framework.Localisation;
using osu.Game.Audio; using osu.Game.Audio;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Beatmaps.Timing; using osu.Game.Beatmaps.Timing;
using osu.Game.Configuration;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Containers; using osu.Game.Graphics.Containers;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
@ -19,22 +20,33 @@ using osu.Game.Skinning;
namespace osu.Game.Rulesets.Mods namespace osu.Game.Rulesets.Mods
{ {
public abstract class ModNightcore : ModDoubleTime public abstract class ModNightcore : ModRateAdjust
{ {
public override string Name => "Nightcore"; public override string Name => "Nightcore";
public override string Acronym => "NC"; public override string Acronym => "NC";
public override IconUsage? Icon => OsuIcon.ModNightcore; public override IconUsage? Icon => OsuIcon.ModNightcore;
public override ModType Type => ModType.DifficultyIncrease;
public override LocalisableString Description => "Uguuuuuuuu..."; public override LocalisableString Description => "Uguuuuuuuu...";
}
public abstract partial class ModNightcore<TObject> : ModNightcore, IApplicableToDrawableRuleset<TObject> [SettingSource("Speed increase", "The actual increase to apply")]
where TObject : HitObject public override BindableNumber<double> SpeedChange { get; } = new BindableDouble(1.5)
{ {
MinValue = 1.01,
MaxValue = 2,
Precision = 0.01,
};
private readonly BindableNumber<double> tempoAdjust = new BindableDouble(1); private readonly BindableNumber<double> tempoAdjust = new BindableDouble(1);
private readonly BindableNumber<double> freqAdjust = new BindableDouble(1); private readonly BindableNumber<double> freqAdjust = new BindableDouble(1);
private readonly RateAdjustModHelper rateAdjustHelper;
protected ModNightcore() 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 => SpeedChange.BindValueChanged(val =>
{ {
freqAdjust.Value = SpeedChange.Default; freqAdjust.Value = SpeedChange.Default;
@ -44,11 +56,16 @@ namespace osu.Game.Rulesets.Mods
public override void ApplyToTrack(IAdjustableAudioComponent track) public override void ApplyToTrack(IAdjustableAudioComponent track)
{ {
// base.ApplyToTrack() intentionally not called (different tempo adjustment is applied)
track.AddAdjustment(AdjustableProperty.Frequency, freqAdjust); track.AddAdjustment(AdjustableProperty.Frequency, freqAdjust);
track.AddAdjustment(AdjustableProperty.Tempo, tempoAdjust); track.AddAdjustment(AdjustableProperty.Tempo, tempoAdjust);
} }
public override double ScoreMultiplier => rateAdjustHelper.ScoreMultiplier;
}
public abstract partial class ModNightcore<TObject> : ModNightcore, IApplicableToDrawableRuleset<TObject>
where TObject : HitObject
{
public void ApplyToDrawableRuleset(DrawableRuleset<TObject> drawableRuleset) public void ApplyToDrawableRuleset(DrawableRuleset<TObject> drawableRuleset)
{ {
drawableRuleset.Overlays.Add(new NightcoreBeatContainer()); drawableRuleset.Overlays.Add(new NightcoreBeatContainer());

View File

@ -13,10 +13,7 @@ namespace osu.Game.Rulesets.Mods
public abstract BindableNumber<double> SpeedChange { get; } public abstract BindableNumber<double> SpeedChange { get; }
public virtual void ApplyToTrack(IAdjustableAudioComponent track) public abstract void ApplyToTrack(IAdjustableAudioComponent track);
{
track.AddAdjustment(AdjustableProperty.Tempo, SpeedChange);
}
public virtual void ApplyToSample(IAdjustableAudioComponent sample) public virtual void ApplyToSample(IAdjustableAudioComponent sample)
{ {

View File

@ -44,21 +44,21 @@ namespace osu.Game.Rulesets.Mods
Precision = 0.01, Precision = 0.01,
}; };
private IAdjustableAudioComponent? track; private readonly RateAdjustModHelper rateAdjustHelper;
protected ModTimeRamp() 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. // for preview purpose at song select. eventually we'll want to be able to update every frame.
FinalRate.BindValueChanged(_ => applyRateAdjustment(double.PositiveInfinity), true); FinalRate.BindValueChanged(_ => applyRateAdjustment(double.PositiveInfinity), true);
AdjustPitch.BindValueChanged(applyPitchAdjustment);
} }
public void ApplyToTrack(IAdjustableAudioComponent track) public void ApplyToTrack(IAdjustableAudioComponent track)
{ {
this.track = track; rateAdjustHelper.ApplyToTrack(track);
FinalRate.TriggerChange(); FinalRate.TriggerChange();
AdjustPitch.TriggerChange();
} }
public void ApplyToSample(IAdjustableAudioComponent sample) public void ApplyToSample(IAdjustableAudioComponent sample)
@ -95,16 +95,5 @@ namespace osu.Game.Rulesets.Mods
/// Adjust the rate along the specified ramp. /// Adjust the rate along the specified ramp.
/// </summary> /// </summary>
private void applyRateAdjustment(double time) => SpeedChange.Value = ApplyToRate(time); private void applyRateAdjustment(double time) => SpeedChange.Value = ApplyToRate(time);
private void applyPitchAdjustment(ValueChangedEvent<bool> 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;
} }
} }

View File

@ -0,0 +1,84 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
{
/// <summary>
/// Provides common functionality shared across various rate adjust mods.
/// </summary>
public class RateAdjustModHelper : IApplicableToTrack
{
public readonly IBindableNumber<double> SpeedChange;
private IAdjustableAudioComponent? track;
private BindableBool? adjustPitch;
/// <summary>
/// The score multiplier for the current <see cref="SpeedChange"/>.
/// </summary>
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;
}
}
/// <summary>
/// Construct a new <see cref="RateAdjustModHelper"/>.
/// </summary>
/// <param name="speedChange">The main speed adjust parameter which is exposed to the user.</param>
public RateAdjustModHelper(IBindableNumber<double> speedChange)
{
SpeedChange = speedChange;
}
/// <summary>
/// Setup audio track adjustments for a rate adjust mod.
/// Importantly, <see cref="ApplyToTrack"/> must be called when a track is obtained/changed for this to work.
/// </summary>
/// <param name="adjustPitch">The "adjust pitch" setting as exposed to the user.</param>
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;
});
}
/// <summary>
/// Should be invoked when a track is obtained / changed.
/// </summary>
/// <param name="track">The new track.</param>
/// <exception cref="InvalidOperationException">If this method is called before <see cref="HandleAudioAdjustments"/>.</exception>
public void ApplyToTrack(IAdjustableAudioComponent track)
{
if (adjustPitch == null)
throw new InvalidOperationException($"Must call {nameof(HandleAudioAdjustments)} first");
this.track = track;
adjustPitch.TriggerChange();
}
}
}

View File

@ -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. /// 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. /// 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 <see cref="SliderEventType.LegacyLastTick"/> *only* for osu!catch conversion, which relies on it to generate tiny ticks
/// correctly.
/// </summary> /// </summary>
public const double LAST_TICK_OFFSET = -36; public const double TAIL_LENIENCY = -36;
public static IEnumerable<SliderEventDescriptor> Generate(double startTime, double spanDuration, double velocity, double tickDistance, double totalDistance, int spanCount, public static IEnumerable<SliderEventDescriptor> Generate(double startTime, double spanDuration, double velocity, double tickDistance, double totalDistance, int spanCount,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
@ -84,18 +88,27 @@ namespace osu.Game.Rulesets.Objects
int finalSpanIndex = spanCount - 1; int finalSpanIndex = spanCount - 1;
double finalSpanStartTime = startTime + finalSpanIndex * spanDuration; 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 yield return new SliderEventDescriptor
{ {
Type = SliderEventType.LastTick, Type = SliderEventType.LegacyLastTick,
SpanIndex = finalSpanIndex, SpanIndex = finalSpanIndex,
SpanStartTime = finalSpanStartTime, SpanStartTime = finalSpanStartTime,
Time = finalSpanEndTime, Time = legacyLastTickTime,
PathProgress = finalProgress, PathProgress = legacyLastTickProgress,
}; };
yield return new SliderEventDescriptor yield return new SliderEventDescriptor
@ -183,9 +196,10 @@ namespace osu.Game.Rulesets.Objects
Tick, Tick,
/// <summary> /// <summary>
/// Occurs just before the tail. See <see cref="SliderEventGenerator.LAST_TICK_OFFSET"/>. /// Occurs just before the tail. See <see cref="SliderEventGenerator.TAIL_LENIENCY"/>.
/// Should generally be ignored.
/// </summary> /// </summary>
LastTick, LegacyLastTick,
Head, Head,
Tail, Tail,
Repeat Repeat

View File

@ -81,7 +81,7 @@ namespace osu.Game.Rulesets.UI.Scrolling
/// </summary> /// </summary>
protected readonly SortedList<MultiplierControlPoint> ControlPoints = new SortedList<MultiplierControlPoint>(Comparer<MultiplierControlPoint>.Default); protected readonly SortedList<MultiplierControlPoint> ControlPoints = new SortedList<MultiplierControlPoint>(Comparer<MultiplierControlPoint>.Default);
protected IScrollingInfo ScrollingInfo => scrollingInfo; public IScrollingInfo ScrollingInfo => scrollingInfo;
[Cached(Type = typeof(IScrollingInfo))] [Cached(Type = typeof(IScrollingInfo))]
private readonly LocalScrollingInfo scrollingInfo; private readonly LocalScrollingInfo scrollingInfo;

View File

@ -11,5 +11,7 @@ namespace osu.Game.Rulesets.UI.Scrolling
public interface IDrawableScrollingRuleset public interface IDrawableScrollingRuleset
{ {
ScrollVisualisationMethod VisualisationMethod { get; } ScrollVisualisationMethod VisualisationMethod { get; }
IScrollingInfo ScrollingInfo { get; }
} }
} }

View File

@ -0,0 +1,213 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
{
/// <summary>
/// A grid which displays coloured beat divisor lines in proximity to the selection or placement cursor.
/// </summary>
public abstract partial class BeatSnapGrid : CompositeComponent
{
private const double visible_range = 750;
/// <summary>
/// The range of time values of the current selection.
/// </summary>
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<ScrollingHitObjectContainer> grids = new List<ScrollingHitObjectContainer>();
private readonly DrawablePool<DrawableGridLine> linesPool = new DrawablePool<DrawableGridLine>(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<Container> 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<DrawableGridLine>())
{
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<ScrollingDirection> direction = new Bindable<ScrollingDirection>();
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<ScrollingDirection> 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;
}
}
}
}