1
0
mirror of https://github.com/ppy/osu.git synced 2024-11-11 08:27:49 +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

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

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)
{
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)

View File

@ -54,7 +54,7 @@ namespace osu.Game.Rulesets.Catch
new KeyBinding(InputKey.X, CatchAction.MoveRight),
new KeyBinding(InputKey.Right, CatchAction.MoveRight),
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)

View File

@ -1,180 +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;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Caching;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Game.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Catch.UI;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Screens.Edit;
using osuTK.Graphics;
using osu.Game.Screens.Edit.Compose.Components;
namespace osu.Game.Rulesets.Catch.Edit
{
/// <summary>
/// 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
public partial class CatchBeatSnapGrid : BeatSnapGrid
{
private const double visible_range = 750;
/// <summary>
/// The range of time values of the current selection.
/// </summary>
public (double start, double end)? SelectionTimeRange
protected override IEnumerable<Container> GetTargetContainers(HitObjectComposer composer) => new[]
{
set
{
if (value == selectionTimeRange)
return;
selectionTimeRange = value;
lineCache.Invalidate();
}
}
[Resolved]
private EditorBeatmap beatmap { get; set; } = null!;
[Resolved]
private OsuColour colours { get; set; } = null!;
[Resolved]
private BindableBeatDivisor beatDivisor { get; set; } = null!;
private readonly Cached lineCache = new Cached();
private (double start, double end)? selectionTimeRange;
private ScrollingHitObjectContainer lineContainer = null!;
[BackgroundDependencyLoader]
private void load(HitObjectComposer composer)
{
lineContainer = new ScrollingHitObjectContainer();
((CatchPlayfield)composer.Playfield).UnderlayElements.Add(lineContainer);
beatDivisor.BindValueChanged(_ => createLines(), true);
}
protected override void Update()
{
base.Update();
if (!lineCache.IsValid)
{
lineCache.Validate();
createLines();
}
}
private readonly Stack<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;
}
}
((CatchPlayfield)composer.Playfield).UnderlayElements
};
}
}

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();
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;
}
}

View File

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

View File

@ -1,7 +1,6 @@
// 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.Framework.Graphics;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Localisation;
using osu.Game.Rulesets.Catch.Objects;
@ -21,10 +20,8 @@ namespace osu.Game.Rulesets.Catch.Mods
public void ApplyToDrawableRuleset(DrawableRuleset<CatchHitObject> drawableRuleset)
{
drawableRuleset.PlayfieldAdjustmentContainer.Anchor = Anchor.Centre;
drawableRuleset.PlayfieldAdjustmentContainer.Origin = Anchor.Centre;
drawableRuleset.PlayfieldAdjustmentContainer.Scale = new Vector2(1, -1);
drawableRuleset.PlayfieldAdjustmentContainer.Y = 1 - drawableRuleset.PlayfieldAdjustmentContainer.Y;
}
}
}

View File

@ -8,6 +8,7 @@ using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Catch.UI;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Scoring;
using osuTK;
@ -151,7 +152,7 @@ namespace osu.Game.Rulesets.Catch.Objects
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;

View File

@ -17,6 +17,7 @@ using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.Objects.Drawables;
using osu.Game.Rulesets.Catch.Skinning;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Skinning;
using osuTK;
using osuTK.Graphics;
@ -182,11 +183,6 @@ namespace osu.Game.Rulesets.Catch.UI
/// </summary>
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>
/// Calculates the width of the area used for attempting catches in gameplay.
/// </summary>
@ -471,6 +467,11 @@ namespace osu.Game.Rulesets.Catch.UI
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
{
Drop,

View File

@ -17,12 +17,16 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
{
private const double offset = 18;
protected override bool AllowFail => true;
protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset();
[Test]
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,
Beatmap = new Beatmap
{
@ -40,24 +44,31 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods
});
[Test]
public void TestHitWindowWithDoubleTime() => CreateModTest(new ModTestData
public void TestHitWindowWithDoubleTime()
{
Mod = new ManiaModDoubleTime(),
PassCondition = () => Player.ScoreProcessor.JudgedHits > 0 && Player.ScoreProcessor.Accuracy.Value == 1,
Autoplay = false,
Beatmap = new Beatmap
var doubleTime = new ManiaModDoubleTime();
CreateModTest(new ModTestData
{
BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo },
Difficulty = { OverallDifficulty = 10 },
HitObjects = new List<HitObject>
Mod = doubleTime,
PassCondition = () => Player.ScoreProcessor.JudgedHits > 0
&& Player.ScoreProcessor.Accuracy.Value == 1
&& Player.ScoreProcessor.TotalScore.Value == (long)(1_000_010 * doubleTime.ScoreMultiplier),
Autoplay = false,
Beatmap = new Beatmap
{
new Note { StartTime = 1000 }
BeatmapInfo = { Ruleset = new ManiaRuleset().RulesetInfo },
Difficulty = { OverallDifficulty = 10 },
HitObjects = new List<HitObject>
{
new Note { StartTime = 1000 }
},
},
},
ReplayFrames = new List<ReplayFrame>
{
new ManiaReplayFrame(1000 + offset, ManiaAction.Key1)
}
});
ReplayFrames = new List<ReplayFrame>
{
new ManiaReplayFrame(1000 + offset, ManiaAction.Key1)
}
});
}
}
}

View File

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

View File

@ -54,7 +54,7 @@ namespace osu.Game.Rulesets.Mania.Tests
AddAssert("all objects perfectly judged",
() => judgementResults.Select(result => result.Type),
() => 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]
@ -87,7 +87,7 @@ namespace osu.Game.Rulesets.Mania.Tests
AddAssert("all objects perfectly judged",
() => judgementResults.Select(result => result.Type),
() => 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)

View File

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

View File

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

View File

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

View File

@ -5,15 +5,12 @@
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Input;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.UI;
using osu.Game.Rulesets.UI.Scrolling;
using osu.Game.Screens.Edit.Compose.Components;
@ -24,32 +21,12 @@ namespace osu.Game.Rulesets.Mania.Edit
public partial class ManiaHitObjectComposer : ScrollingHitObjectComposer<ManiaHitObject>
{
private DrawableManiaEditorRuleset drawableRuleset;
private ManiaBeatSnapGrid beatSnapGrid;
private InputManager inputManager;
public ManiaHitObjectComposer(Ruleset ruleset)
: base(ruleset)
{
}
[BackgroundDependencyLoader]
private void load()
{
AddInternal(beatSnapGrid = new ManiaBeatSnapGrid());
}
protected override void LoadComplete()
{
base.LoadComplete();
inputManager = GetContainingInputManager();
}
private DependencyContainer dependencies;
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
=> dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
public new ManiaPlayfield Playfield => ((ManiaPlayfield)drawableRuleset.Playfield);
public IScrollingInfo ScrollingInfo => drawableRuleset.ScrollingInfo;
@ -57,48 +34,20 @@ namespace osu.Game.Rulesets.Mania.Edit
protected override Playfield PlayfieldAtScreenSpacePosition(Vector2 screenSpacePosition) =>
Playfield.GetColumnByPosition(screenSpacePosition);
protected override DrawableRuleset<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);
// This is the earliest we can cache the scrolling info to ourselves, before masks are added to the hierarchy and inject it
dependencies.CacheAs(drawableRuleset.ScrollingInfo);
return drawableRuleset;
}
protected override ComposeBlueprintContainer CreateBlueprintContainer()
=> new ManiaBlueprintContainer(this);
protected override BeatSnapGrid CreateBeatSnapGrid() => new ManiaBeatSnapGrid();
protected override IReadOnlyList<HitObjectCompositionTool> CompositionTools => new HitObjectCompositionTool[]
{
new NoteCompositionTool(),
new HoldNoteCompositionTool()
};
protected override void UpdateAfterChildren()
{
base.UpdateAfterChildren();
if (BlueprintContainer.CurrentTool is SelectTool)
{
if (EditorBeatmap.SelectedHitObjects.Any())
{
beatSnapGrid.SelectionTimeRange = (EditorBeatmap.SelectedHitObjects.Min(h => h.StartTime), EditorBeatmap.SelectedHitObjects.Max(h => h.GetEndTime()));
}
else
beatSnapGrid.SelectionTimeRange = null;
}
else
{
var result = FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position);
if (result.Time is double time)
beatSnapGrid.SelectionTimeRange = (time, time);
else
beatSnapGrid.SelectionTimeRange = null;
}
}
public override string ConvertSelectionToString()
=> string.Join(',', EditorBeatmap.SelectedHitObjects.Cast<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.Ok,
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))
{
HitObjectContainer hoc = column.HitObjectArea.HitObjectContainer;
Container hocParent = (Container)hoc.Parent;
Container hocParent = (Container)hoc.Parent!;
hocParent.Remove(hoc, false);
hocParent.Add(new PlayfieldCoveringWrapper(hoc).With(c =>

View File

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

View File

@ -3,7 +3,6 @@
#nullable disable
using System.Diagnostics;
using osu.Framework.Graphics;
using osu.Framework.Input.Events;
using osu.Game.Rulesets.Scoring;
@ -33,35 +32,19 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
public void UpdateResult() => base.UpdateResult(true);
protected override void CheckForResult(bool userTriggered, double timeOffset)
{
Debug.Assert(HitObject.HitWindows != null);
protected override void CheckForResult(bool userTriggered, double timeOffset) =>
// Factor in the release lenience
timeOffset /= TailNote.RELEASE_WINDOW_LENIENCE;
base.CheckForResult(userTriggered, timeOffset / TailNote.RELEASE_WINDOW_LENIENCE);
if (!userTriggered)
{
if (!HitObject.HitWindows.CanBeHit(timeOffset))
ApplyResult(r => r.Type = r.Judgement.MinResult);
protected override HitResult GetCappedResult(HitResult result)
{
// 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;
return;
}
if (result > HitResult.Meh && hasComboBreak)
return HitResult.Meh;
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.
bool hasComboBreak = !HoldNote.Head.IsHit || HoldNote.Body.HasHoldBreak;
if (result > HitResult.Meh && hasComboBreak)
result = HitResult.Meh;
r.Type = result;
});
return result;
}
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.Rulesets.Mania.Configuration;
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.UI.Scrolling;
using osu.Game.Screens.Edit;
@ -38,6 +40,8 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
private Drawable headPiece;
private DrawableNotePerfectBonus perfectBonus;
public DrawableNote()
: this(null)
{
@ -89,7 +93,11 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
if (!userTriggered)
{
if (!HitObject.HitWindows.CanBeHit(timeOffset))
{
perfectBonus.TriggerResult(false);
ApplyResult(r => r.Type = r.Judgement.MinResult);
}
return;
}
@ -97,9 +105,23 @@ namespace osu.Game.Rulesets.Mania.Objects.Drawables
if (result == HitResult.None)
return;
result = GetCappedResult(result);
perfectBonus.TriggerResult(result == HitResult.Perfect);
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)
{
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()
{
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.
// See the LICENCE file in the repository root for full licence text.
using System.Threading;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Judgements;
@ -12,5 +13,12 @@ namespace osu.Game.Rulesets.Mania.Objects
public class Note : ManiaHitObject
{
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)
{
// ensure that the hold note is also faded out when the head/tail/any tick is missed.
if (state == ArmedState.Miss)
missFadeTime.Value ??= hitObject.HitStateUpdateTime;
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)
missFadeTime.Value ??= hitObject.HitStateUpdateTime;
break;
}
}
private void onIsHittingChanged(ValueChangedEvent<bool> isHitting)

View File

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

View File

@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
});
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();
@ -198,7 +198,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
});
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();
AddAssert("circles not merged", () => circle1 is not null && circle2 is not null
&& EditorBeatmap.HitObjects.Contains(circle1) && EditorBeatmap.HitObjects.Contains(circle2));
@ -222,7 +222,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
});
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();

View File

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

View File

@ -169,7 +169,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
AddStep($"move mouse to control point {index}", () =>
{
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}", () =>
{
Vector2 position = slider.Position + relativePosition;
InputManager.MoveMouseTo(drawableObject.Parent.ToScreenSpace(position));
InputManager.MoveMouseTo(drawableObject.Parent!.ToScreenSpace(position));
});
[Test]
@ -331,7 +331,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
AddStep($"move mouse to {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}", () =>
{
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}", () =>
{
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 pos = slider.Path.PositionAt(0.25d) + slider.Position;
InputManager.MoveMouseTo(firstPiece.Parent.ToScreenSpace(pos));
InputManager.MoveMouseTo(firstPiece.Parent!.ToScreenSpace(pos));
});
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;
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;
MenuItem? item = visualiser.ContextMenuItems.FirstOrDefault(menuItem => menuItem.Text.Value == contextMenuText);
MenuItem? item = visualiser.ContextMenuItems?.FirstOrDefault(menuItem => menuItem.Text.Value == contextMenuText);
item?.Action.Value?.Invoke();
});

View File

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

View File

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Utils;
using osu.Game.Rulesets.Objects;
@ -33,8 +34,21 @@ namespace osu.Game.Rulesets.Osu.Tests
switch (hitObject)
{
case Slider slider:
var objects = new List<ConvertValue>();
foreach (var nested in slider.NestedHitObjects)
yield return createConvertValue((OsuHitObject)nested);
objects.Add(createConvertValue((OsuHitObject)nested, slider));
// stable does slider tail leniency by offsetting the last tick 36ms back.
// based on player feedback, we're doing this a little different in lazer,
// and the lazer method does not require offsetting the last tick
// (see `DrawableSliderTail.CheckForResult()`).
// however, in conversion tests, just so the output matches, we're bringing
// the 36ms offset back locally.
// in particular, on some sliders, this may rearrange nested objects,
// so we sort them again by start time to prevent test failures.
foreach (var obj in objects.OrderBy(cv => cv.StartTime))
yield return obj;
break;
@ -44,13 +58,29 @@ namespace osu.Game.Rulesets.Osu.Tests
break;
}
static ConvertValue createConvertValue(OsuHitObject obj) => new ConvertValue
static ConvertValue createConvertValue(OsuHitObject obj, OsuHitObject? parent = null)
{
StartTime = obj.StartTime,
EndTime = obj.GetEndTime(),
X = obj.StackedPosition.X,
Y = obj.StackedPosition.Y
};
double startTime = obj.StartTime;
double endTime = obj.GetEndTime();
// as stated in the inline comment above, this is locally bringing back
// the stable treatment of the "legacy last tick" just to make sure
// that the conversion output matches.
// compare: `SliderEventGenerator.Generate()`, and the calculation of `legacyLastTickTime`.
if (obj is SliderTailCircle && parent is Slider slider)
{
startTime = Math.Max(startTime + SliderEventGenerator.TAIL_LENIENCY, slider.StartTime + slider.Duration / 2);
endTime = Math.Max(endTime + SliderEventGenerator.TAIL_LENIENCY, slider.StartTime + slider.Duration / 2);
}
return new ConvertValue
{
StartTime = startTime,
EndTime = endTime,
X = obj.StackedPosition.X,
Y = obj.StackedPosition.Y
};
}
}
protected override Ruleset CreateRuleset() => new OsuRuleset();

View File

@ -15,19 +15,22 @@ namespace osu.Game.Rulesets.Osu.Tests
{
protected override string ResourceAssembly => "osu.Game.Rulesets.Osu";
[TestCase(6.7115569159190587d, 206, "diffcalc-test")]
[TestCase(1.4391311903612753d, 45, "zero-length-sliders")]
[TestCase(6.710442985146793d, 206, "diffcalc-test")]
[TestCase(1.4386882251130073d, 45, "zero-length-sliders")]
[TestCase(0.42506480230838789d, 2, "very-fast-slider")]
[TestCase(0.14102693012101306d, 1, "nan-slider")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name);
[TestCase(8.9757300665532966d, 206, "diffcalc-test")]
[TestCase(1.7437232654020756d, 45, "zero-length-sliders")]
[TestCase(8.9742952703071666d, 206, "diffcalc-test")]
[TestCase(0.55071082800473514d, 2, "very-fast-slider")]
[TestCase(1.743180218215227d, 45, "zero-length-sliders")]
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime());
[TestCase(6.7115569159190587d, 239, "diffcalc-test")]
[TestCase(1.4391311903612753d, 54, "zero-length-sliders")]
[TestCase(6.710442985146793d, 239, "diffcalc-test")]
[TestCase(0.42506480230838789d, 4, "very-fast-slider")]
[TestCase(1.4386882251130073d, 54, "zero-length-sliders")]
public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name)
=> 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,
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];
// 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

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 + 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 } },
new OsuReplayFrame { Time = time_spinner + 40, Position = new Vector2(256, 212), Actions = { OsuAction.RightButton } },
new OsuReplayFrame { Time = time_spinner + 50, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } },
});
};
frames.AddRange(new SpinFramesGenerator(time_spinner + 10)
.Spin(360, 500)
.Build());
performTest(hitObjects, frames);
addJudgementAssert(hitObjects[0], HitResult.Great);
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.Types;
using osu.Game.Rulesets.Osu.Configuration;
using osu.Game.Rulesets.Osu.Mods;
namespace osu.Game.Rulesets.Osu.Tests
{
@ -50,6 +51,8 @@ namespace osu.Game.Rulesets.Osu.Tests
snakingOut.Value = !v;
});
AddToggleStep("toggle hidden", hiddenActive => SelectedMods.Value = hiddenActive ? new[] { new OsuModHidden() } : Array.Empty<Mod>());
AddSliderStep("hit at", 0f, 1f, 0f, v =>
{
progressToHit = v;

View File

@ -10,6 +10,7 @@ using osu.Game.Beatmaps;
using osu.Game.Replays;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Replays;
@ -35,7 +36,7 @@ namespace osu.Game.Rulesets.Osu.Tests
{
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;
performTest(new Beatmap<OsuHitObject>

View File

@ -1,13 +1,12 @@
// 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.
#nullable disable
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Screens;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Replays;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
@ -33,7 +32,100 @@ namespace osu.Game.Rulesets.Osu.Tests
private const double time_during_slide_4 = 3800;
private const double time_slider_end = 4000;
private List<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]
public void TestPressBothKeysSimultaneouslyAndReleaseOne()
@ -44,7 +136,7 @@ namespace osu.Game.Rulesets.Osu.Tests
new OsuReplayFrame { Position = Vector2.Zero, Actions = { OsuAction.RightButton }, Time = time_during_slide_1 },
});
AddAssert("Tracking retained", assertMaxJudge);
assertAllMaxJudgements();
}
/// <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 },
});
AddAssert("Tracking retained", assertMaxJudge);
assertAllMaxJudgements();
}
/// <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 },
});
AddAssert("Tracking retained", assertMaxJudge);
assertAllMaxJudgements();
}
/// <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 },
});
AddAssert("Tracking retained", assertMaxJudge);
assertAllMaxJudgements();
}
/// <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 },
});
AddAssert("Tracking kept", assertMaxJudge);
assertAllMaxJudgements();
}
/// <summary>
@ -325,7 +417,13 @@ namespace osu.Game.Rulesets.Osu.Tests
AddAssert("Tracking dropped", assertMidSliderJudgementFail);
}
private bool assertMaxJudge() => judgementResults.Any() && judgementResults.All(t => t.Type == t.Judgement.MaxResult);
private void assertAllMaxJudgements()
{
AddAssert("All judgements max", () =>
{
return judgementResults.Select(j => (j.HitObject, j.Type));
}, () => Is.EqualTo(judgementResults.Select(j => (j.HitObject, j.Judgement.MaxResult))));
}
private bool assertHeadMissTailTracked() => judgementResults[^2].Type == HitResult.SmallTickHit && !judgementResults.First().IsHit;
@ -333,35 +431,36 @@ namespace osu.Game.Rulesets.Osu.Tests
private bool assertMidSliderJudgementFail() => judgementResults[^2].Type == HitResult.SmallTickMiss;
private ScoreAccessibleReplayPlayer currentPlayer;
private const float slider_path_length = 25;
private void performTest(List<ReplayFrame> frames)
private void performTest(List<ReplayFrame> frames, Slider? slider = null, double? bpm = null, int? tickRate = null)
{
slider ??= new Slider
{
StartTime = time_slider_start,
Position = new Vector2(0, 0),
SliderVelocityMultiplier = 0.1f,
Path = new SliderPath(PathType.PerfectCurve, new[]
{
Vector2.Zero,
new Vector2(slider_path_length, 0),
}, slider_path_length),
};
AddStep("load player", () =>
{
var cpi = new ControlPointInfo();
if (bpm != null)
cpi.Add(0, new TimingControlPoint { BeatLength = 60000 / bpm.Value });
Beatmap.Value = CreateWorkingBeatmap(new Beatmap<OsuHitObject>
{
HitObjects =
{
new Slider
{
StartTime = time_slider_start,
Position = new Vector2(0, 0),
SliderVelocityMultiplier = 0.1f,
Path = new SliderPath(PathType.PerfectCurve, new[]
{
Vector2.Zero,
new Vector2(slider_path_length, 0),
}, slider_path_length),
}
},
HitObjects = { slider },
BeatmapInfo =
{
Difficulty = new BeatmapDifficulty { SliderTickRate = 3 },
Ruleset = new OsuRuleset().RulesetInfo
Difficulty = new BeatmapDifficulty { SliderTickRate = tickRate ?? 3 },
Ruleset = new OsuRuleset().RulesetInfo,
},
ControlPointInfo = cpi,
});
var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } });
@ -375,7 +474,7 @@ namespace osu.Game.Rulesets.Osu.Tests
};
LoadScreen(currentPlayer = p);
judgementResults = new List<JudgementResult>();
judgementResults.Clear();
});
AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0);

View File

@ -6,6 +6,7 @@
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Game.Audio;
@ -26,6 +27,15 @@ namespace osu.Game.Rulesets.Osu.Tests
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(false)]
public void TestVariousSpinners(bool autoplay)
@ -86,7 +96,7 @@ namespace osu.Game.Rulesets.Osu.Tests
spinner.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { OverallDifficulty = od });
return drawableSpinner = new TestDrawableSpinner(spinner, true)
return drawableSpinner = new TestDrawableSpinner(spinner, true, spinRate)
{
Anchor = Anchor.Centre,
Depth = depthIndex++,
@ -114,7 +124,7 @@ namespace osu.Game.Rulesets.Osu.Tests
spinner.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty { CircleSize = circleSize });
drawableSpinner = new TestDrawableSpinner(spinner, auto)
drawableSpinner = new TestDrawableSpinner(spinner, auto, spinRate)
{
Anchor = Anchor.Centre,
Depth = depthIndex++,
@ -130,18 +140,20 @@ namespace osu.Game.Rulesets.Osu.Tests
private partial class TestDrawableSpinner : DrawableSpinner
{
private readonly bool auto;
private readonly BindableDouble spinRate;
public TestDrawableSpinner(Spinner s, bool auto)
public TestDrawableSpinner(Spinner s, bool auto, BindableDouble spinRate)
: base(s)
{
this.auto = auto;
this.spinRate = spinRate;
}
protected override void Update()
{
base.Update();
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));
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
{
@ -41,7 +41,7 @@ namespace osu.Game.Rulesets.Osu.Tests
Duration = 1000,
})));
AddAssert("rotation is reset", () => dho.Result.RateAdjustedRotation == 0);
AddAssert("rotation is reset", () => dho.Result.TotalRotation == 0);
}
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.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
@ -11,14 +10,12 @@ using osu.Game.Beatmaps;
using osu.Game.Replays;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
using osu.Game.Tests.Visual;
using osuTK;
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));
}
private static List<ReplayFrame> generateReplay(int spins)
{
var replayFrames = new List<ReplayFrame>();
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 static List<ReplayFrame> generateReplay(int spins) => new SpinFramesGenerator(time_spinner_start)
.Spin(spins * 360, time_spinner_end - time_spinner_start)
.Build();
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);
});
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);
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]
@ -82,7 +82,7 @@ namespace osu.Game.Rulesets.Osu.Tests
finalTrackerRotation = drawableSpinner.RotationTracker.Rotation;
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);
AddAssert("disc rotation rewound",
@ -92,13 +92,13 @@ namespace osu.Game.Rulesets.Osu.Tests
() => drawableSpinner.RotationTracker.Rotation, () => Is.EqualTo(finalTrackerRotation / 2).Within(trackerRotationTolerance));
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.
() => drawableSpinner.Result.RateAdjustedRotation, () => Is.EqualTo(finalCumulativeTrackerRotation / 2).Within(100));
() => drawableSpinner.Result.TotalRotation, () => Is.EqualTo(finalCumulativeTrackerRotation / 2).Within(100));
addSeekStep(spinner_start_time + 5000);
AddAssert("is disc rotation almost same",
() => drawableSpinner.RotationTracker.Rotation, () => Is.EqualTo(finalTrackerRotation).Within(trackerRotationTolerance));
AddAssert("is cumulative rotation almost same",
() => drawableSpinner.Result.RateAdjustedRotation, () => Is.EqualTo(finalCumulativeTrackerRotation).Within(100));
() => drawableSpinner.Result.TotalRotation, () => Is.EqualTo(finalCumulativeTrackerRotation).Within(100));
}
[Test]
@ -135,7 +135,7 @@ namespace osu.Game.Rulesets.Osu.Tests
{
// multipled by 2 to nullify the score multiplier. (autoplay mod selected)
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);

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 + 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 } },
new OsuReplayFrame { Time = time_spinner + 40, Position = new Vector2(256, 212), Actions = { OsuAction.RightButton } },
new OsuReplayFrame { Time = time_spinner + 50, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } },
});
};
frames.AddRange(new SpinFramesGenerator(time_spinner + 10)
.Spin(360, 500)
.Build());
performTest(hitObjects, frames);
addJudgementAssert(hitObjects[0], 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>
/// </list>
/// </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)
return 0;
@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.StrainTime;
// But if the last object is a slider, then we extend the travel velocity through the slider into the current object.
if (osuLastObj.BaseObject is Slider && withSliders)
if (osuLastObj.BaseObject is Slider && withSliderTravelDistance)
{
double travelVelocity = osuLastObj.TravelDistance / osuLastObj.TravelTime; // calculate the slider velocity from slider head to slider end.
double movementVelocity = osuCurrObj.MinimumJumpDistance / osuCurrObj.MinimumJumpTime; // calculate the movement velocity from slider end to current object
@ -48,7 +48,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
// As above, do the same for the previous hitobject.
double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.StrainTime;
if (osuLastLastObj.BaseObject is Slider && withSliders)
if (osuLastLastObj.BaseObject is Slider && withSliderTravelDistance)
{
double travelVelocity = osuLastLastObj.TravelDistance / osuLastLastObj.TravelTime;
double movementVelocity = osuLastObj.MinimumJumpDistance / osuLastObj.MinimumJumpTime;
@ -122,7 +122,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier + velocityChangeBonus * velocity_change_multiplier);
// Add in additional slider velocity bonus.
if (withSliders)
if (withSliderTravelDistance)
aimStrain += sliderBonus * slider_multiplier;
return aimStrain;

View File

@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Mods;
@ -214,7 +215,45 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
if (slider.LazyEndPosition != null)
return;
slider.LazyTravelTime = slider.NestedHitObjects[^1].StartTime - slider.StartTime;
// TODO: This commented version is actually correct by the new lazer implementation, but intentionally held back from
// difficulty calculator to preserve known behaviour.
// double trackingEndTime = Math.Max(
// // SliderTailCircle always occurs at the final end time of the slider, but the player only needs to hold until within a lenience before it.
// slider.Duration + SliderEventGenerator.TAIL_LENIENCY,
// // There's an edge case where one or more ticks/repeats fall within that leniency range.
// // In such a case, the player needs to track until the final tick or repeat.
// slider.NestedHitObjects.LastOrDefault(n => n is not SliderTailCircle)?.StartTime ?? double.MinValue
// );
double trackingEndTime = Math.Max(
slider.StartTime + slider.Duration + SliderEventGenerator.TAIL_LENIENCY,
slider.StartTime + slider.Duration / 2
);
IList<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;
if (endTimeMin % 2 >= 1)
@ -223,12 +262,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
endTimeMin %= 1;
slider.LazyEndPosition = slider.StackedPosition + slider.Path.PositionAt(endTimeMin); // temporary lazy end position until a real result can be derived.
var currCursorPosition = slider.StackedPosition;
Vector2 currCursorPosition = slider.StackedPosition;
double scalingFactor = NORMALISED_RADIUS / slider.Radius; // lazySliderDistance is coded to be sensitive to scaling, this makes the maths easier with the thresholds being used.
for (int i = 1; i < slider.NestedHitObjects.Count; i++)
for (int i = 1; i < nestedObjects.Count; i++)
{
var currMovementObj = (OsuHitObject)slider.NestedHitObjects[i];
var currMovementObj = (OsuHitObject)nestedObjects[i];
Vector2 currMovement = Vector2.Subtract(currMovementObj.StackedPosition, currCursorPosition);
double currMovementLength = scalingFactor * currMovement.Length;
@ -236,7 +277,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
// Amount of movement required so that the cursor position needs to be updated.
double requiredMovement = assumed_slider_radius;
if (i == slider.NestedHitObjects.Count - 1)
if (i == nestedObjects.Count - 1)
{
// The end of a slider has special aim rules due to the relaxed time constraint on position.
// There is both a lazy end position as well as the actual end slider position. We assume the player takes the simpler movement.
@ -263,7 +304,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
slider.LazyTravelDistance += (float)currMovementLength;
}
if (i == slider.NestedHitObjects.Count - 1)
if (i == nestedObjects.Count - 1)
slider.LazyEndPosition = currCursorPosition;
}
}

View File

@ -47,7 +47,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
public Action<List<PathControlPoint>> SplitControlPointsRequested;
[Resolved(CanBeNull = true)]
private IDistanceSnapProvider snapProvider { get; set; }
private IPositionSnapProvider positionSnapProvider { get; set; }
[Resolved(CanBeNull = true)]
private IDistanceSnapProvider distanceSnapProvider { get; set; }
public PathControlPointVisualiser(T hitObject, bool allowSelection)
{
@ -288,10 +291,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
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
Vector2 newHeadPosition = Parent.ToScreenSpace(e.MousePosition + (dragStartPositions[0] - dragStartPositions[draggedControlPointIndex]));
var result = snapProvider?.FindSnappedPositionAndTime(newHeadPosition);
Vector2 newHeadPosition = Parent!.ToScreenSpace(e.MousePosition + (dragStartPositions[0] - dragStartPositions[draggedControlPointIndex]));
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.StartTime = result?.Time ?? hitObject.StartTime;
@ -309,9 +312,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
}
else
{
var result = snapProvider?.FindSnappedPositionAndTime(Parent.ToScreenSpace(e.MousePosition), SnapType.GlobalGrids);
var result = positionSnapProvider?.FindSnappedPositionAndTime(Parent!.ToScreenSpace(e.MousePosition), SnapType.GlobalGrids);
Vector2 movementDelta = Parent.ToLocalSpace(result?.ScreenSpacePosition ?? Parent.ToScreenSpace(e.MousePosition)) - dragStartPositions[draggedControlPointIndex] - hitObject.Position;
Vector2 movementDelta = Parent!.ToLocalSpace(result?.ScreenSpacePosition ?? Parent!.ToScreenSpace(e.MousePosition)) - dragStartPositions[draggedControlPointIndex] - hitObject.Position;
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.
hitObject.SnapTo(snapProvider);
hitObject.SnapTo(distanceSnapProvider);
if (!hitObject.Path.HasValidLength)
{
@ -332,7 +335,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
hitObject.Position = oldPosition;
hitObject.StartTime = oldStartTime;
// Snap the path length again to undo the invalid length.
hitObject.SnapTo(snapProvider);
hitObject.SnapTo(distanceSnapProvider);
return;
}

View File

@ -39,7 +39,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
private int currentSegmentLength;
[Resolved(CanBeNull = true)]
private IDistanceSnapProvider snapProvider { get; set; }
private IPositionSnapProvider positionSnapProvider { get; set; }
[Resolved(CanBeNull = true)]
private IDistanceSnapProvider distanceSnapProvider { get; set; }
protected override bool IsValidForPlacement => HitObject.Path.HasValidLength;
@ -198,7 +201,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
}
// Update the cursor position.
var result = snapProvider?.FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position, state == SliderPlacementState.Body ? SnapType.GlobalGrids : SnapType.All);
var result = positionSnapProvider?.FindSnappedPositionAndTime(inputManager.CurrentState.Mouse.Position, state == SliderPlacementState.Body ? SnapType.GlobalGrids : SnapType.All);
cursor.Position = ToLocalSpace(result?.ScreenSpacePosition ?? inputManager.CurrentState.Mouse.Position) - HitObject.Position;
}
else if (cursor != null)
@ -230,7 +233,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
private void updateSlider()
{
HitObject.Path.ExpectedDistance.Value = snapProvider?.FindSnappedDistance(HitObject, (float)HitObject.Path.CalculatedDistance) ?? (float)HitObject.Path.CalculatedDistance;
HitObject.Path.ExpectedDistance.Value = distanceSnapProvider?.FindSnappedDistance(HitObject, (float)HitObject.Path.CalculatedDistance) ?? (float)HitObject.Path.CalculatedDistance;
bodyPiece.UpdateFrom(HitObject);
headCirclePiece.UpdateFrom(HitObject.HeadCircle);

View File

@ -40,7 +40,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
protected PathControlPointVisualiser<Slider> ControlPointVisualiser { get; private set; }
[Resolved(CanBeNull = true)]
private IDistanceSnapProvider snapProvider { get; set; }
private IPositionSnapProvider positionSnapProvider { get; set; }
[Resolved(CanBeNull = true)]
private IDistanceSnapProvider distanceSnapProvider { get; set; }
[Resolved(CanBeNull = true)]
private IPlacementHandler placementHandler { get; set; }
@ -194,7 +197,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
{
if (placementControlPoint != null)
{
var result = snapProvider?.FindSnappedPositionAndTime(ToScreenSpace(e.MousePosition));
var result = positionSnapProvider?.FindSnappedPositionAndTime(ToScreenSpace(e.MousePosition));
placementControlPoint.Position = ToLocalSpace(result?.ScreenSpacePosition ?? ToScreenSpace(e.MousePosition)) - HitObject.Position;
}
}
@ -245,7 +248,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
// Move the control points from the insertion index onwards to make room for the insertion
controlPoints.Insert(insertionIndex, pathControlPoint);
HitObject.SnapTo(snapProvider);
HitObject.SnapTo(distanceSnapProvider);
return pathControlPoint;
}
@ -267,7 +270,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
}
// Snap the slider to the current beat divisor before checking length validity.
HitObject.SnapTo(snapProvider);
HitObject.SnapTo(distanceSnapProvider);
// If there are 0 or 1 remaining control points, or the slider has an invalid length, it is in a degenerate form and should be deleted
if (controlPoints.Count <= 1 || !HitObject.Path.HasValidLength)

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.Game.Beatmaps;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Tools;
using osu.Game.Rulesets.Mods;
@ -30,7 +29,7 @@ using osuTK;
namespace osu.Game.Rulesets.Osu.Edit
{
public partial class OsuHitObjectComposer : DistancedHitObjectComposer<OsuHitObject>
public partial class OsuHitObjectComposer : HitObjectComposer<OsuHitObject>
{
public OsuHitObjectComposer(Ruleset ruleset)
: base(ruleset)
@ -49,18 +48,27 @@ namespace osu.Game.Rulesets.Osu.Edit
private readonly Bindable<TernaryState> rectangularGridSnapToggle = new Bindable<TernaryState>();
protected override IEnumerable<TernaryButton> CreateTernaryButtons() => base.CreateTernaryButtons().Concat(new[]
{
new TernaryButton(rectangularGridSnapToggle, "Grid Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Th })
});
protected override IEnumerable<TernaryButton> CreateTernaryButtons()
=> base.CreateTernaryButtons()
.Concat(DistanceSnapProvider.CreateTernaryButtons())
.Concat(new[]
{
new TernaryButton(rectangularGridSnapToggle, "Grid Snap", () => new SpriteIcon { Icon = FontAwesome.Solid.Th })
});
private BindableList<HitObject> selectedHitObjects;
private Bindable<HitObject> placementObject;
[Cached(typeof(IDistanceSnapProvider))]
protected readonly OsuDistanceSnapProvider DistanceSnapProvider = new OsuDistanceSnapProvider();
[BackgroundDependencyLoader]
private void load()
{
AddInternal(DistanceSnapProvider);
DistanceSnapProvider.AttachToToolbox(RightToolbox);
// Give a bit of breathing room around the playfield content.
PlayfieldContentContainer.Padding = new MarginPadding(10);
@ -81,7 +89,7 @@ namespace osu.Game.Rulesets.Osu.Edit
placementObject = EditorBeatmap.PlacementObject.GetBoundCopy();
placementObject.ValueChanged += _ => updateDistanceSnapGrid();
DistanceSnapToggle.ValueChanged += _ => updateDistanceSnapGrid();
DistanceSnapProvider.DistanceSnapToggle.ValueChanged += _ => updateDistanceSnapGrid();
// we may be entering the screen with a selection already active
updateDistanceSnapGrid();
@ -106,14 +114,6 @@ namespace osu.Game.Rulesets.Osu.Edit
private RectangularPositionSnapGrid rectangularPositionSnapGrid;
protected override double ReadCurrentDistanceSnap(HitObject before, HitObject after)
{
float expectedDistance = DurationToDistance(before, after.StartTime - before.GetEndTime());
float actualDistance = Vector2.Distance(((OsuHitObject)before).EndPosition, ((OsuHitObject)after).Position);
return actualDistance / expectedDistance;
}
protected override void Update()
{
base.Update();
@ -143,7 +143,7 @@ namespace osu.Game.Rulesets.Osu.Edit
// We want to ensure that in this particular case, the time-snapping component of distance snap is still applied.
// The easiest way to ensure this is to attempt application of distance snap after a nearby object is found, and copy over
// the time value if the proposed positions are roughly the same.
if (snapType.HasFlagFast(SnapType.RelativeGrids) && DistanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null)
if (snapType.HasFlagFast(SnapType.RelativeGrids) && DistanceSnapProvider.DistanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null)
{
(Vector2 distanceSnappedPosition, double distanceSnappedTime) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(snapResult.ScreenSpacePosition));
if (Precision.AlmostEquals(distanceSnapGrid.ToScreenSpace(distanceSnappedPosition), snapResult.ScreenSpacePosition, 1))
@ -157,7 +157,7 @@ namespace osu.Game.Rulesets.Osu.Edit
if (snapType.HasFlagFast(SnapType.RelativeGrids))
{
if (DistanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null)
if (DistanceSnapProvider.DistanceSnapToggle.Value == TernaryState.True && distanceSnapGrid != null)
{
(Vector2 pos, double time) = distanceSnapGrid.GetSnappedPosition(distanceSnapGrid.ToLocalSpace(screenSpacePosition));
@ -220,7 +220,7 @@ namespace osu.Game.Rulesets.Osu.Edit
distanceSnapGridCache.Invalidate();
distanceSnapGrid = null;
if (DistanceSnapToggle.Value != TernaryState.True)
if (DistanceSnapProvider.DistanceSnapToggle.Value != TernaryState.True)
return;
switch (BlueprintContainer.CurrentTool)
@ -262,14 +262,6 @@ namespace osu.Game.Rulesets.Osu.Edit
base.OnKeyUp(e);
}
protected override bool AdjustDistanceSpacing(GlobalAction action, float amount)
{
// To allow better visualisation, ensure that the spacing grid is visible before adjusting.
DistanceSnapToggle.Value = TernaryState.True;
return base.AdjustDistanceSpacing(action, amount);
}
private bool gridSnapMomentary;
private void handleToggleViaKey(KeyboardEvent key)

View File

@ -4,6 +4,7 @@
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
namespace osu.Game.Rulesets.Osu.Judgements
{
@ -15,28 +16,15 @@ namespace osu.Game.Rulesets.Osu.Judgements
public Spinner Spinner => (Spinner)HitObject;
/// <summary>
/// The total rotation performed on the spinner disc, disregarding the spin direction,
/// adjusted for the track's playback rate.
/// The total amount that the spinner was rotated.
/// </summary>
/// <remarks>
/// <para>
/// This value is always non-negative and is monotonically increasing with time
/// (i.e. will only increase if time is passing forward, but can decrease during rewind).
/// </para>
/// <para>
/// The rotation from each frame is multiplied by the clock's current playback rate.
/// 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;
public float TotalRotation => History.TotalRotation;
/// <summary>
/// Stores the spinning history of the spinner.<br />
/// Instants of movement deltas may be added or removed from this in order to calculate the total rotation for the spinner.
/// </summary>
public readonly SpinnerSpinHistory History = new SpinnerSpinHistory();
/// <summary>
/// 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))
{
start = Parent.ToLocalSpace(restrictTo.ScreenSpaceDrawQuad.TopLeft).X;
end = Parent.ToLocalSpace(restrictTo.ScreenSpaceDrawQuad.TopRight).X;
start = Parent!.ToLocalSpace(restrictTo.ScreenSpaceDrawQuad.TopLeft).X;
end = Parent!.ToLocalSpace(restrictTo.ScreenSpaceDrawQuad.TopRight).X;
}
else
{
float center = restrictTo.ToSpaceOfOtherDrawable(restrictTo.OriginPosition, Parent).X;
float center = restrictTo.ToSpaceOfOtherDrawable(restrictTo.OriginPosition, Parent!).X;
float halfDiagonal = (restrictTo.DrawSize / 2).LengthFast;
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.
sliderRepeat.CirclePiece.FadeOut(fadeDuration);
using (drawableObject.BeginAbsoluteSequence(drawableObject.HitStateUpdateTime))
sliderRepeat.FadeOut();
break;
case DrawableHitCircle circle:

View File

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

View File

@ -102,7 +102,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
/// </summary>
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>
/// 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,
TailCircle,
repeatContainer,
Body,
};
@ -107,7 +108,11 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
headContainer = new Container<DrawableSliderHead> { RelativeSizeAxes = Axes.Both },
OverlayElementContainer = new Container { RelativeSizeAxes = Axes.Both, },
Ball,
slidingSample = new PausableSkinnableSound { Looping = true }
slidingSample = new PausableSkinnableSound
{
Looping = true,
MinimumSampleVolume = MINIMUM_SAMPLE_VOLUME,
}
});
PositionBindable.BindValueChanged(_ => Position = HitObject.StackedPosition);
@ -264,7 +269,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
protected override void CheckForResult(bool userTriggered, double timeOffset)
{
if (userTriggered || Time.Current < HitObject.EndTime)
if (userTriggered || !TailCircle.Judged || Time.Current < HitObject.EndTime)
return;
// If only the nested hitobjects are judged, then the slider's own judgement is ignored for scoring purposes.

View File

@ -11,6 +11,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input;
using osu.Framework.Input.Events;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Skinning.Default;
@ -153,9 +154,12 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
Tracking =
// in valid time range
Time.Current >= drawableSlider.HitObject.StartTime && Time.Current < drawableSlider.HitObject.EndTime &&
Time.Current >= drawableSlider.HitObject.StartTime
// even in an edge case where current time has exceeded the slider's time, we may not have finished judging.
// we don't want to potentially update from Tracking=true to Tracking=false at this point.
&& (!drawableSlider.AllJudged || Time.Current <= drawableSlider.HitObject.GetEndTime())
// in valid position range
lastScreenSpaceMousePosition.HasValue && followCircleReceptor.ReceivePositionalInputAt(lastScreenSpaceMousePosition.Value) &&
&& lastScreenSpaceMousePosition.HasValue && followCircleReceptor.ReceivePositionalInputAt(lastScreenSpaceMousePosition.Value) &&
// valid action
(actions?.Any(isValidTrackingAction) ?? false);

View File

@ -118,11 +118,6 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
case ArmedState.Hit:
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;
}
}

View File

@ -8,6 +8,7 @@ using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Skinning;
@ -125,8 +126,22 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
protected override void CheckForResult(bool userTriggered, double timeOffset)
{
if (!userTriggered && timeOffset >= 0)
ApplyResult(r => r.Type = Tracking ? r.Judgement.MaxResult : r.Judgement.MinResult);
if (userTriggered)
return;
// Ensure the tail can only activate after all previous ticks already have.
//
// This covers the edge case where the lenience may allow the tail to activate before
// the last tick, changing ordering of score/combo awarding.
if (DrawableSlider.NestedHitObjects.Count > 1 && !DrawableSlider.NestedHitObjects[^2].Judged)
return;
// The player needs to have engaged in tracking at any point after the tail leniency cutoff.
// An actual tick miss should only occur if reaching the tick itself.
if (timeOffset >= SliderEventGenerator.TAIL_LENIENCY && Tracking)
ApplyResult(r => r.Type = r.Judgement.MaxResult);
else if (timeOffset > 0)
ApplyResult(r => r.Type = r.Judgement.MinResult);
}
protected override void OnApply()

View File

@ -48,9 +48,16 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
/// <summary>
/// The amount of bonus score gained from spinning after the required number of spins, for display purposes.
/// </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>
/// 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
{
Volume = { Value = 0 },
MinimumSampleVolume = MINIMUM_SAMPLE_VOLUME,
Looping = true,
Frequency = { Value = spinning_sample_initial_frequency }
}
@ -218,7 +226,7 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
// these become implicitly hit.
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.
// 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)
spmCalculator.SetRotation(Result.RateAdjustedRotation);
spmCalculator.SetRotation(Result.TotalRotation);
updateBonusScore();
}
private static readonly int score_per_tick = new SpinnerBonusTick.OsuSpinnerBonusTickJudgement().MaxNumericResult;
private int completedFullSpins;
private void updateBonusScore()
{
if (ticks.Count == 0)
return;
int spins = (int)(Result.RateAdjustedRotation / 360);
int spins = (int)(Result.TotalRotation / 360);
if (spins < completedFullSpins)
if (spins < completedFullSpins.Value)
{
// rewinding, silently handle
completedFullSpins = spins;
completedFullSpins.Value = spins;
return;
}
while (completedFullSpins != spins)
while (completedFullSpins.Value != spins)
{
var tick = ticks.FirstOrDefault(t => !t.Result.HasResult);
// tick may be null if we've hit the spin limit.
if (tick != null)
{
tick.TriggerResult(true);
tick?.TriggerResult(true);
if (tick is DrawableSpinnerBonusTick)
gainedBonus.Value = score_per_tick * (spins - HitObject.SpinsRequiredForBonus);
}
completedFullSpins++;
completedFullSpins.Value++;
}
}
}

View File

@ -25,6 +25,16 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
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>
/// Apply a judgement result.
/// </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.ControlPoints;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.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.
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();

View File

@ -204,11 +204,7 @@ namespace osu.Game.Rulesets.Osu.Objects
});
break;
case SliderEventType.LastTick:
// Of note, we are directly mapping LastTick (instead of `SliderEventType.Tail`) to SliderTailCircle.
// It is required as difficulty calculation and gameplay relies on reading this value.
// (although it is displayed in classic skins, which may be a concern).
// If this is to change, we should revisit this.
case SliderEventType.Tail:
AddNested(TailCircle = new SliderTailCircle(this)
{
RepeatIndex = e.SpanIndex,

View File

@ -2,16 +2,11 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Judgements;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Osu.Objects
{
/// <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 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.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
@ -12,6 +13,7 @@ using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osuTK;
using osuTK.Graphics;
@ -19,8 +21,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
{
public partial class ArgonReverseArrow : CompositeDrawable
{
[Resolved]
private DrawableHitObject drawableObject { get; set; } = null!;
private DrawableSliderRepeat drawableRepeat { get; set; } = null!;
private Bindable<Color4> accentColour = null!;
@ -29,8 +30,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
private Sprite side = null!;
[BackgroundDependencyLoader]
private void load(TextureStore textures)
private void load(DrawableHitObject drawableObject, TextureStore textures)
{
drawableRepeat = (DrawableSliderRepeat)drawableObject;
Anchor = 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);
drawableObject.ApplyCustomUpdateState += updateStateTransforms;
drawableRepeat.ApplyCustomUpdateState += updateStateTransforms;
}
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)
.Loop(total - (move_in_duration + move_out_duration));
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);
if (drawableObject.IsNotNull())
drawableObject.ApplyCustomUpdateState -= updateStateTransforms;
if (drawableRepeat.IsNotNull())
drawableRepeat.ApplyCustomUpdateState -= updateStateTransforms;
}
}
}

View File

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

View File

@ -35,14 +35,6 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon
InternalChildren = new Drawable[]
{
bonusCounter = new OsuSpriteText
{
Alpha = 0,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Font = OsuFont.Default.With(size: 24),
Y = -120,
},
new ArgonSpinnerDisc
{
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!;
protected override void LoadComplete()
{
base.LoadComplete();
gainedBonus = drawableSpinner.GainedBonus.GetBoundCopy();
gainedBonus.BindValueChanged(bonus =>
completedSpins = drawableSpinner.CompletedFullSpins.GetBoundCopy();
completedSpins.BindValueChanged(_ =>
{
bonusCounter.Text = bonus.NewValue.ToString(NumberFormatInfo.InvariantInfo);
bonusCounter.FadeOutFromOne(1500);
bonusCounter.ScaleTo(1.5f).Then().ScaleTo(1f, 1000, Easing.OutQuint);
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(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.FadeOutFromOne(1500);
}
});
spinsPerMinute = drawableSpinner.SpinsPerMinute.GetBoundCopy();

View File

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

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
@ -8,14 +9,14 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osuTK;
namespace osu.Game.Rulesets.Osu.Skinning.Default
{
public partial class DefaultReverseArrow : CompositeDrawable
{
[Resolved]
private DrawableHitObject drawableObject { get; set; } = null!;
private DrawableSliderRepeat drawableRepeat { get; set; } = null!;
public DefaultReverseArrow()
{
@ -36,9 +37,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
}
[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)
@ -55,6 +57,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
.ScaleTo(1f, move_in_duration, Easing.Out)
.Loop(total - (move_in_duration + move_out_duration));
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);
if (drawableObject.IsNotNull())
drawableObject.ApplyCustomUpdateState -= updateStateTransforms;
if (drawableRepeat.IsNotNull())
drawableRepeat.ApplyCustomUpdateState -= updateStateTransforms;
}
}
}

View File

@ -24,6 +24,9 @@ namespace osu.Game.Rulesets.Osu.Skinning.Default
private Container spmContainer = null!;
private OsuSpriteText spmCounter = null!;
[Resolved]
private OsuColour colours { get; set; } = null!;
public DefaultSpinner()
{
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!;
protected override void LoadComplete()
{
base.LoadComplete();
gainedBonus = drawableSpinner.GainedBonus.GetBoundCopy();
gainedBonus.BindValueChanged(bonus =>
completedSpins = drawableSpinner.CompletedFullSpins.GetBoundCopy();
completedSpins.BindValueChanged(bonus =>
{
bonusCounter.Text = bonus.NewValue.ToString(NumberFormatInfo.InvariantInfo);
bonusCounter.FadeOutFromOne(1500);
bonusCounter.ScaleTo(1.5f).Then().ScaleTo(1f, 1000, Easing.OutQuint);
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.ScaleTo(1.5f).Then().ScaleTo(1f, 1000, Easing.OutQuint);
}
});
spinsPerMinute = drawableSpinner.SpinsPerMinute.GetBoundCopy();

View File

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

View File

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

View File

@ -1,6 +1,7 @@
// 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.Diagnostics;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
@ -17,8 +18,7 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{
public partial class LegacyReverseArrow : CompositeDrawable
{
[Resolved]
private DrawableHitObject drawableObject { get; set; } = null!;
private DrawableSliderRepeat drawableRepeat { get; set; } = null!;
private Drawable proxy = null!;
@ -31,8 +31,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
private bool shouldRotate;
[BackgroundDependencyLoader]
private void load(ISkinSource skinSource)
private void load(DrawableHitObject drawableObject, ISkinSource skinSource)
{
drawableRepeat = (DrawableSliderRepeat)drawableObject;
AutoSizeAxes = Axes.Both;
string lookupName = new OsuSkinComponentLookup(OsuSkinComponents.ReverseArrow).LookupName;
@ -58,10 +60,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
proxy = CreateProxy();
drawableObject.HitObjectApplied += onHitObjectApplied;
onHitObjectApplied(drawableObject);
drawableRepeat.HitObjectApplied += onHitObjectApplied;
onHitObjectApplied(drawableRepeat);
accentColour = drawableObject.AccentColour.GetBoundCopy();
accentColour = drawableRepeat.AccentColour.GetBoundCopy();
accentColour.BindValueChanged(c =>
{
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);
// see logic in LegacySliderHeadHitCircle.
(drawableObject as DrawableSliderRepeat)?.DrawableSlider
.OverlayElementContainer.Add(proxy);
drawableRepeat.DrawableSlider.OverlayElementContainer.Add(proxy);
}
private void updateStateTransforms(DrawableHitObject hitObject, ArmedState state)
@ -102,6 +103,11 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
}
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);
if (drawableObject.IsNotNull())
if (drawableRepeat.IsNotNull())
{
drawableObject.HitObjectApplied -= onHitObjectApplied;
drawableObject.ApplyCustomUpdateState -= updateStateTransforms;
drawableRepeat.HitObjectApplied -= onHitObjectApplied;
drawableRepeat.ApplyCustomUpdateState -= updateStateTransforms;
}
}
}

View File

@ -1,35 +1,39 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Animations;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Osu.Objects.Drawables;
using osu.Game.Skinning;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{
public partial class LegacySliderBall : CompositeDrawable
{
private readonly Drawable animationContent;
private readonly ISkin skin;
[Resolved(canBeNull: true)]
private DrawableHitObject? parentObject { get; set; }
public Color4 BallColour => animationContent.Colour;
private Sprite layerNd = 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;
AutoSizeAxes = Axes.Both;
@ -38,30 +42,39 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
[BackgroundDependencyLoader]
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
{
Anchor = 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),
},
LegacyColourCompatibility.ApplyWithDoubledAlpha(animationContent.With(d =>
ballAnimation = new LegacySkinExtensions.SkinnableTextureAnimation
{
d.Anchor = Anchor.Centre;
d.Origin = Anchor.Centre;
}), ballColour),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Colour = ballColour,
},
layerSpec = new Sprite
{
Anchor = 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,
},
};
if (parentObject != null)
parentObject.HitObjectApplied += onHitObjectApplied;
onHitObjectApplied(parentObject);
}
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)
{
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();
//undo rotation on layers which should not be rotated.
float appliedRotation = Parent.Rotation;
float appliedRotation = Parent!.Rotation;
layerNd.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 _)
{
// Gets called by slider ticks, tails, etc., leading to duplicated
@ -114,7 +147,10 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
base.Dispose(isDisposing);
if (parentObject != null)
{
parentObject.HitObjectApplied -= onHitObjectApplied;
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 readonly Bindable<bool> completed = new Bindable<bool>();
@ -116,12 +116,24 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
{
base.LoadComplete();
gainedBonus = DrawableSpinner.GainedBonus.GetBoundCopy();
gainedBonus.BindValueChanged(bonus =>
completedSpins = DrawableSpinner.CompletedFullSpins.GetBoundCopy();
completedSpins.BindValueChanged(bonus =>
{
bonusCounter.Text = bonus.NewValue.ToString(NumberFormatInfo.InvariantInfo);
bonusCounter.FadeOutFromOne(800, Easing.Out);
bonusCounter.ScaleTo(SPRITE_SCALE * 2f).Then().ScaleTo(SPRITE_SCALE * 1.28f, 800, Easing.Out);
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.FadeOutFromOne(800, Easing.Out);
bonusCounter.ScaleTo(SPRITE_SCALE * 2f).Then().ScaleTo(SPRITE_SCALE * 1.28f, 800, Easing.Out);
}
});
spinsPerMinute = DrawableSpinner.SpinsPerMinute.GetBoundCopy();

View File

@ -59,13 +59,8 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
return null;
case OsuSkinComponents.SliderBall:
var sliderBallContent = this.GetAnimation("sliderb", true, true, animationSeparator: "", maxSize: MAX_FOLLOW_CIRCLE_AREA_SIZE);
// 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);
if (GetTexture("sliderb") != null || GetTexture("sliderb0") != null)
return new LegacySliderBall(this);
return null;

View File

@ -13,6 +13,7 @@ using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Scoring;
using osuTK;
@ -208,8 +209,7 @@ namespace osu.Game.Rulesets.Osu.Statistics
if (score.HitEvents.Count == 0)
return;
// Todo: This should probably not be done like this.
float radius = OsuHitObject.OBJECT_RADIUS * (1.0f - 0.7f * (playableBeatmap.Difficulty.CircleSize - 5) / 5) / 2;
float radius = OsuHitObject.OBJECT_RADIUS * LegacyRulesetExtensions.CalculateScaleFromCircleSize(playableBeatmap.Difficulty.CircleSize, true);
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)
//
// 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 = 819.2
// Parent!.ChildSize.X = min(game_size.X, game_size.Y * (4 / 3)) * playfield_size_adjust
// Parent!.ChildSize.X = 819.2
//
// Scale = 819.2 / 512
// 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);
// Size = 0.625
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 bottomRight = new Vector2(float.MinValue, float.MinValue);
topLeft = Vector2.ComponentMin(topLeft, Parent.ToLocalSpace(DrawableObject.ScreenSpaceDrawQuad.TopLeft));
bottomRight = Vector2.ComponentMax(bottomRight, Parent.ToLocalSpace(DrawableObject.ScreenSpaceDrawQuad.BottomRight));
topLeft = Vector2.ComponentMin(topLeft, Parent!.ToLocalSpace(DrawableObject.ScreenSpaceDrawQuad.TopLeft));
bottomRight = Vector2.ComponentMax(bottomRight, Parent!.ToLocalSpace(DrawableObject.ScreenSpaceDrawQuad.BottomRight));
Size = bottomRight - 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()
=> new TaikoBlueprintContainer(this);
protected override BeatSnapGrid CreateBeatSnapGrid() => new TaikoBeatSnapGrid();
}
}

View File

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

View File

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

View File

@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon
protected override void 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()
{
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>
public Bindable<bool> ClassicHitTargetPosition = new BindableBool();
public Container UnderlayElements { get; private set; } = null!;
private Container<HitExplosion> hitExplosionContainer;
private Container<KiaiHitExplosion> kiaiExplosionContainer;
private JudgementContainer<DrawableTaikoJudgement> judgementContainer;
@ -130,7 +132,14 @@ namespace osu.Game.Rulesets.Taiko.UI
{
Name = "Bar line content",
RelativeSizeAxes = Axes.Both,
Child = barLinePlayfield = new BarLinePlayfield(),
Children = new Drawable[]
{
UnderlayElements = new Container
{
RelativeSizeAxes = Axes.Both,
},
barLinePlayfield = new BarLinePlayfield(),
}
},
hitObjectContent = new Container
{

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.
if (LockPlayfieldAspectRange.Value)
{
float currentAspect = Parent.ChildSize.X / Parent.ChildSize.Y;
float currentAspect = Parent!.ChildSize.X / Parent!.ChildSize.Y;
if (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);
}
}
[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();
Assert.That(events[2].Type, Is.EqualTo(SliderEventType.LastTick));
Assert.That(events[2].Time, Is.EqualTo(span_duration + SliderEventGenerator.LAST_TICK_OFFSET));
Assert.That(events[2].Type, Is.EqualTo(SliderEventType.LegacyLastTick));
Assert.That(events[2].Time, Is.EqualTo(span_duration + SliderEventGenerator.TAIL_LENIENCY));
}
[Test]

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