1
0
mirror of https://github.com/ppy/osu.git synced 2024-12-14 13:22:55 +08:00

Merge branch 'master' into score-encoding-cleanup

This commit is contained in:
Bartłomiej Dach 2023-10-26 09:04:05 +02:00
commit ca4b09f8ef
No known key found for this signature in database
208 changed files with 3616 additions and 1845 deletions

View File

@ -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() && needs.directory.result == 'success' }}
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

@ -73,7 +73,7 @@ namespace osu.Game.Rulesets.Catch.Tests
private float getCaughtObjectPosition(Fruit fruit) private float getCaughtObjectPosition(Fruit fruit)
{ {
var caughtObject = catcher.ChildrenOfType<CaughtObject>().Single(c => c.HitObject == fruit); var caughtObject = catcher.ChildrenOfType<CaughtObject>().Single(c => c.HitObject == fruit);
return caughtObject.Parent.ToSpaceOfOtherDrawable(caughtObject.Position, catcher).X; return caughtObject.Parent!.ToSpaceOfOtherDrawable(caughtObject.Position, catcher).X;
} }
private void catchFruit(Fruit fruit, float x) private void catchFruit(Fruit fruit, float x)

View File

@ -54,7 +54,7 @@ namespace osu.Game.Rulesets.Catch
new KeyBinding(InputKey.X, CatchAction.MoveRight), new KeyBinding(InputKey.X, CatchAction.MoveRight),
new KeyBinding(InputKey.Right, CatchAction.MoveRight), new KeyBinding(InputKey.Right, CatchAction.MoveRight),
new KeyBinding(InputKey.Shift, CatchAction.Dash), new KeyBinding(InputKey.Shift, CatchAction.Dash),
new KeyBinding(InputKey.Shift, CatchAction.Dash), new KeyBinding(InputKey.MouseLeft, CatchAction.Dash),
}; };
public override IEnumerable<Mod> ConvertFromLegacyMods(LegacyMods mods) public override IEnumerable<Mod> ConvertFromLegacyMods(LegacyMods mods)

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

@ -41,7 +41,7 @@ namespace osu.Game.Rulesets.Catch.Edit
{ {
base.Update(); base.Update();
Scale = new Vector2(Math.Min(Parent.ChildSize.X / CatchPlayfield.WIDTH, Parent.ChildSize.Y / CatchPlayfield.HEIGHT)); Scale = new Vector2(Math.Min(Parent!.ChildSize.X / CatchPlayfield.WIDTH, Parent!.ChildSize.Y / CatchPlayfield.HEIGHT));
Height = 1 / Scale.Y; Height = 1 / Scale.Y;
} }
} }

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()
.Concat(DistanceSnapProvider.CreateTernaryButtons());
protected override DrawableRuleset<CatchHitObject> CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods) =>
new DrawableCatchEditorRuleset(ruleset, beatmap, mods)
{ {
base.LoadComplete(); TimeRangeMultiplier = { BindTarget = timeRangeMultiplier, }
};
inputManager = GetContainingInputManager(); protected override ComposeBlueprintContainer CreateBlueprintContainer() => new CatchBlueprintContainer(this);
}
protected override void UpdateAfterChildren() protected override BeatSnapGrid CreateBeatSnapGrid() => new CatchBeatSnapGrid();
protected override IReadOnlyList<HitObjectCompositionTool> CompositionTools => new HitObjectCompositionTool[]
{ {
base.UpdateAfterChildren(); new FruitCompositionTool(),
new JuiceStreamCompositionTool(),
new BananaShowerCompositionTool()
};
if (BlueprintContainer.CurrentTool is SelectTool) public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{
if (EditorBeatmap.SelectedHitObjects.Any())
{
beatSnapGrid.SelectionTimeRange = (EditorBeatmap.SelectedHitObjects.Min(h => h.StartTime), EditorBeatmap.SelectedHitObjects.Max(h => h.GetEndTime()));
}
else
beatSnapGrid.SelectionTimeRange = null;
}
else
{
var result = FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position);
if (result.Time is double time)
beatSnapGrid.SelectionTimeRange = (time, time);
else
beatSnapGrid.SelectionTimeRange = null;
}
}
protected override double ReadCurrentDistanceSnap(HitObject before, HitObject after)
{
// osu!catch's distance snap implementation is limited, in that a custom spacing cannot be specified.
// 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;
}
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

@ -8,6 +8,7 @@ using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Catch.UI; using osu.Game.Rulesets.Catch.UI;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osuTK; using osuTK;
@ -151,7 +152,7 @@ namespace osu.Game.Rulesets.Catch.Objects
TimePreempt = (float)IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, 1800, 1200, 450); TimePreempt = (float)IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, 1800, 1200, 450);
Scale = (1.0f - 0.7f * (difficulty.CircleSize - 5) / 5) / 2; Scale = LegacyRulesetExtensions.CalculateScaleFromCircleSize(difficulty.CircleSize);
} }
protected override HitWindows CreateHitWindows() => HitWindows.Empty; protected override HitWindows CreateHitWindows() => HitWindows.Empty;

View File

@ -17,6 +17,7 @@ using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.Objects.Drawables; using osu.Game.Rulesets.Catch.Objects.Drawables;
using osu.Game.Rulesets.Catch.Skinning; using osu.Game.Rulesets.Catch.Skinning;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Skinning; using osu.Game.Skinning;
using osuTK; using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
@ -182,11 +183,6 @@ namespace osu.Game.Rulesets.Catch.UI
/// </summary> /// </summary>
public Drawable CreateProxiedContent() => caughtObjectContainer.CreateProxy(); public Drawable CreateProxiedContent() => caughtObjectContainer.CreateProxy();
/// <summary>
/// Calculates the scale of the catcher based off the provided beatmap difficulty.
/// </summary>
private static Vector2 calculateScale(IBeatmapDifficultyInfo difficulty) => new Vector2(1.0f - 0.7f * (difficulty.CircleSize - 5) / 5);
/// <summary> /// <summary>
/// Calculates the width of the area used for attempting catches in gameplay. /// Calculates the width of the area used for attempting catches in gameplay.
/// </summary> /// </summary>
@ -471,6 +467,11 @@ namespace osu.Game.Rulesets.Catch.UI
d.Expire(); d.Expire();
} }
/// <summary>
/// Calculates the scale of the catcher based off the provided beatmap difficulty.
/// </summary>
private static Vector2 calculateScale(IBeatmapDifficultyInfo difficulty) => new Vector2(LegacyRulesetExtensions.CalculateScaleFromCircleSize(difficulty.CircleSize) * 2);
private enum DroppedObjectAnimation private enum DroppedObjectAnimation
{ {
Drop, Drop,

View File

@ -17,12 +17,16 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
{ {
private const double offset = 18; private const double offset = 18;
protected override bool AllowFail => true;
protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset();
[Test] [Test]
public void TestHitWindowWithoutDoubleTime() => CreateModTest(new ModTestData public void TestHitWindowWithoutDoubleTime() => CreateModTest(new ModTestData
{ {
PassCondition = () => Player.ScoreProcessor.JudgedHits > 0 && Player.ScoreProcessor.Accuracy.Value != 1, PassCondition = () => Player.ScoreProcessor.JudgedHits > 0
&& Player.ScoreProcessor.Accuracy.Value == 1
&& Player.ScoreProcessor.TotalScore.Value == 1_000_000,
Autoplay = false, Autoplay = false,
Beatmap = new Beatmap Beatmap = new Beatmap
{ {
@ -40,10 +44,16 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
}); });
[Test] [Test]
public void TestHitWindowWithDoubleTime() => CreateModTest(new ModTestData public void TestHitWindowWithDoubleTime()
{ {
Mod = new ManiaModDoubleTime(), var doubleTime = new ManiaModDoubleTime();
PassCondition = () => Player.ScoreProcessor.JudgedHits > 0 && Player.ScoreProcessor.Accuracy.Value == 1,
CreateModTest(new ModTestData
{
Mod = doubleTime,
PassCondition = () => Player.ScoreProcessor.JudgedHits > 0
&& Player.ScoreProcessor.Accuracy.Value == 1
&& Player.ScoreProcessor.TotalScore.Value == (long)(1_000_010 * doubleTime.ScoreMultiplier),
Autoplay = false, Autoplay = false,
Beatmap = new Beatmap Beatmap = new Beatmap
{ {
@ -60,4 +70,5 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
} }
}); });
} }
}
} }

View File

@ -200,10 +200,12 @@ namespace osu.Game.Rulesets.Mania.Tests
}); });
assertHeadJudgement(HitResult.Perfect); assertHeadJudgement(HitResult.Perfect);
assertComboAtJudgement(0, 1); // judgement combo offset by perfect bonus judgement. see logic in DrawableNote.CheckForResult.
assertComboAtJudgement(1, 1);
assertTailJudgement(HitResult.Meh); assertTailJudgement(HitResult.Meh);
assertComboAtJudgement(1, 0); assertComboAtJudgement(2, 0);
assertComboAtJudgement(2, 1); // judgement combo offset by perfect bonus judgement. see logic in DrawableNote.CheckForResult.
assertComboAtJudgement(4, 1);
} }
/// <summary> /// <summary>
@ -380,7 +382,8 @@ namespace osu.Game.Rulesets.Mania.Tests
[Test] [Test]
public void TestPressAndReleaseJustAfterTailWithNearbyNote() public void TestPressAndReleaseJustAfterTailWithNearbyNote()
{ {
Note note; // Next note within tail lenience
Note note = new Note { StartTime = time_tail + 50 };
var beatmap = new Beatmap<ManiaHitObject> var beatmap = new Beatmap<ManiaHitObject>
{ {
@ -392,13 +395,7 @@ namespace osu.Game.Rulesets.Mania.Tests
Duration = time_tail - time_head, Duration = time_tail - time_head,
Column = 0, Column = 0,
}, },
{ note
// Next note within tail lenience
note = new Note
{
StartTime = time_tail + 50
}
}
}, },
BeatmapInfo = BeatmapInfo =
{ {

View File

@ -54,7 +54,7 @@ namespace osu.Game.Rulesets.Mania.Tests
AddAssert("all objects perfectly judged", AddAssert("all objects perfectly judged",
() => judgementResults.Select(result => result.Type), () => judgementResults.Select(result => result.Type),
() => Is.EquivalentTo(judgementResults.Select(result => result.Judgement.MaxResult))); () => Is.EquivalentTo(judgementResults.Select(result => result.Judgement.MaxResult)));
AddAssert("score is 1 million", () => currentPlayer.ScoreProcessor.TotalScore.Value, () => Is.EqualTo(1_000_000)); AddAssert("score is correct", () => currentPlayer.ScoreProcessor.TotalScore.Value, () => Is.EqualTo(1_000_030));
} }
[Test] [Test]
@ -87,7 +87,7 @@ namespace osu.Game.Rulesets.Mania.Tests
AddAssert("all objects perfectly judged", AddAssert("all objects perfectly judged",
() => judgementResults.Select(result => result.Type), () => judgementResults.Select(result => result.Type),
() => Is.EquivalentTo(judgementResults.Select(result => result.Judgement.MaxResult))); () => Is.EquivalentTo(judgementResults.Select(result => result.Judgement.MaxResult)));
AddAssert("score is 1 million", () => currentPlayer.ScoreProcessor.TotalScore.Value, () => Is.EqualTo(1_000_000)); AddAssert("score is correct", () => currentPlayer.ScoreProcessor.TotalScore.Value, () => Is.EqualTo(1_000_040));
} }
private void performTest(List<ManiaHitObject> hitObjects, List<ReplayFrame> frames) private void performTest(List<ManiaHitObject> hitObjects, List<ReplayFrame> frames)

View File

@ -44,8 +44,8 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
if (Column != null) if (Column != null)
{ {
headPiece.Y = Parent.ToLocalSpace(Column.ScreenSpacePositionAtTime(HitObject.StartTime)).Y; headPiece.Y = Parent!.ToLocalSpace(Column.ScreenSpacePositionAtTime(HitObject.StartTime)).Y;
tailPiece.Y = Parent.ToLocalSpace(Column.ScreenSpacePositionAtTime(HitObject.EndTime)).Y; tailPiece.Y = Parent!.ToLocalSpace(Column.ScreenSpacePositionAtTime(HitObject.EndTime)).Y;
switch (scrollingInfo.Direction.Value) switch (scrollingInfo.Direction.Value)
{ {

View File

@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Mania.Edit.Blueprints
foreach (var child in InternalChildren) foreach (var child in InternalChildren)
child.Anchor = child.Origin = anchor; child.Anchor = child.Origin = anchor;
Position = Parent.ToLocalSpace(HitObjectContainer.ScreenSpacePositionAtTime(HitObject.StartTime)) - AnchorPosition; Position = Parent!.ToLocalSpace(HitObjectContainer.ScreenSpacePositionAtTime(HitObject.StartTime)) - AnchorPosition;
Width = HitObjectContainer.DrawWidth; Width = HitObjectContainer.DrawWidth;
} }
} }

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

@ -385,6 +385,9 @@ namespace osu.Game.Rulesets.Mania
HitResult.Good, HitResult.Good,
HitResult.Ok, HitResult.Ok,
HitResult.Meh, HitResult.Meh,
// HitResult.SmallBonus is used for awarding perfect bonus score but is not included here as
// it would be a bit redundant to show this to the user.
}; };
} }

View File

@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Mania.Mods
foreach (Column column in maniaPlayfield.Stages.SelectMany(stage => stage.Columns)) foreach (Column column in maniaPlayfield.Stages.SelectMany(stage => stage.Columns))
{ {
HitObjectContainer hoc = column.HitObjectArea.HitObjectContainer; HitObjectContainer hoc = column.HitObjectArea.HitObjectContainer;
Container hocParent = (Container)hoc.Parent; Container hocParent = (Container)hoc.Parent!;
hocParent.Remove(hoc, false); hocParent.Remove(hoc, false);
hocParent.Add(new PlayfieldCoveringWrapper(hoc).With(c => hocParent.Add(new PlayfieldCoveringWrapper(hoc).With(c =>

View File

@ -108,7 +108,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
RelativeSizeAxes = Axes.X RelativeSizeAxes = Axes.X
}, },
tailContainer = new Container<DrawableHoldNoteTail> { RelativeSizeAxes = Axes.Both }, tailContainer = new Container<DrawableHoldNoteTail> { RelativeSizeAxes = Axes.Both },
slidingSample = new PausableSkinnableSound { Looping = true } slidingSample = new PausableSkinnableSound
{
Looping = true,
MinimumSampleVolume = MINIMUM_SAMPLE_VOLUME,
}
}); });
maskedContents.AddRange(new[] maskedContents.AddRange(new[]

View File

@ -3,7 +3,6 @@
#nullable disable #nullable disable
using System.Diagnostics;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Input.Events; using osu.Framework.Input.Events;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
@ -33,35 +32,19 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
public void UpdateResult() => base.UpdateResult(true); public void UpdateResult() => base.UpdateResult(true);
protected override void CheckForResult(bool userTriggered, double timeOffset) protected override void CheckForResult(bool userTriggered, double timeOffset) =>
{
Debug.Assert(HitObject.HitWindows != null);
// Factor in the release lenience // Factor in the release lenience
timeOffset /= TailNote.RELEASE_WINDOW_LENIENCE; base.CheckForResult(userTriggered, timeOffset / TailNote.RELEASE_WINDOW_LENIENCE);
if (!userTriggered) protected override HitResult GetCappedResult(HitResult result)
{
if (!HitObject.HitWindows.CanBeHit(timeOffset))
ApplyResult(r => r.Type = r.Judgement.MinResult);
return;
}
var result = HitObject.HitWindows.ResultFor(timeOffset);
if (result == HitResult.None)
return;
ApplyResult(r =>
{ {
// If the head wasn't hit or the hold note was broken, cap the max score to Meh. // If the head wasn't hit or the hold note was broken, cap the max score to Meh.
bool hasComboBreak = !HoldNote.Head.IsHit || HoldNote.Body.HasHoldBreak; bool hasComboBreak = !HoldNote.Head.IsHit || HoldNote.Body.HasHoldBreak;
if (result > HitResult.Meh && hasComboBreak) if (result > HitResult.Meh && hasComboBreak)
result = HitResult.Meh; return HitResult.Meh;
r.Type = result; return result;
});
} }
public override bool OnPressed(KeyBindingPressEvent<ManiaAction> e) => false; // Handled by the hold note public override bool OnPressed(KeyBindingPressEvent<ManiaAction> e) => false; // Handled by the hold note

View File

@ -13,6 +13,8 @@ using osu.Game.Beatmaps;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Rulesets.Mania.Configuration; using osu.Game.Rulesets.Mania.Configuration;
using osu.Game.Rulesets.Mania.Skinning.Default; using osu.Game.Rulesets.Mania.Skinning.Default;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Screens.Edit; using osu.Game.Screens.Edit;
@ -38,6 +40,8 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
private Drawable headPiece; private Drawable headPiece;
private DrawableNotePerfectBonus perfectBonus;
public DrawableNote() public DrawableNote()
: this(null) : this(null)
{ {
@ -89,7 +93,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
if (!userTriggered) if (!userTriggered)
{ {
if (!HitObject.HitWindows.CanBeHit(timeOffset)) if (!HitObject.HitWindows.CanBeHit(timeOffset))
{
perfectBonus.TriggerResult(false);
ApplyResult(r => r.Type = r.Judgement.MinResult); ApplyResult(r => r.Type = r.Judgement.MinResult);
}
return; return;
} }
@ -97,9 +105,23 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
if (result == HitResult.None) if (result == HitResult.None)
return; return;
result = GetCappedResult(result);
perfectBonus.TriggerResult(result == HitResult.Perfect);
ApplyResult(r => r.Type = result); ApplyResult(r => r.Type = result);
} }
public override void MissForcefully()
{
perfectBonus.TriggerResult(false);
base.MissForcefully();
}
/// <summary>
/// Some objects in mania may want to limit the max result.
/// </summary>
protected virtual HitResult GetCappedResult(HitResult result) => result;
public virtual bool OnPressed(KeyBindingPressEvent<ManiaAction> e) public virtual bool OnPressed(KeyBindingPressEvent<ManiaAction> e)
{ {
if (e.Action != Action.Value) if (e.Action != Action.Value)
@ -115,6 +137,32 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
{ {
} }
protected override void AddNestedHitObject(DrawableHitObject hitObject)
{
switch (hitObject)
{
case DrawableNotePerfectBonus bonus:
AddInternal(perfectBonus = bonus);
break;
}
}
protected override void ClearNestedHitObjects()
{
RemoveInternal(perfectBonus, false);
}
protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject)
{
switch (hitObject)
{
case NotePerfectBonus bonus:
return new DrawableNotePerfectBonus(bonus);
}
return base.CreateNestedHitObject(hitObject);
}
private void updateSnapColour() private void updateSnapColour()
{ {
if (beatmap == null || HitObject == null) return; if (beatmap == null || HitObject == null) return;

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.
namespace osu.Game.Rulesets.Mania.Objects.Drawables
{
public partial class DrawableNotePerfectBonus : DrawableManiaHitObject<NotePerfectBonus>
{
public override bool DisplayResult => false;
public DrawableNotePerfectBonus()
: this(null!)
{
}
public DrawableNotePerfectBonus(NotePerfectBonus hitObject)
: base(hitObject)
{
}
/// <summary>
/// Apply a judgement result.
/// </summary>
/// <param name="hit">Whether this tick was reached.</param>
internal void TriggerResult(bool hit) => ApplyResult(r => r.Type = hit ? r.Judgement.MaxResult : r.Judgement.MinResult);
}
}

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.Threading;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Judgements; using osu.Game.Rulesets.Mania.Judgements;
@ -12,5 +13,12 @@ namespace osu.Game.Rulesets.Mania.Objects
public class Note : ManiaHitObject public class Note : ManiaHitObject
{ {
public override Judgement CreateJudgement() => new ManiaJudgement(); public override Judgement CreateJudgement() => new ManiaJudgement();
protected override void CreateNestedHitObjects(CancellationToken cancellationToken)
{
base.CreateNestedHitObjects(cancellationToken);
AddNested(new NotePerfectBonus { StartTime = StartTime });
}
} }
} }

View File

@ -0,0 +1,20 @@
// 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.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Judgements;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Mania.Objects
{
public class NotePerfectBonus : ManiaHitObject
{
public override Judgement CreateJudgement() => new NotePerfectBonusJudgement();
protected override HitWindows CreateHitWindows() => HitWindows.Empty;
public class NotePerfectBonusJudgement : ManiaJudgement
{
public override HitResult MaxResult => HitResult.SmallBonus;
}
}
}

View File

@ -123,9 +123,18 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
private void applyCustomUpdateState(DrawableHitObject hitObject, ArmedState state) private void applyCustomUpdateState(DrawableHitObject hitObject, ArmedState state)
{ {
// ensure that the hold note is also faded out when the head/tail/any tick is missed. switch (hitObject)
{
// Ensure that the hold note is also faded out when the head/tail/body is missed.
// Importantly, we filter out unrelated objects like DrawableNotePerfectBonus.
case DrawableHoldNoteTail:
case DrawableHoldNoteHead:
case DrawableHoldNoteBody:
if (state == ArmedState.Miss) if (state == ArmedState.Miss)
missFadeTime.Value ??= hitObject.HitStateUpdateTime; missFadeTime.Value ??= hitObject.HitStateUpdateTime;
break;
}
} }
private void onIsHittingChanged(ValueChangedEvent<bool> isHitting) private void onIsHittingChanged(ValueChangedEvent<bool> isHitting)

View File

@ -109,6 +109,7 @@ namespace osu.Game.Rulesets.Mania.UI
TopLevelContainer.Add(HitObjectArea.Explosions.CreateProxy()); TopLevelContainer.Add(HitObjectArea.Explosions.CreateProxy());
RegisterPool<Note, DrawableNote>(10, 50); RegisterPool<Note, DrawableNote>(10, 50);
RegisterPool<NotePerfectBonus, DrawableNotePerfectBonus>(10, 50);
RegisterPool<HoldNote, DrawableHoldNote>(10, 50); RegisterPool<HoldNote, DrawableHoldNote>(10, 50);
RegisterPool<HeadNote, DrawableHoldNoteHead>(10, 50); RegisterPool<HeadNote, DrawableHoldNoteHead>(10, 50);
RegisterPool<TailNote, DrawableHoldNoteTail>(10, 50); RegisterPool<TailNote, DrawableHoldNoteTail>(10, 50);

View File

@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
}); });
moveMouseToHitObject(1); moveMouseToHitObject(1);
AddAssert("merge option available", () => selectionHandler.ContextMenuItems.Any(o => o.Text.Value == "Merge selection")); AddAssert("merge option available", () => selectionHandler.ContextMenuItems?.Any(o => o.Text.Value == "Merge selection") == true);
mergeSelection(); mergeSelection();
@ -198,7 +198,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
}); });
moveMouseToHitObject(1); moveMouseToHitObject(1);
AddAssert("merge option not available", () => selectionHandler.ContextMenuItems.Length > 0 && selectionHandler.ContextMenuItems.All(o => o.Text.Value != "Merge selection")); AddAssert("merge option not available", () => selectionHandler.ContextMenuItems?.Length > 0 && selectionHandler.ContextMenuItems.All(o => o.Text.Value != "Merge selection"));
mergeSelection(); mergeSelection();
AddAssert("circles not merged", () => circle1 is not null && circle2 is not null AddAssert("circles not merged", () => circle1 is not null && circle2 is not null
&& EditorBeatmap.HitObjects.Contains(circle1) && EditorBeatmap.HitObjects.Contains(circle2)); && EditorBeatmap.HitObjects.Contains(circle1) && EditorBeatmap.HitObjects.Contains(circle2));
@ -222,7 +222,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
}); });
moveMouseToHitObject(1); moveMouseToHitObject(1);
AddAssert("merge option available", () => selectionHandler.ContextMenuItems.Any(o => o.Text.Value == "Merge selection")); AddAssert("merge option available", () => selectionHandler.ContextMenuItems?.Any(o => o.Text.Value == "Merge selection") == true);
mergeSelection(); mergeSelection();

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

@ -169,7 +169,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
AddStep($"move mouse to control point {index}", () => AddStep($"move mouse to control point {index}", () =>
{ {
Vector2 position = slider.Path.ControlPoints[index].Position; Vector2 position = slider.Path.ControlPoints[index].Position;
InputManager.MoveMouseTo(visualiser.Pieces[0].Parent.ToScreenSpace(position)); InputManager.MoveMouseTo(visualiser.Pieces[0].Parent!.ToScreenSpace(position));
}); });
} }

View File

@ -169,7 +169,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
AddStep($"move mouse to {relativePosition}", () => AddStep($"move mouse to {relativePosition}", () =>
{ {
Vector2 position = slider.Position + relativePosition; Vector2 position = slider.Position + relativePosition;
InputManager.MoveMouseTo(drawableObject.Parent.ToScreenSpace(position)); InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position));
}); });
[Test] [Test]
@ -331,7 +331,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
AddStep($"move mouse to {relativePosition}", () => AddStep($"move mouse to {relativePosition}", () =>
{ {
Vector2 position = slider.Position + relativePosition; Vector2 position = slider.Position + relativePosition;
InputManager.MoveMouseTo(drawableObject.Parent.ToScreenSpace(position)); InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position));
}); });
} }
@ -340,7 +340,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
AddStep($"move mouse to control point {index}", () => AddStep($"move mouse to control point {index}", () =>
{ {
Vector2 position = slider.Position + slider.Path.ControlPoints[index].Position; Vector2 position = slider.Position + slider.Path.ControlPoints[index].Position;
InputManager.MoveMouseTo(drawableObject.Parent.ToScreenSpace(position)); InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position));
}); });
} }

View File

@ -187,7 +187,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
AddStep($"move mouse to control point {index}", () => AddStep($"move mouse to control point {index}", () =>
{ {
Vector2 position = slider.Position + slider.Path.ControlPoints[index].Position; Vector2 position = slider.Position + slider.Path.ControlPoints[index].Position;
InputManager.MoveMouseTo(drawableObject.Parent.ToScreenSpace(position)); InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position));
}); });
} }

View File

@ -101,7 +101,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
{ {
var firstPiece = this.ChildrenOfType<PathControlPointPiece<Slider>>().Single(piece => piece.ControlPoint == slider.Path.ControlPoints[0]); var firstPiece = this.ChildrenOfType<PathControlPointPiece<Slider>>().Single(piece => piece.ControlPoint == slider.Path.ControlPoints[0]);
var pos = slider.Path.PositionAt(0.25d) + slider.Position; var pos = slider.Path.PositionAt(0.25d) + slider.Position;
InputManager.MoveMouseTo(firstPiece.Parent.ToScreenSpace(pos)); InputManager.MoveMouseTo(firstPiece.Parent!.ToScreenSpace(pos));
}); });
AddStep("move slider end", () => AddStep("move slider end", () =>
{ {

View File

@ -231,7 +231,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
if (slider is null || visualiser is null) return; if (slider is null || visualiser is null) return;
Vector2 position = slider.Path.ControlPoints[index].Position + slider.Position; Vector2 position = slider.Path.ControlPoints[index].Position + slider.Position;
InputManager.MoveMouseTo(visualiser.Pieces[0].Parent.ToScreenSpace(position)); InputManager.MoveMouseTo(visualiser.Pieces[0].Parent!.ToScreenSpace(position));
}); });
} }
@ -241,7 +241,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
{ {
if (visualiser is null) return; if (visualiser is null) return;
MenuItem? item = visualiser.ContextMenuItems.FirstOrDefault(menuItem => menuItem.Text.Value == contextMenuText); MenuItem? item = visualiser.ContextMenuItems?.FirstOrDefault(menuItem => menuItem.Text.Value == contextMenuText);
item?.Action.Value?.Invoke(); item?.Action.Value?.Invoke();
}); });

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,14 +58,30 @@ 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();
// 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, X = obj.StackedPosition.X,
Y = obj.StackedPosition.Y Y = obj.StackedPosition.Y
}; };
} }
}
protected override Ruleset CreateRuleset() => new OsuRuleset(); protected override Ruleset CreateRuleset() => new OsuRuleset();
} }

View File

@ -15,19 +15,22 @@ namespace osu.Game.Rulesets.Osu.Tests
{ {
protected override string ResourceAssembly => "osu.Game.Rulesets.Osu"; protected override string ResourceAssembly => "osu.Game.Rulesets.Osu";
[TestCase(6.7115569159190587d, 206, "diffcalc-test")] [TestCase(6.710442985146793d, 206, "diffcalc-test")]
[TestCase(1.4391311903612753d, 45, "zero-length-sliders")] [TestCase(1.4386882251130073d, 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.9742952703071666d, 206, "diffcalc-test")]
[TestCase(1.7437232654020756d, 45, "zero-length-sliders")] [TestCase(0.55071082800473514d, 2, "very-fast-slider")]
[TestCase(1.743180218215227d, 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.710442985146793d, 239, "diffcalc-test")]
[TestCase(1.4391311903612753d, 54, "zero-length-sliders")] [TestCase(0.42506480230838789d, 4, "very-fast-slider")]
[TestCase(1.4386882251130073d, 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

@ -0,0 +1,111 @@
// 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 osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.Replays;
using osuTK;
namespace osu.Game.Rulesets.Osu.Tests
{
public class SpinFramesGenerator
{
/// <summary>
/// A small amount to spin beyond a given angle to mitigate floating-point precision errors.
/// </summary>
public const float SPIN_ERROR = MathF.PI / 8;
/// <summary>
/// The offset from the centre of the spinner at which to spin.
/// </summary>
private const float centre_spin_offset = 50;
private readonly double startTime;
private readonly float startAngle;
private readonly List<(float deltaAngle, double duration)> sequences = new List<(float deltaAngle, double duration)>();
/// <summary>
/// Creates a new <see cref="SpinFramesGenerator"/> that can be used to generate spinner spin frames.
/// </summary>
/// <param name="startTime">The time at which to start spinning.</param>
/// <param name="startAngle">The angle, in radians, at which to start spinning from. Defaults to the positive-y-axis.</param>
public SpinFramesGenerator(double startTime, float startAngle = -MathF.PI / 2f)
{
this.startTime = startTime;
this.startAngle = startAngle;
}
/// <summary>
/// Performs a single spin.
/// </summary>
/// <param name="delta">The amount of degrees to spin.</param>
/// <param name="duration">The time to spend to perform the spin.</param>
/// <returns>This <see cref="SpinFramesGenerator"/>.</returns>
public SpinFramesGenerator Spin(float delta, double duration)
{
sequences.Add((delta / 360 * 2 * MathF.PI, duration));
return this;
}
/// <summary>
/// Constructs the replay frames.
/// </summary>
/// <returns>The replay frames.</returns>
public List<ReplayFrame> Build()
{
List<ReplayFrame> frames = new List<ReplayFrame>();
double lastTime = startTime;
float lastAngle = startAngle;
int lastDirection = 0;
for (int i = 0; i < sequences.Count; i++)
{
var seq = sequences[i];
int seqDirection = Math.Sign(seq.deltaAngle);
float seqError = SPIN_ERROR * seqDirection;
if (seqDirection == lastDirection)
{
// Spinning in the same direction, but the error was already added in the last rotation.
seqError = 0;
}
else if (lastDirection != 0)
{
// Spinning in a different direction, we need to account for the error of the start angle, so double it.
seqError *= 2;
}
double seqStartTime = lastTime;
double seqEndTime = lastTime + seq.duration;
float seqStartAngle = lastAngle;
float seqEndAngle = seqStartAngle + seq.deltaAngle + seqError;
// Intermediate spin frames.
for (; lastTime < seqEndTime; lastTime += 10)
frames.Add(new OsuReplayFrame(lastTime, calcOffsetAt((lastTime - seqStartTime) / (seqEndTime - seqStartTime), seqStartAngle, seqEndAngle), OsuAction.LeftButton));
// Final frame at the end of the current spin.
frames.Add(new OsuReplayFrame(seqEndTime, calcOffsetAt(1, seqStartAngle, seqEndAngle), OsuAction.LeftButton));
lastTime = seqEndTime;
lastAngle = seqEndAngle;
lastDirection = seqDirection;
}
// Key release frame.
if (frames.Count > 0)
frames.Add(new OsuReplayFrame(frames[^1].Time, ((OsuReplayFrame)frames[^1]).Position));
return frames;
}
private static Vector2 calcOffsetAt(double p, float startAngle, float endAngle)
{
float angle = startAngle + (endAngle - startAngle) * (float)p;
return new Vector2(256, 192) + centre_spin_offset * new Vector2(MathF.Cos(angle), MathF.Sin(angle));
}
}
}

View File

@ -0,0 +1,135 @@
// 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.Osu.Objects.Drawables;
namespace osu.Game.Rulesets.Osu.Tests
{
[TestFixture]
public class SpinnerSpinHistoryTest
{
private SpinnerSpinHistory history = null!;
[SetUp]
public void Setup()
{
history = new SpinnerSpinHistory();
}
[TestCase(0, 0)]
[TestCase(10, 10)]
[TestCase(180, 180)]
[TestCase(350, 350)]
[TestCase(360, 360)]
[TestCase(370, 370)]
[TestCase(540, 540)]
[TestCase(720, 720)]
// ---
[TestCase(-0, 0)]
[TestCase(-10, 10)]
[TestCase(-180, 180)]
[TestCase(-350, 350)]
[TestCase(-360, 360)]
[TestCase(-370, 370)]
[TestCase(-540, 540)]
[TestCase(-720, 720)]
public void TestSpinOneDirection(float spin, float expectedRotation)
{
history.ReportDelta(500, spin);
Assert.That(history.TotalRotation, Is.EqualTo(expectedRotation));
}
[TestCase(0, 0, 0, 0)]
// ---
[TestCase(10, -10, 0, 10)]
[TestCase(-10, 10, 0, 10)]
// ---
[TestCase(10, -20, 0, 10)]
[TestCase(-10, 20, 0, 10)]
// ---
[TestCase(20, -10, 0, 20)]
[TestCase(-20, 10, 0, 20)]
// ---
[TestCase(10, -360, 0, 350)]
[TestCase(-10, 360, 0, 350)]
// ---
[TestCase(360, -10, 0, 370)]
[TestCase(360, 10, 0, 370)]
[TestCase(-360, 10, 0, 370)]
[TestCase(-360, -10, 0, 370)]
// ---
[TestCase(10, 10, 10, 30)]
[TestCase(10, 10, -10, 20)]
[TestCase(10, -10, 10, 10)]
[TestCase(-10, -10, -10, 30)]
[TestCase(-10, -10, 10, 20)]
[TestCase(-10, 10, 10, 10)]
// ---
[TestCase(10, -20, -350, 360)]
[TestCase(10, -20, 350, 340)]
[TestCase(-10, 20, 350, 360)]
[TestCase(-10, 20, -350, 340)]
public void TestSpinMultipleDirections(float spin1, float spin2, float spin3, float expectedRotation)
{
history.ReportDelta(500, spin1);
history.ReportDelta(1000, spin2);
history.ReportDelta(1500, spin3);
Assert.That(history.TotalRotation, Is.EqualTo(expectedRotation));
}
// One spin
[TestCase(370, -50, 320)]
[TestCase(-370, 50, 320)]
// Two spins
[TestCase(740, -420, 320)]
[TestCase(-740, 420, 320)]
public void TestRemoveAndCrossFullSpin(float deltaToAdd, float deltaToRemove, float expectedRotation)
{
history.ReportDelta(1000, deltaToAdd);
history.ReportDelta(500, deltaToRemove);
Assert.That(history.TotalRotation, Is.EqualTo(expectedRotation));
}
// One spin + partial
[TestCase(400, -30, -50, 320)]
[TestCase(-400, 30, 50, 320)]
// Two spins + partial
[TestCase(800, -430, -50, 320)]
[TestCase(-800, 430, 50, 320)]
public void TestRemoveAndCrossFullAndPartialSpins(float deltaToAdd1, float deltaToAdd2, float deltaToRemove, float expectedRotation)
{
history.ReportDelta(1000, deltaToAdd1);
history.ReportDelta(1500, deltaToAdd2);
history.ReportDelta(500, deltaToRemove);
Assert.That(history.TotalRotation, Is.EqualTo(expectedRotation));
}
[Test]
public void TestRewindMultipleFullSpins()
{
history.ReportDelta(500, 360);
history.ReportDelta(1000, 720);
Assert.That(history.TotalRotation, Is.EqualTo(1080));
history.ReportDelta(250, -900);
Assert.That(history.TotalRotation, Is.EqualTo(180));
}
[Test]
public void TestRewindOverDirectionChange()
{
history.ReportDelta(1000, 40); // max is now CW 40 degrees
Assert.That(history.TotalRotation, Is.EqualTo(40));
history.ReportDelta(1100, -90); // max is now CCW 50 degrees
Assert.That(history.TotalRotation, Is.EqualTo(50));
history.ReportDelta(1200, 110); // max is now CW 60 degrees
Assert.That(history.TotalRotation, Is.EqualTo(60));
history.ReportDelta(1000, -20);
Assert.That(history.TotalRotation, Is.EqualTo(40));
}
}
}

View File

@ -78,7 +78,7 @@ namespace osu.Game.Rulesets.Osu.Tests
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Size = new Vector2(0.8f), Size = new Vector2(0.8f),
Child = new MovingCursorInputManager { Child = createContent?.Invoke() } Child = new MovingCursorInputManager { Child = createContent() }
}); });
}); });

View File

@ -75,7 +75,7 @@ namespace osu.Game.Rulesets.Osu.Tests
pool = pools[poolIndex]; pool = pools[poolIndex];
// We need to make sure neither the pool nor the judgement get disposed when new content is set, and they both share the same parent. // We need to make sure neither the pool nor the judgement get disposed when new content is set, and they both share the same parent.
((Container)pool.Parent).Clear(false); ((Container)pool.Parent!).Clear(false);
} }
var container = new Container var container = new Container

View File

@ -356,15 +356,16 @@ namespace osu.Game.Rulesets.Osu.Tests
}, },
}; };
performTest(hitObjects, new List<ReplayFrame> List<ReplayFrame> frames = new List<ReplayFrame>
{ {
new OsuReplayFrame { Time = time_spinner - 90, Position = positionCircle, Actions = { OsuAction.LeftButton } }, new OsuReplayFrame { Time = time_spinner - 90, Position = positionCircle, Actions = { OsuAction.LeftButton } },
new OsuReplayFrame { Time = time_spinner + 10, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } }, };
new OsuReplayFrame { Time = time_spinner + 20, Position = new Vector2(256, 172), Actions = { OsuAction.RightButton } },
new OsuReplayFrame { Time = time_spinner + 30, Position = new Vector2(276, 192), Actions = { OsuAction.RightButton } }, frames.AddRange(new SpinFramesGenerator(time_spinner + 10)
new OsuReplayFrame { Time = time_spinner + 40, Position = new Vector2(256, 212), Actions = { OsuAction.RightButton } }, .Spin(360, 500)
new OsuReplayFrame { Time = time_spinner + 50, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } }, .Build());
});
performTest(hitObjects, frames);
addJudgementAssert(hitObjects[0], HitResult.Great); addJudgementAssert(hitObjects[0], HitResult.Great);
addJudgementAssert(hitObjects[1], HitResult.Meh); addJudgementAssert(hitObjects[1], HitResult.Meh);

View File

@ -27,6 +27,7 @@ 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.Configuration; using osu.Game.Rulesets.Osu.Configuration;
using osu.Game.Rulesets.Osu.Mods;
namespace osu.Game.Rulesets.Osu.Tests namespace osu.Game.Rulesets.Osu.Tests
{ {
@ -50,6 +51,8 @@ namespace osu.Game.Rulesets.Osu.Tests
snakingOut.Value = !v; snakingOut.Value = !v;
}); });
AddToggleStep("toggle hidden", hiddenActive => SelectedMods.Value = hiddenActive ? new[] { new OsuModHidden() } : Array.Empty<Mod>());
AddSliderStep("hit at", 0f, 1f, 0f, v => AddSliderStep("hit at", 0f, 1f, 0f, v =>
{ {
progressToHit = v; progressToHit = v;

View File

@ -10,6 +10,7 @@ using osu.Game.Beatmaps;
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;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.Osu.Replays;
@ -35,7 +36,7 @@ namespace osu.Game.Rulesets.Osu.Tests
{ {
const double time_slider_start = 1000; const double time_slider_start = 1000;
float circleRadius = OsuHitObject.OBJECT_RADIUS * (1.0f - 0.7f * (circleSize - 5) / 5) / 2; float circleRadius = OsuHitObject.OBJECT_RADIUS * LegacyRulesetExtensions.CalculateScaleFromCircleSize(circleSize, true);
float followCircleRadius = circleRadius * 1.2f; float followCircleRadius = circleRadius * 1.2f;
performTest(new Beatmap<OsuHitObject> performTest(new Beatmap<OsuHitObject>

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,19 +431,9 @@ 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)
{ {
AddStep("load player", () => slider ??= new Slider
{
Beatmap.Value = CreateWorkingBeatmap(new Beatmap<OsuHitObject>
{
HitObjects =
{
new Slider
{ {
StartTime = time_slider_start, StartTime = time_slider_start,
Position = new Vector2(0, 0), Position = new Vector2(0, 0),
@ -355,13 +443,24 @@ namespace osu.Game.Rulesets.Osu.Tests
Vector2.Zero, Vector2.Zero,
new Vector2(slider_path_length, 0), new Vector2(slider_path_length, 0),
}, slider_path_length), }, slider_path_length),
} };
},
AddStep("load player", () =>
{
var cpi = new ControlPointInfo();
if (bpm != null)
cpi.Add(0, new TimingControlPoint { BeatLength = 60000 / bpm.Value });
Beatmap.Value = CreateWorkingBeatmap(new Beatmap<OsuHitObject>
{
HitObjects = { slider },
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

@ -6,6 +6,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Audio; using osu.Game.Audio;
@ -26,6 +27,15 @@ namespace osu.Game.Rulesets.Osu.Tests
private TestDrawableSpinner drawableSpinner; private TestDrawableSpinner drawableSpinner;
private readonly BindableDouble spinRate = new BindableDouble();
protected override void LoadComplete()
{
base.LoadComplete();
AddSliderStep("Spin rate", 0.5, 5, 1, val => spinRate.Value = val);
}
[TestCase(true)] [TestCase(true)]
[TestCase(false)] [TestCase(false)]
public void TestVariousSpinners(bool autoplay) public void TestVariousSpinners(bool autoplay)
@ -86,7 +96,7 @@ namespace osu.Game.Rulesets.Osu.Tests
spinner.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { OverallDifficulty = od }); spinner.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { OverallDifficulty = od });
return drawableSpinner = new TestDrawableSpinner(spinner, true) return drawableSpinner = new TestDrawableSpinner(spinner, true, spinRate)
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Depth = depthIndex++, Depth = depthIndex++,
@ -114,7 +124,7 @@ namespace osu.Game.Rulesets.Osu.Tests
spinner.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = circleSize }); spinner.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = circleSize });
drawableSpinner = new TestDrawableSpinner(spinner, auto) drawableSpinner = new TestDrawableSpinner(spinner, auto, spinRate)
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Depth = depthIndex++, Depth = depthIndex++,
@ -130,18 +140,20 @@ namespace osu.Game.Rulesets.Osu.Tests
private partial class TestDrawableSpinner : DrawableSpinner private partial class TestDrawableSpinner : DrawableSpinner
{ {
private readonly bool auto; private readonly bool auto;
private readonly BindableDouble spinRate;
public TestDrawableSpinner(Spinner s, bool auto) public TestDrawableSpinner(Spinner s, bool auto, BindableDouble spinRate)
: base(s) : base(s)
{ {
this.auto = auto; this.auto = auto;
this.spinRate = spinRate;
} }
protected override void Update() protected override void Update()
{ {
base.Update(); base.Update();
if (auto) if (auto)
RotationTracker.AddRotation((float)(Clock.ElapsedFrameTime * 2)); RotationTracker.AddRotation((float)(Clock.ElapsedFrameTime * spinRate.Value));
} }
} }
} }

View File

@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Osu.Tests
}); });
AddStep("rotate some", () => dho.RotationTracker.AddRotation(180)); AddStep("rotate some", () => dho.RotationTracker.AddRotation(180));
AddAssert("rotation is set", () => dho.Result.RateAdjustedRotation == 180); AddAssert("rotation is set", () => dho.Result.TotalRotation == 180);
AddStep("apply new spinner", () => dho.Apply(prepareObject(new Spinner AddStep("apply new spinner", () => dho.Apply(prepareObject(new Spinner
{ {
@ -41,7 +41,7 @@ namespace osu.Game.Rulesets.Osu.Tests
Duration = 1000, Duration = 1000,
}))); })));
AddAssert("rotation is reset", () => dho.Result.RateAdjustedRotation == 0); AddAssert("rotation is reset", () => dho.Result.TotalRotation == 0);
} }
private Spinner prepareObject(Spinner circle) private Spinner prepareObject(Spinner circle)

View File

@ -0,0 +1,319 @@
// 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 System.Linq;
using NUnit.Framework;
using osu.Framework.Screens;
using osu.Framework.Testing;
using osu.Framework.Timing;
using osu.Game.Beatmaps;
using osu.Game.Replays;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
using osu.Game.Storyboards;
using osu.Game.Tests.Visual;
using osuTK;
namespace osu.Game.Rulesets.Osu.Tests
{
public partial class TestSceneSpinnerInput : RateAdjustedBeatmapTestScene
{
private const int centre_x = 256;
private const int centre_y = 192;
private const double time_spinner_start = 1500;
private const double time_spinner_end = 8000;
private readonly List<JudgementResult> judgementResults = new List<JudgementResult>();
private ScoreAccessibleReplayPlayer currentPlayer = null!;
private ManualClock? manualClock;
protected override WorkingBeatmap CreateWorkingBeatmap(IBeatmap beatmap, Storyboard? storyboard = null)
{
return manualClock == null
? base.CreateWorkingBeatmap(beatmap, storyboard)
: new ClockBackedTestWorkingBeatmap(beatmap, storyboard, new FramedClock(manualClock), Audio);
}
[SetUp]
public void Setup() => Schedule(() =>
{
manualClock = null;
});
/// <summary>
/// While off-centre, vibrates backwards and forwards on the x-axis, from centre-50 to centre+50, every 50ms.
/// </summary>
[Test]
public void TestVibrateWithoutSpinningOffCentre()
{
List<ReplayFrame> frames = new List<ReplayFrame>();
const int vibrate_time = 50;
const float y_pos = centre_y - 50;
int direction = -1;
for (double i = time_spinner_start; i <= time_spinner_end; i += vibrate_time)
{
frames.Add(new OsuReplayFrame(i, new Vector2(centre_x + direction * 50, y_pos), OsuAction.LeftButton));
frames.Add(new OsuReplayFrame(i + vibrate_time, new Vector2(centre_x - direction * 50, y_pos), OsuAction.LeftButton));
direction *= -1;
}
performTest(frames);
assertTicksHit(0);
assertSpinnerHit(false);
}
/// <summary>
/// While centred on the slider, vibrates backwards and forwards on the x-axis, from centre-50 to centre+50, every 50ms.
/// </summary>
[Test]
public void TestVibrateWithoutSpinningOnCentre()
{
List<ReplayFrame> frames = new List<ReplayFrame>();
const int vibrate_time = 50;
int direction = -1;
for (double i = time_spinner_start; i <= time_spinner_end; i += vibrate_time)
{
frames.Add(new OsuReplayFrame(i, new Vector2(centre_x + direction * 50, centre_y), OsuAction.LeftButton));
frames.Add(new OsuReplayFrame(i + vibrate_time, new Vector2(centre_x - direction * 50, centre_y), OsuAction.LeftButton));
direction *= -1;
}
performTest(frames);
assertTicksHit(0);
assertSpinnerHit(false);
}
/// <summary>
/// Spins in a single direction.
/// </summary>
[TestCase(180, 0)]
[TestCase(-180, 0)]
[TestCase(360, 1)]
[TestCase(-360, 1)]
[TestCase(540, 1)]
[TestCase(-540, 1)]
[TestCase(720, 2)]
[TestCase(-720, 2)]
public void TestSpinSingleDirection(float amount, int expectedTicks)
{
performTest(new SpinFramesGenerator(time_spinner_start)
.Spin(amount, 500)
.Build());
assertTicksHit(expectedTicks);
assertSpinnerHit(false);
}
/// <summary>
/// Spin half-way clockwise then perform one full spin counter-clockwise.
/// No ticks should be hit since the total rotation is -0.5 (0.5 CW + 1 CCW = 0.5 CCW).
/// </summary>
[Test]
public void TestSpinHalfBothDirections()
{
performTest(new SpinFramesGenerator(time_spinner_start)
.Spin(180, 500) // Rotate to +0.5.
.Spin(-360, 500) // Rotate to -0.5
.Build());
assertTicksHit(0);
assertSpinnerHit(false);
}
/// <summary>
/// Spin in one direction then spin in the other.
/// </summary>
[TestCase(180, -540, 1)]
[TestCase(-180, 540, 1)]
[TestCase(180, -900, 2)]
[TestCase(-180, 900, 2)]
public void TestSpinOneDirectionThenChangeDirection(float direction1, float direction2, int expectedTicks)
{
performTest(new SpinFramesGenerator(time_spinner_start)
.Spin(direction1, 500)
.Spin(direction2, 500)
.Build());
assertTicksHit(expectedTicks);
assertSpinnerHit(false);
}
[Test]
public void TestRewind()
{
AddStep("set manual clock", () => manualClock = new ManualClock
{
// Avoids interpolation trying to run ahead during testing.
Rate = 0
});
List<ReplayFrame> frames =
new SpinFramesGenerator(time_spinner_start)
// 1500ms start
.Spin(360, 500)
// 2000ms -> 1 full CW spin
.Spin(-180, 500)
// 2500ms -> 1 full CW spin + 0.5 CCW spins
.Spin(90, 500)
// 3000ms -> 1 full CW spin + 0.25 CCW spins
.Spin(450, 500)
// 3500ms -> 2 full CW spins
.Spin(180, 500)
// 4000ms -> 2 full CW spins + 0.5 CW spins
.Build();
loadPlayer(frames);
GameplayClockContainer clock = null!;
DrawableRuleset drawableRuleset = null!;
AddStep("get gameplay objects", () =>
{
clock = currentPlayer.ChildrenOfType<GameplayClockContainer>().Single();
drawableRuleset = currentPlayer.ChildrenOfType<DrawableRuleset>().Single();
});
addSeekStep(frames.Last().Time);
DrawableSpinner drawableSpinner = null!;
AddUntilStep("get spinner", () => (drawableSpinner = currentPlayer.ChildrenOfType<DrawableSpinner>().Single()) != null);
assertFinalRotationCorrect();
assertTotalRotation(3750, 810);
assertTotalRotation(3500, 720);
assertTotalRotation(3250, 530);
assertTotalRotation(3000, 450);
assertTotalRotation(2750, 540);
assertTotalRotation(2500, 540);
assertTotalRotation(2250, 450);
assertTotalRotation(2000, 360);
assertTotalRotation(1500, 0);
// same thing but always returning to final time to check.
assertFinalRotationCorrect();
assertTotalRotation(3750, 810);
assertFinalRotationCorrect();
assertTotalRotation(3500, 720);
assertFinalRotationCorrect();
assertTotalRotation(3250, 530);
assertFinalRotationCorrect();
assertTotalRotation(3000, 450);
assertFinalRotationCorrect();
assertTotalRotation(2750, 540);
assertFinalRotationCorrect();
assertTotalRotation(2500, 540);
assertFinalRotationCorrect();
assertTotalRotation(2250, 450);
assertFinalRotationCorrect();
assertTotalRotation(2000, 360);
assertFinalRotationCorrect();
assertTotalRotation(1500, 0);
void assertTotalRotation(double time, float expected)
{
addSeekStep(time);
AddAssert($"total rotation @ {time} is {expected}", () => drawableSpinner.Result.TotalRotation,
() => Is.EqualTo(expected).Within(MathHelper.RadiansToDegrees(SpinFramesGenerator.SPIN_ERROR * 2)));
}
void addSeekStep(double time)
{
AddStep($"seek to {time}", () => clock.Seek(time));
// Lenience is required due to interpolation running slightly ahead on a stalled clock.
AddUntilStep("wait for seek to finish", () => drawableRuleset.FrameStableClock.CurrentTime, () => Is.EqualTo(time));
}
void assertFinalRotationCorrect() => assertTotalRotation(4000, 900);
}
private void assertTicksHit(int count)
{
AddAssert($"{count} ticks hit", () => judgementResults.Where(r => r.HitObject is SpinnerTick).Count(r => r.IsHit), () => Is.EqualTo(count));
}
private void assertSpinnerHit(bool shouldBeHit)
{
AddAssert($"spinner is {(shouldBeHit ? "hit" : "missed")}", () => judgementResults.Single(r => r.HitObject is Spinner).IsHit, () => Is.EqualTo(shouldBeHit));
}
private void loadPlayer(List<ReplayFrame> frames)
{
AddStep("load player", () =>
{
Beatmap.Value = CreateWorkingBeatmap(new Beatmap<OsuHitObject>
{
HitObjects =
{
new Spinner
{
StartTime = time_spinner_start,
EndTime = time_spinner_end,
Position = new Vector2(centre_x, centre_y)
}
},
BeatmapInfo =
{
Difficulty = new BeatmapDifficulty(),
Ruleset = new OsuRuleset().RulesetInfo
},
});
var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } });
p.OnLoadComplete += _ =>
{
p.ScoreProcessor.NewJudgement += result =>
{
if (currentPlayer == p) judgementResults.Add(result);
};
};
LoadScreen(currentPlayer = p);
judgementResults.Clear();
});
AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0);
AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen());
}
private void performTest(List<ReplayFrame> frames)
{
loadPlayer(frames);
AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor.HasCompleted.Value);
}
private partial class ScoreAccessibleReplayPlayer : ReplayPlayer
{
public new ScoreProcessor ScoreProcessor => base.ScoreProcessor;
protected override bool PauseOnFocusLost => false;
public ScoreAccessibleReplayPlayer(Score score)
: base(score, new PlayerConfiguration
{
AllowPause = false,
ShowResults = false,
})
{
}
}
}
}

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 System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using NUnit.Framework; using NUnit.Framework;
@ -11,14 +10,12 @@ using osu.Game.Beatmaps;
using osu.Game.Replays; using osu.Game.Replays;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Screens.Play; using osu.Game.Screens.Play;
using osu.Game.Tests.Visual; using osu.Game.Tests.Visual;
using osuTK;
namespace osu.Game.Rulesets.Osu.Tests namespace osu.Game.Rulesets.Osu.Tests
{ {
@ -59,26 +56,9 @@ namespace osu.Game.Rulesets.Osu.Tests
AddAssert("all max judgements", () => judgementResults.All(result => result.Type == result.Judgement.MaxResult)); AddAssert("all max judgements", () => judgementResults.All(result => result.Type == result.Judgement.MaxResult));
} }
private static List<ReplayFrame> generateReplay(int spins) private static List<ReplayFrame> generateReplay(int spins) => new SpinFramesGenerator(time_spinner_start)
{ .Spin(spins * 360, time_spinner_end - time_spinner_start)
var replayFrames = new List<ReplayFrame>(); .Build();
const int frames_per_spin = 30;
for (int i = 0; i < spins * frames_per_spin; ++i)
{
float totalProgress = i / (float)(spins * frames_per_spin);
float spinProgress = (i % frames_per_spin) / (float)frames_per_spin;
double time = time_spinner_start + (time_spinner_end - time_spinner_start) * totalProgress;
float posX = MathF.Cos(2 * MathF.PI * spinProgress);
float posY = MathF.Sin(2 * MathF.PI * spinProgress);
Vector2 finalPos = OsuPlayfield.BASE_SIZE / 2 + new Vector2(posX, posY) * 50;
replayFrames.Add(new OsuReplayFrame(time, finalPos, OsuAction.LeftButton));
}
return replayFrames;
}
private void performTest(List<ReplayFrame> frames) private void performTest(List<ReplayFrame> frames)
{ {

View File

@ -63,11 +63,11 @@ namespace osu.Game.Rulesets.Osu.Tests
trackerRotationTolerance = Math.Abs(drawableSpinner.RotationTracker.Rotation * 0.1f); trackerRotationTolerance = Math.Abs(drawableSpinner.RotationTracker.Rotation * 0.1f);
}); });
AddAssert("is disc rotation not almost 0", () => drawableSpinner.RotationTracker.Rotation, () => Is.Not.EqualTo(0).Within(100)); AddAssert("is disc rotation not almost 0", () => drawableSpinner.RotationTracker.Rotation, () => Is.Not.EqualTo(0).Within(100));
AddAssert("is disc rotation absolute not almost 0", () => drawableSpinner.Result.RateAdjustedRotation, () => Is.Not.EqualTo(0).Within(100)); AddAssert("is disc rotation absolute not almost 0", () => drawableSpinner.Result.TotalRotation, () => Is.Not.EqualTo(0).Within(100));
addSeekStep(0); addSeekStep(0);
AddAssert("is disc rotation almost 0", () => drawableSpinner.RotationTracker.Rotation, () => Is.EqualTo(0).Within(trackerRotationTolerance)); AddAssert("is disc rotation almost 0", () => drawableSpinner.RotationTracker.Rotation, () => Is.EqualTo(0).Within(trackerRotationTolerance));
AddAssert("is disc rotation absolute almost 0", () => drawableSpinner.Result.RateAdjustedRotation, () => Is.EqualTo(0).Within(100)); AddAssert("is disc rotation absolute almost 0", () => drawableSpinner.Result.TotalRotation, () => Is.EqualTo(0).Within(100));
} }
[Test] [Test]
@ -82,7 +82,7 @@ namespace osu.Game.Rulesets.Osu.Tests
finalTrackerRotation = drawableSpinner.RotationTracker.Rotation; finalTrackerRotation = drawableSpinner.RotationTracker.Rotation;
trackerRotationTolerance = Math.Abs(finalTrackerRotation * 0.05f); trackerRotationTolerance = Math.Abs(finalTrackerRotation * 0.05f);
}); });
AddStep("retrieve cumulative disc rotation", () => finalCumulativeTrackerRotation = drawableSpinner.Result.RateAdjustedRotation); AddStep("retrieve cumulative disc rotation", () => finalCumulativeTrackerRotation = drawableSpinner.Result.TotalRotation);
addSeekStep(spinner_start_time + 2500); addSeekStep(spinner_start_time + 2500);
AddAssert("disc rotation rewound", AddAssert("disc rotation rewound",
@ -92,13 +92,13 @@ namespace osu.Game.Rulesets.Osu.Tests
() => drawableSpinner.RotationTracker.Rotation, () => Is.EqualTo(finalTrackerRotation / 2).Within(trackerRotationTolerance)); () => drawableSpinner.RotationTracker.Rotation, () => Is.EqualTo(finalTrackerRotation / 2).Within(trackerRotationTolerance));
AddAssert("is cumulative rotation rewound", AddAssert("is cumulative rotation rewound",
// cumulative rotation is not damped, so we're treating it as the "ground truth" and allowing a comparatively smaller margin of error. // cumulative rotation is not damped, so we're treating it as the "ground truth" and allowing a comparatively smaller margin of error.
() => drawableSpinner.Result.RateAdjustedRotation, () => Is.EqualTo(finalCumulativeTrackerRotation / 2).Within(100)); () => drawableSpinner.Result.TotalRotation, () => Is.EqualTo(finalCumulativeTrackerRotation / 2).Within(100));
addSeekStep(spinner_start_time + 5000); addSeekStep(spinner_start_time + 5000);
AddAssert("is disc rotation almost same", AddAssert("is disc rotation almost same",
() => drawableSpinner.RotationTracker.Rotation, () => Is.EqualTo(finalTrackerRotation).Within(trackerRotationTolerance)); () => drawableSpinner.RotationTracker.Rotation, () => Is.EqualTo(finalTrackerRotation).Within(trackerRotationTolerance));
AddAssert("is cumulative rotation almost same", AddAssert("is cumulative rotation almost same",
() => drawableSpinner.Result.RateAdjustedRotation, () => Is.EqualTo(finalCumulativeTrackerRotation).Within(100)); () => drawableSpinner.Result.TotalRotation, () => Is.EqualTo(finalCumulativeTrackerRotation).Within(100));
} }
[Test] [Test]
@ -135,7 +135,7 @@ namespace osu.Game.Rulesets.Osu.Tests
{ {
// multipled by 2 to nullify the score multiplier. (autoplay mod selected) // multipled by 2 to nullify the score multiplier. (autoplay mod selected)
long totalScore = ((ScoreExposedPlayer)Player).ScoreProcessor.TotalScore.Value * 2; long totalScore = ((ScoreExposedPlayer)Player).ScoreProcessor.TotalScore.Value * 2;
return totalScore == (int)(drawableSpinner.Result.RateAdjustedRotation / 360) * new SpinnerTick().CreateJudgement().MaxNumericResult; return totalScore == (int)(drawableSpinner.Result.TotalRotation / 360) * new SpinnerTick().CreateJudgement().MaxNumericResult;
}); });
addSeekStep(0); addSeekStep(0);

View File

@ -284,15 +284,16 @@ namespace osu.Game.Rulesets.Osu.Tests
}, },
}; };
performTest(hitObjects, new List<ReplayFrame> List<ReplayFrame> frames = new List<ReplayFrame>
{ {
new OsuReplayFrame { Time = time_spinner - 100, Position = positionCircle, Actions = { OsuAction.LeftButton } }, new OsuReplayFrame { Time = time_spinner - 100, Position = positionCircle, Actions = { OsuAction.LeftButton } },
new OsuReplayFrame { Time = time_spinner + 10, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } }, };
new OsuReplayFrame { Time = time_spinner + 20, Position = new Vector2(256, 172), Actions = { OsuAction.RightButton } },
new OsuReplayFrame { Time = time_spinner + 30, Position = new Vector2(276, 192), Actions = { OsuAction.RightButton } }, frames.AddRange(new SpinFramesGenerator(time_spinner + 10)
new OsuReplayFrame { Time = time_spinner + 40, Position = new Vector2(256, 212), Actions = { OsuAction.RightButton } }, .Spin(360, 500)
new OsuReplayFrame { Time = time_spinner + 50, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } }, .Build());
});
performTest(hitObjects, frames);
addJudgementAssert(hitObjects[0], HitResult.Great); addJudgementAssert(hitObjects[0], HitResult.Great);
addJudgementAssert(hitObjects[1], HitResult.Great); addJudgementAssert(hitObjects[1], HitResult.Great);

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)
{ {
@ -288,10 +291,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
if (selectedControlPoints.Contains(hitObject.Path.ControlPoints[0])) if (selectedControlPoints.Contains(hitObject.Path.ControlPoints[0]))
{ {
// 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;
hitObject.Position += movementDelta; hitObject.Position += movementDelta;
hitObject.StartTime = result?.Time ?? hitObject.StartTime; hitObject.StartTime = result?.Time ?? hitObject.StartTime;
@ -309,9 +312,9 @@ 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;
for (int i = 0; i < controlPoints.Count; ++i) for (int i = 0; i < controlPoints.Count; ++i)
{ {
@ -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,7 +48,10 @@ 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()
.Concat(DistanceSnapProvider.CreateTernaryButtons())
.Concat(new[]
{ {
new TernaryButton(rectangularGridSnapToggle, "Grid Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Th }) new TernaryButton(rectangularGridSnapToggle, "Grid Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Th })
}); });
@ -58,9 +60,15 @@ namespace osu.Game.Rulesets.Osu.Edit
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

@ -4,6 +4,7 @@
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
namespace osu.Game.Rulesets.Osu.Judgements namespace osu.Game.Rulesets.Osu.Judgements
{ {
@ -15,28 +16,15 @@ namespace osu.Game.Rulesets.Osu.Judgements
public Spinner Spinner => (Spinner)HitObject; public Spinner Spinner => (Spinner)HitObject;
/// <summary> /// <summary>
/// The total rotation performed on the spinner disc, disregarding the spin direction, /// The total amount that the spinner was rotated.
/// adjusted for the track's playback rate.
/// </summary> /// </summary>
/// <remarks> public float TotalRotation => History.TotalRotation;
/// <para>
/// This value is always non-negative and is monotonically increasing with time /// <summary>
/// (i.e. will only increase if time is passing forward, but can decrease during rewind). /// Stores the spinning history of the spinner.<br />
/// </para> /// Instants of movement deltas may be added or removed from this in order to calculate the total rotation for the spinner.
/// <para> /// </summary>
/// The rotation from each frame is multiplied by the clock's current playback rate. public readonly SpinnerSpinHistory History = new SpinnerSpinHistory();
/// The reason this is done is to ensure that spinners give the same score and require the same number of spins
/// regardless of whether speed-modifying mods are applied.
/// </para>
/// </remarks>
/// <example>
/// Assuming no speed-modifying mods are active,
/// if the spinner is spun 360 degrees clockwise and then 360 degrees counter-clockwise,
/// this property will return the value of 720 (as opposed to 0).
/// If Double Time is active instead (with a speed multiplier of 1.5x),
/// in the same scenario the property will return 720 * 1.5 = 1080.
/// </example>
public float RateAdjustedRotation;
/// <summary> /// <summary>
/// Time instant at which the spin was started (the first user input which caused an increase in spin). /// Time instant at which the spin was started (the first user input which caused an increase in spin).

View File

@ -139,12 +139,12 @@ namespace osu.Game.Rulesets.Osu.Mods
if (Precision.AlmostEquals(restrictTo.Rotation, 0)) if (Precision.AlmostEquals(restrictTo.Rotation, 0))
{ {
start = Parent.ToLocalSpace(restrictTo.ScreenSpaceDrawQuad.TopLeft).X; start = Parent!.ToLocalSpace(restrictTo.ScreenSpaceDrawQuad.TopLeft).X;
end = Parent.ToLocalSpace(restrictTo.ScreenSpaceDrawQuad.TopRight).X; end = Parent!.ToLocalSpace(restrictTo.ScreenSpaceDrawQuad.TopRight).X;
} }
else else
{ {
float center = restrictTo.ToSpaceOfOtherDrawable(restrictTo.OriginPosition, Parent).X; float center = restrictTo.ToSpaceOfOtherDrawable(restrictTo.OriginPosition, Parent!).X;
float halfDiagonal = (restrictTo.DrawSize / 2).LengthFast; float halfDiagonal = (restrictTo.DrawSize / 2).LengthFast;
start = center - halfDiagonal; start = center - halfDiagonal;

View File

@ -98,6 +98,9 @@ namespace osu.Game.Rulesets.Osu.Mods
// only apply to circle piece reverse arrow is not affected by hidden. // only apply to circle piece reverse arrow is not affected by hidden.
sliderRepeat.CirclePiece.FadeOut(fadeDuration); sliderRepeat.CirclePiece.FadeOut(fadeDuration);
using (drawableObject.BeginAbsoluteSequence(drawableObject.HitStateUpdateTime))
sliderRepeat.FadeOut();
break; break;
case DrawableHitCircle circle: case DrawableHitCircle circle:

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

@ -102,7 +102,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
/// </summary> /// </summary>
public void MissForcefully() => ApplyResult(r => r.Type = r.Judgement.MinResult); public void MissForcefully() => ApplyResult(r => r.Type = r.Judgement.MinResult);
private RectangleF parentScreenSpaceRectangle => ((DrawableOsuHitObject)ParentHitObject)?.parentScreenSpaceRectangle ?? Parent.ScreenSpaceDrawQuad.AABBFloat; private RectangleF parentScreenSpaceRectangle => ((DrawableOsuHitObject)ParentHitObject)?.parentScreenSpaceRectangle ?? Parent!.ScreenSpaceDrawQuad.AABBFloat;
/// <summary> /// <summary>
/// Calculates the position of the given <paramref name="drawable"/> relative to the playfield area. /// Calculates the position of the given <paramref name="drawable"/> relative to the playfield area.

View File

@ -40,6 +40,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
{ {
HeadCircle, HeadCircle,
TailCircle, TailCircle,
repeatContainer,
Body, Body,
}; };
@ -107,7 +108,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
headContainer = new Container<DrawableSliderHead> { RelativeSizeAxes = Axes.Both }, headContainer = new Container<DrawableSliderHead> { RelativeSizeAxes = Axes.Both },
OverlayElementContainer = new Container { RelativeSizeAxes = Axes.Both, }, OverlayElementContainer = new Container { RelativeSizeAxes = Axes.Both, },
Ball, Ball,
slidingSample = new PausableSkinnableSound { Looping = true } slidingSample = new PausableSkinnableSound
{
Looping = true,
MinimumSampleVolume = MINIMUM_SAMPLE_VOLUME,
}
}); });
PositionBindable.BindValueChanged(_ => Position = HitObject.StackedPosition); PositionBindable.BindValueChanged(_ => Position = HitObject.StackedPosition);
@ -264,7 +269,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

@ -118,11 +118,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
case ArmedState.Hit: case ArmedState.Hit:
this.FadeOut(animDuration, Easing.Out); this.FadeOut(animDuration, Easing.Out);
const float final_scale = 1.5f;
Arrow.ScaleTo(Scale * final_scale, animDuration, Easing.Out);
CirclePiece.ScaleTo(Scale * final_scale, animDuration, Easing.Out);
break; break;
} }
} }

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

@ -48,9 +48,16 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
/// <summary> /// <summary>
/// The amount of bonus score gained from spinning after the required number of spins, for display purposes. /// The amount of bonus score gained from spinning after the required number of spins, for display purposes.
/// </summary> /// </summary>
public IBindable<double> GainedBonus => gainedBonus; public double CurrentBonusScore => score_per_tick * Math.Clamp(completedFullSpins.Value - HitObject.SpinsRequiredForBonus, 0, HitObject.MaximumBonusSpins);
private readonly Bindable<double> gainedBonus = new BindableDouble(); /// <summary>
/// The maximum amount of bonus score which can be achieved from extra spins.
/// </summary>
public double MaximumBonusScore => score_per_tick * HitObject.MaximumBonusSpins;
public IBindable<int> CompletedFullSpins => completedFullSpins;
private readonly Bindable<int> completedFullSpins = new Bindable<int>();
/// <summary> /// <summary>
/// The number of spins per minute this spinner is spinning at, for display purposes. /// The number of spins per minute this spinner is spinning at, for display purposes.
@ -99,6 +106,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
spinningSample = new PausableSkinnableSound spinningSample = new PausableSkinnableSound
{ {
Volume = { Value = 0 }, Volume = { Value = 0 },
MinimumSampleVolume = MINIMUM_SAMPLE_VOLUME,
Looping = true, Looping = true,
Frequency = { Value = spinning_sample_initial_frequency } Frequency = { Value = spinning_sample_initial_frequency }
} }
@ -218,7 +226,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
// these become implicitly hit. // these become implicitly hit.
return 1; return 1;
return Math.Clamp(Result.RateAdjustedRotation / 360 / HitObject.SpinsRequired, 0, 1); return Math.Clamp(Result.TotalRotation / 360 / HitObject.SpinsRequired, 0, 1);
} }
} }
@ -279,43 +287,35 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
// don't update after end time to avoid the rate display dropping during fade out. // don't update after end time to avoid the rate display dropping during fade out.
// this shouldn't be limited to StartTime as it causes weirdness with the underlying calculation, which is expecting updates during that period. // this shouldn't be limited to StartTime as it causes weirdness with the underlying calculation, which is expecting updates during that period.
if (Time.Current <= HitObject.EndTime) if (Time.Current <= HitObject.EndTime)
spmCalculator.SetRotation(Result.RateAdjustedRotation); spmCalculator.SetRotation(Result.TotalRotation);
updateBonusScore(); updateBonusScore();
} }
private static readonly int score_per_tick = new SpinnerBonusTick.OsuSpinnerBonusTickJudgement().MaxNumericResult; private static readonly int score_per_tick = new SpinnerBonusTick.OsuSpinnerBonusTickJudgement().MaxNumericResult;
private int completedFullSpins;
private void updateBonusScore() private void updateBonusScore()
{ {
if (ticks.Count == 0) if (ticks.Count == 0)
return; return;
int spins = (int)(Result.RateAdjustedRotation / 360); int spins = (int)(Result.TotalRotation / 360);
if (spins < completedFullSpins) if (spins < completedFullSpins.Value)
{ {
// rewinding, silently handle // rewinding, silently handle
completedFullSpins = spins; completedFullSpins.Value = spins;
return; return;
} }
while (completedFullSpins != spins) while (completedFullSpins.Value != spins)
{ {
var tick = ticks.FirstOrDefault(t => !t.Result.HasResult); var tick = ticks.FirstOrDefault(t => !t.Result.HasResult);
// tick may be null if we've hit the spin limit. // tick may be null if we've hit the spin limit.
if (tick != null) tick?.TriggerResult(true);
{
tick.TriggerResult(true);
if (tick is DrawableSpinnerBonusTick) completedFullSpins.Value++;
gainedBonus.Value = score_per_tick * (spins - HitObject.SpinsRequiredForBonus);
}
completedFullSpins++;
} }
} }
} }

View File

@ -25,6 +25,16 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
Origin = Anchor.Centre; Origin = Anchor.Centre;
} }
protected override void OnApply()
{
base.OnApply();
// the tick can be theoretically judged at any point in the spinner's duration,
// so it must be alive throughout the spinner's entire lifetime.
// this mostly matters for correct sample playback.
LifetimeStart = DrawableSpinner.HitObject.StartTime;
}
/// <summary> /// <summary>
/// Apply a judgement result. /// Apply a judgement result.
/// </summary> /// </summary>

View File

@ -0,0 +1,146 @@
// 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.Diagnostics;
namespace osu.Game.Rulesets.Osu.Objects.Drawables
{
/// <summary>
/// Stores the spinning history of a single spinner.<br />
/// Instants of movement deltas may be added or removed from this in order to calculate the total rotation for the spinner.
/// </summary>
/// <remarks>
/// A single, full rotation of the spinner is defined as a 360-degree rotation of the spinner, starting from 0, going in a single direction.<br />
/// </remarks>
/// <example>
/// If the player spins 90-degrees clockwise, then changes direction, they need to spin 90-degrees counter-clockwise to return to 0
/// and then continue rotating the spinner for another 360-degrees in the same direction.
/// </example>
public class SpinnerSpinHistory
{
/// <summary>
/// The sum of all complete spins and any current partial spin, in degrees.
/// </summary>
/// <remarks>
/// This is the final scoring value.
/// </remarks>
public float TotalRotation => 360 * completedSpins.Count + currentSpinMaxRotation;
private readonly Stack<CompletedSpin> completedSpins = new Stack<CompletedSpin>();
/// <summary>
/// The total accumulated (absolute) rotation.
/// </summary>
private float totalAccumulatedRotation;
private float totalAccumulatedRotationAtLastCompletion;
/// <summary>
/// For the current spin, represents the maximum absolute rotation (from 0..360) achieved by the user.
/// </summary>
/// <remarks>
/// This is used to report <see cref="TotalRotation"/> in the case a user spins backwards.
/// Basically it allows us to not reduce the total rotation in such a case.
///
/// This also stops spinner "cheese" where a user may rapidly change directions and cause an increase
/// in rotations.
/// </remarks>
private float currentSpinMaxRotation;
/// <summary>
/// The current spin, from -360..360.
/// </summary>
private float currentSpinRotation => totalAccumulatedRotation - totalAccumulatedRotationAtLastCompletion;
private double lastReportTime = double.NegativeInfinity;
/// <summary>
/// Report a delta update based on user input.
/// </summary>
/// <param name="currentTime">The current time.</param>
/// <param name="delta">The delta of the angle moved through since the last report.</param>
public void ReportDelta(double currentTime, float delta)
{
if (delta == 0)
return;
// Importantly, outside of tests the max delta entering here is 180 degrees.
// If it wasn't for tests, we could add this line:
//
// Debug.Assert(Math.Abs(delta) < 180);
//
// For this to be 101% correct, we need to add the ability for important frames to be
// created based on gameplay intrinsics (ie. there should be one frame for any spinner delta 90 < n < 180 degrees).
//
// But this can come later.
totalAccumulatedRotation += delta;
if (currentTime >= lastReportTime)
{
currentSpinMaxRotation = Math.Max(currentSpinMaxRotation, Math.Abs(currentSpinRotation));
// Handle the case where the user has completed another spin.
// Note that this does could be an `if` rather than `while` if the above assertion held true.
// It is a `while` loop to handle tests which throw larger values at this method.
while (currentSpinMaxRotation >= 360)
{
int direction = Math.Sign(currentSpinRotation);
completedSpins.Push(new CompletedSpin(currentTime, direction));
// Incrementing the last completion point will cause `currentSpinRotation` to
// hold the remaining spin that needs to be considered.
totalAccumulatedRotationAtLastCompletion += direction * 360;
// Reset the current max as we are entering a new spin.
// Importantly, carry over the remainder (which is now stored in `currentSpinRotation`).
currentSpinMaxRotation = Math.Abs(currentSpinRotation);
}
}
else
{
// When rewinding, the main thing we care about is getting `totalAbsoluteRotationsAtLastCompletion`
// to the correct value. We can used the stored history for this.
while (completedSpins.TryPeek(out var segment) && segment.CompletionTime > currentTime)
{
completedSpins.Pop();
totalAccumulatedRotationAtLastCompletion -= segment.Direction * 360;
}
// This is a best effort. We may not have enough data to match this 1:1, but that's okay.
// We know that the player is somewhere in a spin.
// In the worst case, this will be lower than expected, and recover in forward playback.
currentSpinMaxRotation = Math.Abs(currentSpinRotation);
}
lastReportTime = currentTime;
}
/// <summary>
/// Represents a single completed spin.
/// </summary>
private class CompletedSpin
{
/// <summary>
/// The time at which this spin completion occurred.
/// </summary>
public readonly double CompletionTime;
/// <summary>
/// The direction this spin completed in.
/// </summary>
public readonly int Direction;
public CompletedSpin(double completionTime, int direction)
{
Debug.Assert(direction == -1 || direction == 1);
CompletionTime = completionTime;
Direction = direction;
}
}
}
}

View File

@ -7,6 +7,7 @@ using osu.Framework.Bindables;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
@ -155,7 +156,7 @@ namespace osu.Game.Rulesets.Osu.Objects
// This adjustment is necessary for AR>10, otherwise TimePreempt can become smaller leading to hitcircles not fully fading in. // This adjustment is necessary for AR>10, otherwise TimePreempt can become smaller leading to hitcircles not fully fading in.
TimeFadeIn = 400 * Math.Min(1, TimePreempt / PREEMPT_MIN); TimeFadeIn = 400 * Math.Min(1, TimePreempt / PREEMPT_MIN);
Scale = (1.0f - 0.7f * (difficulty.CircleSize - 5) / 5) / 2; Scale = LegacyRulesetExtensions.CalculateScaleFromCircleSize(difficulty.CircleSize, true);
} }
protected override HitWindows CreateHitWindows() => new OsuHitWindows(); protected override HitWindows CreateHitWindows() => new OsuHitWindows();

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

@ -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;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Extensions.Color4Extensions;
@ -12,6 +13,7 @@ using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures; using osu.Framework.Graphics.Textures;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osuTK; using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
@ -19,8 +21,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
{ {
public partial class ArgonReverseArrow : CompositeDrawable public partial class ArgonReverseArrow : CompositeDrawable
{ {
[Resolved] private DrawableSliderRepeat drawableRepeat { get; set; } = null!;
private DrawableHitObject drawableObject { get; set; } = null!;
private Bindable<Color4> accentColour = null!; private Bindable<Color4> accentColour = null!;
@ -29,8 +30,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
private Sprite side = null!; private Sprite side = null!;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(TextureStore textures) private void load(DrawableHitObject drawableObject, TextureStore textures)
{ {
drawableRepeat = (DrawableSliderRepeat)drawableObject;
Anchor = Anchor.Centre; Anchor = Anchor.Centre;
Origin = Anchor.Centre; Origin = Anchor.Centre;
@ -70,10 +73,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
} }
}; };
accentColour = drawableObject.AccentColour.GetBoundCopy(); accentColour = drawableRepeat.AccentColour.GetBoundCopy();
accentColour.BindValueChanged(accent => icon.Colour = accent.NewValue.Darken(4), true); accentColour.BindValueChanged(accent => icon.Colour = accent.NewValue.Darken(4), true);
drawableObject.ApplyCustomUpdateState += updateStateTransforms; drawableRepeat.ApplyCustomUpdateState += updateStateTransforms;
} }
private void updateStateTransforms(DrawableHitObject hitObject, ArmedState state) private void updateStateTransforms(DrawableHitObject hitObject, ArmedState state)
@ -96,6 +99,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
.MoveToX(0, move_in_duration, Easing.Out) .MoveToX(0, move_in_duration, Easing.Out)
.Loop(total - (move_in_duration + move_out_duration)); .Loop(total - (move_in_duration + move_out_duration));
break; break;
case ArmedState.Hit:
double animDuration = Math.Min(300, drawableRepeat.HitObject.SpanDuration);
this.ScaleTo(1.5f, animDuration, Easing.Out);
break;
} }
} }
@ -103,8 +111,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
{ {
base.Dispose(isDisposing); base.Dispose(isDisposing);
if (drawableObject.IsNotNull()) if (drawableRepeat.IsNotNull())
drawableObject.ApplyCustomUpdateState -= updateStateTransforms; drawableRepeat.ApplyCustomUpdateState -= updateStateTransforms;
} }
} }
} }

View File

@ -109,7 +109,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
base.Update(); base.Update();
//undo rotation on layers which should not be rotated. //undo rotation on layers which should not be rotated.
float appliedRotation = Parent.Rotation; float appliedRotation = Parent!.Rotation;
fill.Rotation = -appliedRotation; fill.Rotation = -appliedRotation;
} }

View File

@ -35,14 +35,6 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
InternalChildren = new Drawable[] InternalChildren = new Drawable[]
{ {
bonusCounter = new OsuSpriteText
{
Alpha = 0,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Font = OsuFont.Default.With(size: 24),
Y = -120,
},
new ArgonSpinnerDisc new ArgonSpinnerDisc
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
@ -85,19 +77,33 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
}; };
} }
private IBindable<double> gainedBonus = null!; private IBindable<int> completedSpins = null!;
private IBindable<double> spinsPerMinute = null!; private IBindable<double> spinsPerMinute = null!;
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();
gainedBonus = drawableSpinner.GainedBonus.GetBoundCopy(); completedSpins = drawableSpinner.CompletedFullSpins.GetBoundCopy();
gainedBonus.BindValueChanged(bonus => completedSpins.BindValueChanged(_ =>
{ {
bonusCounter.Text = bonus.NewValue.ToString(NumberFormatInfo.InvariantInfo); if (drawableSpinner.CurrentBonusScore <= 0)
bonusCounter.FadeOutFromOne(1500); return;
if (drawableSpinner.CurrentBonusScore == drawableSpinner.MaximumBonusScore)
{
bonusCounter.Text = "MAX";
bonusCounter.ScaleTo(1.5f).Then().ScaleTo(2.8f, 1000, Easing.OutQuint);
bonusCounter.FlashColour(Colour4.FromHex("FC618F"), 400);
bonusCounter.FadeOutFromOne(500);
}
else
{
bonusCounter.Text = drawableSpinner.CurrentBonusScore.ToString(NumberFormatInfo.InvariantInfo);
bonusCounter.ScaleTo(1.5f).Then().ScaleTo(1f, 1000, Easing.OutQuint); bonusCounter.ScaleTo(1.5f).Then().ScaleTo(1f, 1000, Easing.OutQuint);
bonusCounter.FadeOutFromOne(1500);
}
}); });
spinsPerMinute = drawableSpinner.SpinsPerMinute.GetBoundCopy(); spinsPerMinute = drawableSpinner.SpinsPerMinute.GetBoundCopy();

View File

@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
{ {
get get
{ {
int rotations = (int)(drawableSpinner.Result.RateAdjustedRotation / 360); int rotations = (int)(drawableSpinner.Result.TotalRotation / 360);
if (wholeRotationCount == rotations) return false; if (wholeRotationCount == rotations) return false;

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;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
@ -8,14 +9,14 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osuTK; using osuTK;
namespace osu.Game.Rulesets.Osu.Skinning.Default namespace osu.Game.Rulesets.Osu.Skinning.Default
{ {
public partial class DefaultReverseArrow : CompositeDrawable public partial class DefaultReverseArrow : CompositeDrawable
{ {
[Resolved] private DrawableSliderRepeat drawableRepeat { get; set; } = null!;
private DrawableHitObject drawableObject { get; set; } = null!;
public DefaultReverseArrow() public DefaultReverseArrow()
{ {
@ -36,9 +37,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
} }
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load(DrawableHitObject drawableObject)
{ {
drawableObject.ApplyCustomUpdateState += updateStateTransforms; drawableRepeat = (DrawableSliderRepeat)drawableObject;
drawableRepeat.ApplyCustomUpdateState += updateStateTransforms;
} }
private void updateStateTransforms(DrawableHitObject hitObject, ArmedState state) private void updateStateTransforms(DrawableHitObject hitObject, ArmedState state)
@ -55,6 +57,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
.ScaleTo(1f, move_in_duration, Easing.Out) .ScaleTo(1f, move_in_duration, Easing.Out)
.Loop(total - (move_in_duration + move_out_duration)); .Loop(total - (move_in_duration + move_out_duration));
break; break;
case ArmedState.Hit:
double animDuration = Math.Min(300, drawableRepeat.HitObject.SpanDuration);
InternalChild.ScaleTo(1.5f, animDuration, Easing.Out);
break;
} }
} }
@ -62,8 +69,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
{ {
base.Dispose(isDisposing); base.Dispose(isDisposing);
if (drawableObject.IsNotNull()) if (drawableRepeat.IsNotNull())
drawableObject.ApplyCustomUpdateState -= updateStateTransforms; drawableRepeat.ApplyCustomUpdateState -= updateStateTransforms;
} }
} }
} }

View File

@ -24,6 +24,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
private Container spmContainer = null!; private Container spmContainer = null!;
private OsuSpriteText spmCounter = null!; private OsuSpriteText spmCounter = null!;
[Resolved]
private OsuColour colours { get; set; } = null!;
public DefaultSpinner() public DefaultSpinner()
{ {
RelativeSizeAxes = Axes.Both; RelativeSizeAxes = Axes.Both;
@ -80,19 +83,33 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
}); });
} }
private IBindable<double> gainedBonus = null!; private IBindable<int> completedSpins = null!;
private IBindable<double> spinsPerMinute = null!; private IBindable<double> spinsPerMinute = null!;
protected override void LoadComplete() protected override void LoadComplete()
{ {
base.LoadComplete(); base.LoadComplete();
gainedBonus = drawableSpinner.GainedBonus.GetBoundCopy(); completedSpins = drawableSpinner.CompletedFullSpins.GetBoundCopy();
gainedBonus.BindValueChanged(bonus => completedSpins.BindValueChanged(bonus =>
{ {
bonusCounter.Text = bonus.NewValue.ToString(NumberFormatInfo.InvariantInfo); if (drawableSpinner.CurrentBonusScore <= 0)
return;
if (drawableSpinner.CurrentBonusScore == drawableSpinner.MaximumBonusScore)
{
bonusCounter.Text = "MAX";
bonusCounter.ScaleTo(1.5f).Then().ScaleTo(2.8f, 1000, Easing.OutQuint);
bonusCounter.FlashColour(colours.YellowLight, 400);
bonusCounter.FadeOutFromOne(500);
}
else
{
bonusCounter.Text = drawableSpinner.CurrentBonusScore.ToString(NumberFormatInfo.InvariantInfo);
bonusCounter.FadeOutFromOne(1500); bonusCounter.FadeOutFromOne(1500);
bonusCounter.ScaleTo(1.5f).Then().ScaleTo(1f, 1000, Easing.OutQuint); bonusCounter.ScaleTo(1.5f).Then().ScaleTo(1f, 1000, Easing.OutQuint);
}
}); });
spinsPerMinute = drawableSpinner.SpinsPerMinute.GetBoundCopy(); spinsPerMinute = drawableSpinner.SpinsPerMinute.GetBoundCopy();

View File

@ -200,7 +200,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
{ {
get get
{ {
int rotations = (int)(drawableSpinner.Result.RateAdjustedRotation / 360); int rotations = (int)(drawableSpinner.Result.TotalRotation / 360);
if (wholeRotationCount == rotations) return false; if (wholeRotationCount == rotations) return false;

View File

@ -2,6 +2,7 @@
// 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;
using System.Diagnostics;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Extensions.ObjectExtensions;
@ -22,11 +23,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
private readonly DrawableSpinner drawableSpinner; private readonly DrawableSpinner drawableSpinner;
private Vector2 mousePosition; private Vector2? mousePosition;
private float? lastAngle;
private float lastAngle;
private float currentRotation; private float currentRotation;
private bool rotationTransferred; private bool rotationTransferred;
[Resolved(canBeNull: true)] [Resolved(canBeNull: true)]
@ -56,24 +56,30 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
protected override bool OnMouseMove(MouseMoveEvent e) protected override bool OnMouseMove(MouseMoveEvent e)
{ {
mousePosition = Parent.ToLocalSpace(e.ScreenSpaceMousePosition); mousePosition = Parent!.ToLocalSpace(e.ScreenSpaceMousePosition);
return base.OnMouseMove(e); return base.OnMouseMove(e);
} }
protected override void Update() protected override void Update()
{ {
base.Update(); base.Update();
float thisAngle = -MathUtils.RadiansToDegrees(MathF.Atan2(mousePosition.X - DrawSize.X / 2, mousePosition.Y - DrawSize.Y / 2));
float delta = thisAngle - lastAngle; if (mousePosition is Vector2 pos)
{
float thisAngle = -MathUtils.RadiansToDegrees(MathF.Atan2(pos.X - DrawSize.X / 2, pos.Y - DrawSize.Y / 2));
float delta = lastAngle == null ? 0 : thisAngle - lastAngle.Value;
// Normalise the delta to -180 .. 180
if (delta > 180) delta -= 360;
if (delta < -180) delta += 360;
if (Tracking) if (Tracking)
AddRotation(delta); AddRotation(delta);
lastAngle = thisAngle; lastAngle = thisAngle;
}
IsSpinning.Value = isSpinnableTime && Math.Abs(currentRotation - Rotation) > 10f; IsSpinning.Value = isSpinnableTime && Math.Abs(currentRotation - Rotation) > 10f;
Rotation = (float)Interpolation.Damp(Rotation, currentRotation, 0.99, Math.Abs(Time.Elapsed)); Rotation = (float)Interpolation.Damp(Rotation, currentRotation, 0.99, Math.Abs(Time.Elapsed));
} }
@ -83,41 +89,35 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
/// <remarks> /// <remarks>
/// Will be a no-op if not a valid time to spin. /// Will be a no-op if not a valid time to spin.
/// </remarks> /// </remarks>
/// <param name="angle">The delta angle.</param> /// <param name="delta">The delta angle.</param>
public void AddRotation(float angle) public void AddRotation(float delta)
{ {
if (!isSpinnableTime) if (!isSpinnableTime)
return; return;
if (!rotationTransferred) if (!rotationTransferred)
{ {
currentRotation = Rotation * 2; currentRotation = Rotation;
rotationTransferred = true; rotationTransferred = true;
} }
if (angle > 180) double rate = gameplayClock?.GetTrueGameplayRate() ?? Clock.Rate;
{ delta = (float)(delta * Math.Abs(rate));
lastAngle += 360;
angle -= 360;
}
else if (-angle > 180)
{
lastAngle -= 360;
angle += 360;
}
currentRotation += angle; Debug.Assert(Math.Abs(delta) <= 180);
// rate has to be applied each frame, because it's not guaranteed to be constant throughout playback
// (see: ModTimeRamp) currentRotation += delta;
drawableSpinner.Result.RateAdjustedRotation += (float)(Math.Abs(angle) * (gameplayClock?.GetTrueGameplayRate() ?? Clock.Rate)); drawableSpinner.Result.History.ReportDelta(Time.Current, delta);
} }
private void resetState(DrawableHitObject obj) private void resetState(DrawableHitObject obj)
{ {
Tracking = false; Tracking = false;
IsSpinning.Value = false; IsSpinning.Value = false;
mousePosition = default; mousePosition = null;
lastAngle = currentRotation = Rotation = 0; lastAngle = null;
currentRotation = 0;
Rotation = 0;
rotationTransferred = false; rotationTransferred = false;
} }

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;
using System.Diagnostics; using System.Diagnostics;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
@ -17,8 +18,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{ {
public partial class LegacyReverseArrow : CompositeDrawable public partial class LegacyReverseArrow : CompositeDrawable
{ {
[Resolved] private DrawableSliderRepeat drawableRepeat { get; set; } = null!;
private DrawableHitObject drawableObject { get; set; } = null!;
private Drawable proxy = null!; private Drawable proxy = null!;
@ -31,8 +31,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
private bool shouldRotate; private bool shouldRotate;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(ISkinSource skinSource) private void load(DrawableHitObject drawableObject, ISkinSource skinSource)
{ {
drawableRepeat = (DrawableSliderRepeat)drawableObject;
AutoSizeAxes = Axes.Both; AutoSizeAxes = Axes.Both;
string lookupName = new OsuSkinComponentLookup(OsuSkinComponents.ReverseArrow).LookupName; string lookupName = new OsuSkinComponentLookup(OsuSkinComponents.ReverseArrow).LookupName;
@ -58,10 +60,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
proxy = CreateProxy(); proxy = CreateProxy();
drawableObject.HitObjectApplied += onHitObjectApplied; drawableRepeat.HitObjectApplied += onHitObjectApplied;
onHitObjectApplied(drawableObject); onHitObjectApplied(drawableRepeat);
accentColour = drawableObject.AccentColour.GetBoundCopy(); accentColour = drawableRepeat.AccentColour.GetBoundCopy();
accentColour.BindValueChanged(c => accentColour.BindValueChanged(c =>
{ {
arrow.Colour = textureIsDefaultSkin && c.NewValue.R + c.NewValue.G + c.NewValue.B > (600 / 255f) ? Color4.Black : Color4.White; arrow.Colour = textureIsDefaultSkin && c.NewValue.R + c.NewValue.G + c.NewValue.B > (600 / 255f) ? Color4.Black : Color4.White;
@ -73,8 +75,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
Debug.Assert(proxy.Parent == null); Debug.Assert(proxy.Parent == null);
// see logic in LegacySliderHeadHitCircle. // see logic in LegacySliderHeadHitCircle.
(drawableObject as DrawableSliderRepeat)?.DrawableSlider drawableRepeat.DrawableSlider.OverlayElementContainer.Add(proxy);
.OverlayElementContainer.Add(proxy);
} }
private void updateStateTransforms(DrawableHitObject hitObject, ArmedState state) private void updateStateTransforms(DrawableHitObject hitObject, ArmedState state)
@ -102,6 +103,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
} }
break; break;
case ArmedState.Hit:
double animDuration = Math.Min(300, drawableRepeat.HitObject.SpanDuration);
InternalChild.ScaleTo(1.4f, animDuration, Easing.Out);
break;
} }
} }
@ -109,10 +115,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{ {
base.Dispose(isDisposing); base.Dispose(isDisposing);
if (drawableObject.IsNotNull()) if (drawableRepeat.IsNotNull())
{ {
drawableObject.HitObjectApplied -= onHitObjectApplied; drawableRepeat.HitObjectApplied -= onHitObjectApplied;
drawableObject.ApplyCustomUpdateState -= updateStateTransforms; drawableRepeat.ApplyCustomUpdateState -= updateStateTransforms;
} }
} }
} }

View File

@ -1,35 +1,39 @@
// 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 osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Animations;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects.Drawables; using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Skinning; using osu.Game.Skinning;
using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
namespace osu.Game.Rulesets.Osu.Skinning.Legacy namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{ {
public partial class LegacySliderBall : CompositeDrawable public partial class LegacySliderBall : CompositeDrawable
{ {
private readonly Drawable animationContent;
private readonly ISkin skin; private readonly ISkin skin;
[Resolved(canBeNull: true)] [Resolved(canBeNull: true)]
private DrawableHitObject? parentObject { get; set; } private DrawableHitObject? parentObject { get; set; }
public Color4 BallColour => animationContent.Colour;
private Sprite layerNd = null!; private Sprite layerNd = null!;
private Sprite layerSpec = null!; private Sprite layerSpec = null!;
public LegacySliderBall(Drawable animationContent, ISkin skin) private TextureAnimation ballAnimation = null!;
private Texture[] ballTextures = null!;
public Color4 BallColour => ballAnimation.Colour;
public LegacySliderBall(ISkin skin)
{ {
this.animationContent = animationContent;
this.skin = skin; this.skin = skin;
AutoSizeAxes = Axes.Both; AutoSizeAxes = Axes.Both;
@ -38,30 +42,39 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load() private void load()
{ {
var ballColour = skin.GetConfig<OsuSkinColour, Color4>(OsuSkinColour.SliderBall)?.Value ?? Color4.White; Vector2 maxSize = OsuLegacySkinTransformer.MAX_FOLLOW_CIRCLE_AREA_SIZE;
InternalChildren = new[] var ballColour = skin.GetConfig<OsuSkinColour, Color4>(OsuSkinColour.SliderBall)?.Value ?? Color4.White;
ballTextures = skin.GetTextures("sliderb", default, default, true, "", maxSize, out _);
InternalChildren = new Drawable[]
{ {
layerNd = new Sprite layerNd = new Sprite
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre,
Texture = skin.GetTexture("sliderb-nd")?.WithMaximumSize(OsuLegacySkinTransformer.MAX_FOLLOW_CIRCLE_AREA_SIZE), Texture = skin.GetTexture("sliderb-nd")?.WithMaximumSize(maxSize),
Colour = new Color4(5, 5, 5, 255), Colour = new Color4(5, 5, 5, 255),
}, },
LegacyColourCompatibility.ApplyWithDoubledAlpha(animationContent.With(d => ballAnimation = new LegacySkinExtensions.SkinnableTextureAnimation
{ {
d.Anchor = Anchor.Centre; Anchor = Anchor.Centre,
d.Origin = Anchor.Centre; Origin = Anchor.Centre,
}), ballColour), Colour = ballColour,
},
layerSpec = new Sprite layerSpec = new Sprite
{ {
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre,
Texture = skin.GetTexture("sliderb-spec")?.WithMaximumSize(OsuLegacySkinTransformer.MAX_FOLLOW_CIRCLE_AREA_SIZE), Texture = skin.GetTexture("sliderb-spec")?.WithMaximumSize(maxSize),
Blending = BlendingParameters.Additive, Blending = BlendingParameters.Additive,
}, },
}; };
if (parentObject != null)
parentObject.HitObjectApplied += onHitObjectApplied;
onHitObjectApplied(parentObject);
} }
private readonly IBindable<Color4> accentColour = new Bindable<Color4>(); private readonly IBindable<Color4> accentColour = new Bindable<Color4>();
@ -78,7 +91,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
if (skin.GetConfig<SkinConfiguration.LegacySetting, bool>(SkinConfiguration.LegacySetting.AllowSliderBallTint)?.Value == true) if (skin.GetConfig<SkinConfiguration.LegacySetting, bool>(SkinConfiguration.LegacySetting.AllowSliderBallTint)?.Value == true)
{ {
accentColour.BindTo(parentObject.AccentColour); accentColour.BindTo(parentObject.AccentColour);
accentColour.BindValueChanged(a => animationContent.Colour = a.NewValue, true); accentColour.BindValueChanged(a => ballAnimation.Colour = a.NewValue, true);
} }
} }
} }
@ -88,12 +101,32 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
base.UpdateAfterChildren(); base.UpdateAfterChildren();
//undo rotation on layers which should not be rotated. //undo rotation on layers which should not be rotated.
float appliedRotation = Parent.Rotation; float appliedRotation = Parent!.Rotation;
layerNd.Rotation = -appliedRotation; layerNd.Rotation = -appliedRotation;
layerSpec.Rotation = -appliedRotation; layerSpec.Rotation = -appliedRotation;
} }
private void onHitObjectApplied(DrawableHitObject? drawableObject = null)
{
double frameDelay;
if (drawableObject?.HitObject != null)
{
DrawableSlider drawableSlider = (DrawableSlider)drawableObject;
frameDelay = Math.Max(
0.15 / drawableSlider.HitObject.Velocity * LegacySkinExtensions.SIXTY_FRAME_TIME,
LegacySkinExtensions.SIXTY_FRAME_TIME);
}
else
frameDelay = LegacySkinExtensions.SIXTY_FRAME_TIME;
ballAnimation.ClearFrames();
foreach (var texture in ballTextures)
ballAnimation.AddFrame(texture, frameDelay);
}
private void updateStateTransforms(DrawableHitObject drawableObject, ArmedState _) private void updateStateTransforms(DrawableHitObject drawableObject, ArmedState _)
{ {
// Gets called by slider ticks, tails, etc., leading to duplicated // Gets called by slider ticks, tails, etc., leading to duplicated
@ -114,7 +147,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
base.Dispose(isDisposing); base.Dispose(isDisposing);
if (parentObject != null) if (parentObject != null)
{
parentObject.HitObjectApplied -= onHitObjectApplied;
parentObject.ApplyCustomUpdateState -= updateStateTransforms; parentObject.ApplyCustomUpdateState -= updateStateTransforms;
} }
} }
}
} }

View File

@ -107,7 +107,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
}); });
} }
private IBindable<double> gainedBonus = null!; private IBindable<int> completedSpins = null!;
private IBindable<double> spinsPerMinute = null!; private IBindable<double> spinsPerMinute = null!;
private readonly Bindable<bool> completed = new Bindable<bool>(); private readonly Bindable<bool> completed = new Bindable<bool>();
@ -116,12 +116,24 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{ {
base.LoadComplete(); base.LoadComplete();
gainedBonus = DrawableSpinner.GainedBonus.GetBoundCopy(); completedSpins = DrawableSpinner.CompletedFullSpins.GetBoundCopy();
gainedBonus.BindValueChanged(bonus => completedSpins.BindValueChanged(bonus =>
{
if (DrawableSpinner.CurrentBonusScore <= 0)
return;
bonusCounter.Text = DrawableSpinner.CurrentBonusScore.ToString(NumberFormatInfo.InvariantInfo);
if (DrawableSpinner.CurrentBonusScore == DrawableSpinner.MaximumBonusScore)
{
bonusCounter.ScaleTo(1.4f).Then().ScaleTo(1.8f, 1000, Easing.Out);
bonusCounter.FadeOutFromOne(500, Easing.Out);
}
else
{ {
bonusCounter.Text = bonus.NewValue.ToString(NumberFormatInfo.InvariantInfo);
bonusCounter.FadeOutFromOne(800, Easing.Out); bonusCounter.FadeOutFromOne(800, Easing.Out);
bonusCounter.ScaleTo(SPRITE_SCALE * 2f).Then().ScaleTo(SPRITE_SCALE * 1.28f, 800, Easing.Out); bonusCounter.ScaleTo(SPRITE_SCALE * 2f).Then().ScaleTo(SPRITE_SCALE * 1.28f, 800, Easing.Out);
}
}); });
spinsPerMinute = DrawableSpinner.SpinsPerMinute.GetBoundCopy(); spinsPerMinute = DrawableSpinner.SpinsPerMinute.GetBoundCopy();

View File

@ -59,13 +59,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
return null; return null;
case OsuSkinComponents.SliderBall: case OsuSkinComponents.SliderBall:
var sliderBallContent = this.GetAnimation("sliderb", true, true, animationSeparator: "", maxSize: MAX_FOLLOW_CIRCLE_AREA_SIZE); if (GetTexture("sliderb") != null || GetTexture("sliderb0") != null)
return new LegacySliderBall(this);
// todo: slider ball has a custom frame delay based on velocity
// Math.Max((150 / Velocity) * GameBase.SIXTY_FRAME_TIME, GameBase.SIXTY_FRAME_TIME);
if (sliderBallContent != null)
return new LegacySliderBall(sliderBallContent, this);
return null; return null;

View File

@ -13,6 +13,7 @@ using osu.Framework.Utils;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Sprites;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Scoring; using osu.Game.Scoring;
using osuTK; using osuTK;
@ -208,8 +209,7 @@ namespace osu.Game.Rulesets.Osu.Statistics
if (score.HitEvents.Count == 0) if (score.HitEvents.Count == 0)
return; return;
// Todo: This should probably not be done like this. float radius = OsuHitObject.OBJECT_RADIUS * LegacyRulesetExtensions.CalculateScaleFromCircleSize(playableBeatmap.Difficulty.CircleSize, true);
float radius = OsuHitObject.OBJECT_RADIUS * (1.0f - 0.7f * (playableBeatmap.Difficulty.CircleSize - 5) / 5) / 2;
foreach (var e in score.HitEvents.Where(e => e.HitObject is HitCircle && !(e.HitObject is SliderTailCircle))) foreach (var e in score.HitEvents.Where(e => e.HitObject is HitCircle && !(e.HitObject is SliderTailCircle)))
{ {

View File

@ -60,12 +60,12 @@ namespace osu.Game.Rulesets.Osu.UI
// game_size = DrawSizePreservingFillContainer.TargetSize = new Vector2(1024, 768) // game_size = DrawSizePreservingFillContainer.TargetSize = new Vector2(1024, 768)
// //
// Parent is a 4:3 aspect enforced, using height as the constricting dimension // Parent is a 4:3 aspect enforced, using height as the constricting dimension
// Parent.ChildSize.X = min(game_size.X, game_size.Y * (4 / 3)) * playfield_size_adjust // Parent!.ChildSize.X = min(game_size.X, game_size.Y * (4 / 3)) * playfield_size_adjust
// Parent.ChildSize.X = 819.2 // Parent!.ChildSize.X = 819.2
// //
// Scale = 819.2 / 512 // Scale = 819.2 / 512
// Scale = 1.6 // Scale = 1.6
Scale = new Vector2(Parent.ChildSize.X / OsuPlayfield.BASE_SIZE.X); Scale = new Vector2(Parent!.ChildSize.X / OsuPlayfield.BASE_SIZE.X);
Position = new Vector2(0, (PlayfieldShift ? 8f : 0f) * Scale.X); Position = new Vector2(0, (PlayfieldShift ? 8f : 0f) * Scale.X);
// Size = 0.625 // Size = 0.625
Size = Vector2.Divide(Vector2.One, Scale); Size = Vector2.Divide(Vector2.One, Scale);

View File

@ -30,8 +30,8 @@ namespace osu.Game.Rulesets.Taiko.Edit.Blueprints
var topLeft = new Vector2(float.MaxValue, float.MaxValue); var topLeft = new Vector2(float.MaxValue, float.MaxValue);
var bottomRight = new Vector2(float.MinValue, float.MinValue); var bottomRight = new Vector2(float.MinValue, float.MinValue);
topLeft = Vector2.ComponentMin(topLeft, Parent.ToLocalSpace(DrawableObject.ScreenSpaceDrawQuad.TopLeft)); topLeft = Vector2.ComponentMin(topLeft, Parent!.ToLocalSpace(DrawableObject.ScreenSpaceDrawQuad.TopLeft));
bottomRight = Vector2.ComponentMax(bottomRight, Parent.ToLocalSpace(DrawableObject.ScreenSpaceDrawQuad.BottomRight)); bottomRight = Vector2.ComponentMax(bottomRight, Parent!.ToLocalSpace(DrawableObject.ScreenSpaceDrawQuad.BottomRight));
Size = bottomRight - topLeft; Size = bottomRight - topLeft;
Position = topLeft; Position = topLeft;

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

@ -261,7 +261,7 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables
{ {
base.Update(); base.Update();
Size = BaseSize * Parent.RelativeChildSize; Size = BaseSize * Parent!.RelativeChildSize;
// Make the swell stop at the hit target // Make the swell stop at the hit target
X = Math.Max(0, X); X = Math.Max(0, X);

View File

@ -4,6 +4,7 @@
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Colour;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Shapes;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
@ -14,53 +15,54 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon
{ {
public partial class ArgonBarLine : CompositeDrawable public partial class ArgonBarLine : CompositeDrawable
{ {
private Container majorEdgeContainer = null!;
private Bindable<bool> major = null!; private Bindable<bool> major = null!;
private Box mainLine = null!;
private Drawable topAnchor = null!;
private Drawable bottomAnchor = null!;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(DrawableHitObject drawableHitObject) private void load(DrawableHitObject drawableHitObject)
{ {
RelativeSizeAxes = Axes.Both; RelativeSizeAxes = Axes.Both;
const float line_offset = 8; // Avoid flickering due to no anti-aliasing of boxes by default.
var majorPieceSize = new Vector2(6, 20); var edgeSmoothness = new Vector2(0.3f);
InternalChildren = new Drawable[] AddInternal(mainLine = new Box
{
line = new Box
{
RelativeSizeAxes = Axes.Both,
EdgeSmoothness = new Vector2(0.5f, 0),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
},
majorEdgeContainer = new Container
{ {
Name = "Bar line",
EdgeSmoothness = edgeSmoothness,
Anchor = Anchor.Centre, Anchor = Anchor.Centre,
Origin = Anchor.Centre, Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Children = new[] });
const float major_extension = 10;
AddInternal(topAnchor = new Box
{ {
new Circle Name = "Top anchor",
{ EdgeSmoothness = edgeSmoothness,
Name = "Top line", Blending = BlendingParameters.Additive,
Anchor = Anchor.TopCentre, Anchor = Anchor.TopCentre,
Origin = Anchor.BottomCentre, Origin = Anchor.BottomCentre,
Size = majorPieceSize, Height = major_extension,
Y = -line_offset, RelativeSizeAxes = Axes.X,
}, Colour = ColourInfo.GradientVertical(Colour4.Transparent, Colour4.White),
new Circle });
AddInternal(bottomAnchor = new Box
{ {
Name = "Bottom line", Name = "Bottom anchor",
EdgeSmoothness = edgeSmoothness,
Blending = BlendingParameters.Additive,
Anchor = Anchor.BottomCentre, Anchor = Anchor.BottomCentre,
Origin = Anchor.TopCentre, Origin = Anchor.TopCentre,
Size = majorPieceSize, Height = major_extension,
Y = line_offset, RelativeSizeAxes = Axes.X,
}, Colour = ColourInfo.GradientVertical(Colour4.White, Colour4.Transparent),
} });
}
};
major = ((DrawableBarLine)drawableHitObject).Major.GetBoundCopy(); major = ((DrawableBarLine)drawableHitObject).Major.GetBoundCopy();
} }
@ -71,13 +73,10 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon
major.BindValueChanged(updateMajor, true); major.BindValueChanged(updateMajor, true);
} }
private Box line = null!;
private void updateMajor(ValueChangedEvent<bool> major) private void updateMajor(ValueChangedEvent<bool> major)
{ {
line.Alpha = major.NewValue ? 1f : 0.5f; mainLine.Alpha = major.NewValue ? 1f : 0.5f;
line.Width = major.NewValue ? 1 : 0.5f; topAnchor.Alpha = bottomAnchor.Alpha = major.NewValue ? mainLine.Alpha * 0.3f : 0;
majorEdgeContainer.Alpha = major.NewValue ? 1 : 0;
} }
} }
} }

View File

@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon
protected override void Update() protected override void Update()
{ {
base.Update(); base.Update();
Width = Parent.DrawSize.X + DrawHeight; Width = Parent!.DrawSize.X + DrawHeight;
} }
} }
} }

View File

@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Default
protected override void Update() protected override void Update()
{ {
base.Update(); base.Update();
Width = Parent.DrawSize.X + DrawHeight; Width = Parent!.DrawSize.X + DrawHeight;
} }
} }
} }

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

@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Taiko.UI
// This is still a bit weird, because readability changes with window size, but it is what it is. // This is still a bit weird, because readability changes with window size, but it is what it is.
if (LockPlayfieldAspectRange.Value) if (LockPlayfieldAspectRange.Value)
{ {
float currentAspect = Parent.ChildSize.X / Parent.ChildSize.Y; float currentAspect = Parent!.ChildSize.X / Parent!.ChildSize.Y;
if (currentAspect > MAXIMUM_ASPECT) if (currentAspect > MAXIMUM_ASPECT)
height *= currentAspect / MAXIMUM_ASPECT; height *= currentAspect / MAXIMUM_ASPECT;

View File

@ -1080,5 +1080,18 @@ namespace osu.Game.Tests.Beatmaps.Formats
Assert.That(controlPoints.DifficultyPointAt(3000).GenerateTicks, Is.True); Assert.That(controlPoints.DifficultyPointAt(3000).GenerateTicks, Is.True);
} }
} }
[Test]
public void TestSamplePointLeniency()
{
var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };
using (var resStream = TestResources.OpenResource("sample-point-leniency.osu"))
using (var stream = new LineBufferedReader(resStream))
{
var hitObject = decoder.Decode(stream).HitObjects.Single();
Assert.That(hitObject.Samples.Select(s => s.Volume), Has.All.EqualTo(70));
}
}
} }
} }

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]

Some files were not shown because too many files have changed in this diff Show More