1
0
mirror of https://github.com/ppy/osu.git synced 2025-02-24 12:05:16 +08:00

Merge branch 'master' into cognition

This commit is contained in:
Givikap120 2024-10-07 23:11:07 +03:00
commit 7f3093ee49
249 changed files with 6405 additions and 1348 deletions

View File

@ -133,7 +133,7 @@ jobs:
dotnet-version: "8.0.x"
- name: Install .NET Workloads
run: dotnet workload install maui-ios
run: dotnet workload install ios --from-rollback-file https://raw.githubusercontent.com/ppy/osu-framework/refs/heads/master/workloads.json
- name: Build
run: dotnet build -c Debug osu.iOS

View File

@ -104,6 +104,25 @@ env:
EXECUTION_ID: execution-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}
jobs:
master-environment:
name: Save master environment
runs-on: ubuntu-latest
outputs:
HEAD: ${{ steps.get-head.outputs.HEAD }}
steps:
- name: Checkout osu
uses: actions/checkout@v4
with:
ref: master
sparse-checkout: |
README.md
- name: Get HEAD ref
id: get-head
run: |
ref=$(git log -1 --format='%H')
echo "HEAD=https://github.com/${{ github.repository }}/commit/${ref}" >> "${GITHUB_OUTPUT}"
check-permissions:
name: Check permissions
runs-on: ubuntu-latest
@ -121,7 +140,7 @@ jobs:
create-comment:
name: Create PR comment
needs: check-permissions
needs: [ master-environment, check-permissions ]
runs-on: ubuntu-latest
if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }}
steps:
@ -158,7 +177,7 @@ jobs:
environment:
name: Setup environment
needs: directory
needs: [ master-environment, directory ]
runs-on: self-hosted
env:
VARS_JSON: ${{ toJSON(vars) }}
@ -182,6 +201,10 @@ jobs:
fi
done
- name: Add master environment
run: |
sed -i "s;^OSU_A=.*$;OSU_A=${{ needs.master-environment.outputs.HEAD }};" "${{ needs.directory.outputs.GENERATOR_ENV }}"
- name: Add pull-request environment
if: ${{ github.event_name == 'issue_comment' && github.event.issue.pull_request }}
run: |
@ -361,8 +384,7 @@ jobs:
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
with:
comment_tag: ${{ env.EXECUTION_ID }}
mode: upsert
create_if_not_exists: false
mode: recreate
message: |
Target: ${{ needs.generator.outputs.TARGET }}
Spreadsheet: ${{ needs.generator.outputs.SPREADSHEET_LINK }}
@ -372,8 +394,7 @@ jobs:
uses: thollander/actions-comment-pull-request@fabd468d3a1a0b97feee5f6b9e499eab0dd903f6 # v2.5.0
with:
comment_tag: ${{ env.EXECUTION_ID }}
mode: upsert
create_if_not_exists: false
mode: recreate
message: |
Difficulty calculation failed: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}

View File

@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2024.912.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2024.1007.0" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.

View File

@ -66,7 +66,7 @@ namespace osu.Desktop.Updater
{
Activated = () =>
{
restartToApplyUpdate();
Task.Run(restartToApplyUpdate);
return true;
}
});
@ -88,7 +88,11 @@ namespace osu.Desktop.Updater
{
notification = new UpdateProgressNotification
{
CompletionClickAction = restartToApplyUpdate,
CompletionClickAction = () =>
{
Task.Run(restartToApplyUpdate);
return true;
},
};
Schedule(() => notificationOverlay.Post(notification));
@ -127,13 +131,10 @@ namespace osu.Desktop.Updater
return true;
}
private bool restartToApplyUpdate()
private async Task restartToApplyUpdate()
{
// TODO: Migrate this to async flow whenever available (see https://github.com/ppy/osu/pull/28743#discussion_r1740505665).
// Currently there's an internal Thread.Sleep(300) which will cause a stutter when the user clicks to restart.
updateManager.WaitExitThenApplyUpdates(pendingUpdate?.TargetFullRelease);
await updateManager.WaitExitThenApplyUpdatesAsync(pendingUpdate?.TargetFullRelease).ConfigureAwait(false);
Schedule(() => game.AttemptExit());
return true;
}
}
}

View File

@ -26,7 +26,7 @@
<ItemGroup Label="Package References">
<PackageReference Include="System.IO.Packaging" Version="8.0.0" />
<PackageReference Include="DiscordRichPresence" Version="1.2.1.24" />
<PackageReference Include="Velopack" Version="0.0.598-g933b2ab" />
<PackageReference Include="Velopack" Version="0.0.630-g9c52e40" />
</ItemGroup>
<ItemGroup Label="Resources">
<EmbeddedResource Include="lazer.ico" />

View File

@ -0,0 +1,29 @@
// 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 BenchmarkDotNet.Attributes;
using osu.Framework.Utils;
using osu.Game.Utils;
using osuTK;
namespace osu.Game.Benchmarks
{
public class BenchmarkGeometryUtils : BenchmarkTest
{
[Params(100, 1000, 2000, 4000, 8000, 10000)]
public int N;
private Vector2[] points = null!;
public override void SetUp()
{
points = new Vector2[N];
for (int i = 0; i < points.Length; ++i)
points[i] = new Vector2(RNG.Next(512), RNG.Next(384));
}
[Benchmark]
public void MinimumEnclosingCircle() => GeometryUtils.MinimumEnclosingCircle(points);
}
}

View File

@ -71,7 +71,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
contentContainer.Playfield.HitObjectContainer.Add(hitObject);
}
protected override SnapResult SnapForBlueprint(PlacementBlueprint blueprint)
protected override SnapResult SnapForBlueprint(HitObjectPlacementBlueprint blueprint)
{
var result = base.SnapForBlueprint(blueprint);
result.Time = Math.Round(HitObjectContainer.TimeAtScreenSpacePosition(result.ScreenSpacePosition) / TIME_SNAP) * TIME_SNAP;

View File

@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
{
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableBananaShower((BananaShower)hitObject);
protected override PlacementBlueprint CreateBlueprint() => new BananaShowerPlacementBlueprint();
protected override HitObjectPlacementBlueprint CreateBlueprint() => new BananaShowerPlacementBlueprint();
protected override void AddHitObject(DrawableHitObject hitObject)
{

View File

@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
{
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableFruit((Fruit)hitObject);
protected override PlacementBlueprint CreateBlueprint() => new FruitPlacementBlueprint();
protected override HitObjectPlacementBlueprint CreateBlueprint() => new FruitPlacementBlueprint();
[Test]
public void TestFruitPlacementPosition()

View File

@ -114,7 +114,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableJuiceStream((JuiceStream)hitObject);
protected override PlacementBlueprint CreateBlueprint() => new JuiceStreamPlacementBlueprint();
protected override HitObjectPlacementBlueprint CreateBlueprint() => new JuiceStreamPlacementBlueprint();
private void addMoveAndClickSteps(double time, float position, bool end = false)
{

View File

@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Bindings;
using osu.Framework.Localisation;
@ -31,6 +32,7 @@ using osu.Game.Scoring;
using osu.Game.Screens.Edit.Setup;
using osu.Game.Screens.Ranking.Statistics;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch
{
@ -223,10 +225,28 @@ namespace osu.Game.Rulesets.Catch
public override HitObjectComposer CreateHitObjectComposer() => new CatchHitObjectComposer(this);
public override IEnumerable<SetupSection> CreateEditorSetupSections() =>
public override IEnumerable<Drawable> CreateEditorSetupSections() =>
[
new MetadataSection(),
new DifficultySection(),
new ColoursSection(),
new FillFlowContainer
{
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(SetupScreen.SPACING),
Children = new Drawable[]
{
new ResourcesSection
{
RelativeSizeAxes = Axes.X,
},
new ColoursSection
{
RelativeSizeAxes = Axes.X,
}
}
},
new DesignSection(),
];
public override IBeatmapVerifier CreateBeatmapVerifier() => new CatchBeatmapVerifier();

View File

@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Catch.Beatmaps;
using osu.Game.Rulesets.Catch.Difficulty.Preprocessing;
@ -44,7 +43,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty
StarRating = Math.Sqrt(skills[0].DifficultyValue()) * difficulty_multiplier,
Mods = mods,
ApproachRate = preempt > 1200.0 ? -(preempt - 1800.0) / 120.0 : -(preempt - 1200.0) / 150.0 + 5.0,
MaxCombo = beatmap.HitObjects.Count(h => h is Fruit) + beatmap.HitObjects.OfType<JuiceStream>().SelectMany(j => j.NestedHitObjects).Count(h => !(h is TinyDroplet)),
MaxCombo = beatmap.GetMaxCombo(),
};
return attributes;

View File

@ -10,7 +10,7 @@ using osu.Game.Rulesets.Edit.Tools;
namespace osu.Game.Rulesets.Catch.Edit
{
public class BananaShowerCompositionTool : HitObjectCompositionTool
public class BananaShowerCompositionTool : CompositionTool
{
public BananaShowerCompositionTool()
: base(nameof(BananaShower))
@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Catch.Edit
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners);
public override PlacementBlueprint CreatePlacementBlueprint() => new BananaShowerPlacementBlueprint();
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new BananaShowerPlacementBlueprint();
}
}

View File

@ -9,7 +9,7 @@ using osu.Game.Rulesets.UI.Scrolling;
namespace osu.Game.Rulesets.Catch.Edit.Blueprints
{
public partial class CatchPlacementBlueprint<THitObject> : PlacementBlueprint
public partial class CatchPlacementBlueprint<THitObject> : HitObjectPlacementBlueprint
where THitObject : CatchHitObject, new()
{
protected new THitObject HitObject => (THitObject)base.HitObject;

View File

@ -8,6 +8,7 @@ using osu.Framework.Caching;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Catch.Edit.Blueprints.Components;
@ -172,7 +173,10 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints
yield return new OsuMenuItem("Add vertex", MenuItemType.Standard, () =>
{
editablePath.AddVertex(rightMouseDownPosition);
});
})
{
Hotkey = new Hotkey(new KeyCombination(InputKey.Control, InputKey.MouseLeft))
};
}
protected override void Dispose(bool isDisposing)

View File

@ -84,7 +84,7 @@ namespace osu.Game.Rulesets.Catch.Edit
protected override BeatSnapGrid CreateBeatSnapGrid() => new CatchBeatSnapGrid();
protected override IReadOnlyList<HitObjectCompositionTool> CompositionTools => new HitObjectCompositionTool[]
protected override IReadOnlyList<CompositionTool> CompositionTools => new CompositionTool[]
{
new FruitCompositionTool(),
new JuiceStreamCompositionTool(),
@ -114,6 +114,26 @@ namespace osu.Game.Rulesets.Catch.Edit
{
}
protected override bool OnKeyDown(KeyDownEvent e)
{
if (e.Repeat)
return false;
handleToggleViaKey(e);
return base.OnKeyDown(e);
}
protected override void OnKeyUp(KeyUpEvent e)
{
handleToggleViaKey(e);
base.OnKeyUp(e);
}
private void handleToggleViaKey(KeyboardEvent key)
{
DistanceSnapProvider.HandleToggleViaKey(key);
}
public override SnapResult FindSnappedPositionAndTime(Vector2 screenSpacePosition, SnapType snapType = SnapType.All)
{
var result = base.FindSnappedPositionAndTime(screenSpacePosition, snapType);

View File

@ -10,7 +10,7 @@ using osu.Game.Rulesets.Edit.Tools;
namespace osu.Game.Rulesets.Catch.Edit
{
public class FruitCompositionTool : HitObjectCompositionTool
public class FruitCompositionTool : CompositionTool
{
public FruitCompositionTool()
: base(nameof(Fruit))
@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Catch.Edit
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles);
public override PlacementBlueprint CreatePlacementBlueprint() => new FruitPlacementBlueprint();
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new FruitPlacementBlueprint();
}
}

View File

@ -10,7 +10,7 @@ using osu.Game.Rulesets.Edit.Tools;
namespace osu.Game.Rulesets.Catch.Edit
{
public class JuiceStreamCompositionTool : HitObjectCompositionTool
public class JuiceStreamCompositionTool : CompositionTool
{
public JuiceStreamCompositionTool()
: base(nameof(JuiceStream))
@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Catch.Edit
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders);
public override PlacementBlueprint CreatePlacementBlueprint() => new JuiceStreamPlacementBlueprint();
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new JuiceStreamPlacementBlueprint();
}
}

View File

@ -1,10 +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.
using System;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Utils;
using osu.Game.Rulesets.Catch.Skinning.Default;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Catch.Objects.Drawables
{
@ -36,23 +38,43 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables
StartTimeBindable.BindValueChanged(_ => UpdateComboColour());
}
private float startScale;
private float endScale;
private float startAngle;
private float endAngle;
protected override void UpdateInitialTransforms()
{
base.UpdateInitialTransforms();
// Important to have this in UpdateInitialTransforms() to it is re-triggered by RefreshStateTransforms().
const float end_scale = 0.6f;
const float random_scale_range = 1.6f;
ScalingContainer.ScaleTo(HitObject.Scale * (end_scale + random_scale_range * RandomSingle(3)))
.Then().ScaleTo(HitObject.Scale * end_scale, HitObject.TimePreempt);
startScale = end_scale + random_scale_range * RandomSingle(3);
endScale = end_scale;
ScalingContainer.RotateTo(getRandomAngle(1))
.Then()
.RotateTo(getRandomAngle(2), HitObject.TimePreempt);
startAngle = getRandomAngle(1);
endAngle = getRandomAngle(2);
float getRandomAngle(int series) => 180 * (RandomSingle(series) * 2 - 1);
}
protected override void Update()
{
base.Update();
double preemptProgress = (Time.Current - (HitObject.StartTime - InitialLifetimeOffset)) / HitObject.TimePreempt;
// Clamp scale and rotation at the point of bananas being caught, else let them freely extrapolate.
if (Result.IsHit)
preemptProgress = Math.Min(1, preemptProgress);
ScalingContainer.Scale = new Vector2(HitObject.Scale * (float)Interpolation.Lerp(startScale, endScale, preemptProgress));
ScalingContainer.Rotation = (float)Interpolation.Lerp(startAngle, endAngle, preemptProgress);
}
public override void PlaySamples()
{
base.PlaySamples();

View File

@ -2,7 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Utils;
using osu.Game.Rulesets.Catch.Skinning.Default;
using osu.Game.Skinning;
@ -28,15 +28,24 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables
_ => new DropletPiece());
}
private float startRotation;
protected override void UpdateInitialTransforms()
{
base.UpdateInitialTransforms();
// roughly matches osu-stable
float startRotation = RandomSingle(1) * 20;
double duration = HitObject.TimePreempt + 2000;
// Important to have this in UpdateInitialTransforms() to it is re-triggered by RefreshStateTransforms().
startRotation = RandomSingle(1) * 20;
}
ScalingContainer.RotateTo(startRotation).RotateTo(startRotation + 720, duration);
protected override void Update()
{
base.Update();
// No clamping for droplets. They should be considered indefinitely spinning regardless of time.
// They also never end up on the plate, so they shouldn't stop spinning when caught.
double preemptProgress = (Time.Current - (HitObject.StartTime - InitialLifetimeOffset)) / (HitObject.TimePreempt + 2000);
ScalingContainer.Rotation = (float)Interpolation.Lerp(startRotation, startRotation + 720, preemptProgress);
}
}
}

View File

@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Rulesets.Catch.Skinning.Default;
using osu.Game.Skinning;
@ -32,7 +31,8 @@ namespace osu.Game.Rulesets.Catch.Objects.Drawables
{
base.UpdateInitialTransforms();
ScalingContainer.RotateTo((RandomSingle(1) - 0.5f) * 40);
// Important to have this in UpdateInitialTransforms() to it is re-triggered by RefreshStateTransforms().
ScalingContainer.Rotation = (RandomSingle(1) - 0.5f) * 40;
}
}
}

View File

@ -47,7 +47,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
});
}
protected override SnapResult SnapForBlueprint(PlacementBlueprint blueprint)
protected override SnapResult SnapForBlueprint(HitObjectPlacementBlueprint blueprint)
{
double time = column.TimeAtScreenSpacePosition(InputManager.CurrentState.Mouse.Position);
var pos = column.ScreenSpacePositionAtTime(time);

View File

@ -13,6 +13,6 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
public partial class TestSceneHoldNotePlacementBlueprint : ManiaPlacementBlueprintTestScene
{
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableHoldNote((HoldNote)hitObject);
protected override PlacementBlueprint CreateBlueprint() => new HoldNotePlacementBlueprint();
protected override HitObjectPlacementBlueprint CreateBlueprint() => new HoldNotePlacementBlueprint();
}
}

View File

@ -20,10 +20,10 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
[Test]
public void TestKeyCountChange()
{
LabelledSliderBar<float> keyCount = null!;
FormSliderBar<float> keyCount = null!;
AddStep("go to setup screen", () => InputManager.Key(Key.F4));
AddUntilStep("retrieve key count slider", () => keyCount = Editor.ChildrenOfType<SetupScreen>().Single().ChildrenOfType<LabelledSliderBar<float>>().First(), () => Is.Not.Null);
AddUntilStep("retrieve key count slider", () => keyCount = Editor.ChildrenOfType<SetupScreen>().Single().ChildrenOfType<FormSliderBar<float>>().First(), () => Is.Not.Null);
AddAssert("key count is 5", () => keyCount.Current.Value, () => Is.EqualTo(5));
AddStep("change key count to 8", () =>
{

View File

@ -6,6 +6,7 @@ using NUnit.Framework;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.UI;
using osu.Game.Screens.Edit.Compose.Components;
using osu.Game.Tests.Beatmaps;
using osu.Game.Tests.Visual;
@ -92,5 +93,30 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
AddAssert("second object flipped", () => second.StartTime, () => Is.EqualTo(250));
AddAssert("third object flipped", () => third.StartTime, () => Is.EqualTo(1250));
}
[Test]
public void TestOffScreenObjectsRemainSelectedOnColumnChange()
{
AddStep("create objects", () =>
{
for (int i = 0; i < 20; ++i)
EditorBeatmap.Add(new Note { StartTime = 1000 * i, Column = 0 });
});
AddStep("select everything", () => EditorBeatmap.SelectedHitObjects.AddRange(EditorBeatmap.HitObjects));
AddStep("start drag", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<Column>().First());
InputManager.PressButton(MouseButton.Left);
});
AddStep("end drag", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<Column>().Last());
InputManager.ReleaseButton(MouseButton.Left);
});
AddAssert("all objects in last column", () => EditorBeatmap.HitObjects.All(ho => ((ManiaHitObject)ho).Column == 3));
AddAssert("all objects remain selected", () => EditorBeatmap.SelectedHitObjects.SequenceEqual(EditorBeatmap.HitObjects));
}
}
}

View File

@ -64,6 +64,6 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor
private Note getNote() => this.ChildrenOfType<DrawableNote>().FirstOrDefault()?.HitObject;
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableNote((Note)hitObject);
protected override PlacementBlueprint CreateBlueprint() => new NotePlacementBlueprint();
protected override HitObjectPlacementBlueprint CreateBlueprint() => new NotePlacementBlueprint();
}
}

View File

@ -15,7 +15,7 @@ using osuTK.Input;
namespace osu.Game.Rulesets.Mania.Edit.Blueprints
{
public abstract partial class ManiaPlacementBlueprint<T> : PlacementBlueprint
public abstract partial class ManiaPlacementBlueprint<T> : HitObjectPlacementBlueprint
where T : ManiaHitObject
{
protected new T HitObject => (T)base.HitObject;

View File

@ -9,7 +9,7 @@ using osu.Game.Rulesets.Mania.Edit.Blueprints;
namespace osu.Game.Rulesets.Mania.Edit
{
public class HoldNoteCompositionTool : HitObjectCompositionTool
public class HoldNoteCompositionTool : CompositionTool
{
public HoldNoteCompositionTool()
: base("Hold")
@ -18,6 +18,6 @@ namespace osu.Game.Rulesets.Mania.Edit
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders);
public override PlacementBlueprint CreatePlacementBlueprint() => new HoldNotePlacementBlueprint();
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new HoldNotePlacementBlueprint();
}
}

View File

@ -46,7 +46,7 @@ namespace osu.Game.Rulesets.Mania.Edit
protected override BeatSnapGrid CreateBeatSnapGrid() => new ManiaBeatSnapGrid();
protected override IReadOnlyList<HitObjectCompositionTool> CompositionTools => new HitObjectCompositionTool[]
protected override IReadOnlyList<CompositionTool> CompositionTools => new CompositionTool[]
{
new NoteCompositionTool(),
new HoldNoteCompositionTool()

View File

@ -104,8 +104,10 @@ namespace osu.Game.Rulesets.Mania.Edit
int minColumn = int.MaxValue;
int maxColumn = int.MinValue;
var selectedObjects = EditorBeatmap.SelectedHitObjects.OfType<ManiaHitObject>().ToArray();
// find min/max in an initial pass before actually performing the movement.
foreach (var obj in EditorBeatmap.SelectedHitObjects.OfType<ManiaHitObject>())
foreach (var obj in selectedObjects)
{
if (obj.Column < minColumn)
minColumn = obj.Column;
@ -121,6 +123,13 @@ namespace osu.Game.Rulesets.Mania.Edit
((ManiaHitObject)h).Column += columnDelta;
maniaPlayfield.Add(h);
});
// `HitObjectUsageEventBuffer`'s usage transferal flows and the playfield's `SetKeepAlive()` functionality do not combine well with this operation's usage pattern,
// leading to selections being sometimes partially dropped if some of the objects being moved are off screen
// (check blame for detailed explanation).
// thus, ensure that selection is preserved manually.
EditorBeatmap.SelectedHitObjects.Clear();
EditorBeatmap.SelectedHitObjects.AddRange(selectedObjects);
}
}
}

View File

@ -10,7 +10,7 @@ using osu.Game.Rulesets.Mania.Objects;
namespace osu.Game.Rulesets.Mania.Edit
{
public class NoteCompositionTool : HitObjectCompositionTool
public class NoteCompositionTool : CompositionTool
{
public NoteCompositionTool()
: base(nameof(Note))
@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Mania.Edit
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles);
public override PlacementBlueprint CreatePlacementBlueprint() => new NotePlacementBlueprint();
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new NotePlacementBlueprint();
}
}

View File

@ -19,12 +19,12 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup
{
public override LocalisableString Title => EditorSetupStrings.DifficultyHeader;
private LabelledSliderBar<float> keyCountSlider { get; set; } = null!;
private LabelledSwitchButton specialStyle { get; set; } = null!;
private LabelledSliderBar<float> healthDrainSlider { get; set; } = null!;
private LabelledSliderBar<float> overallDifficultySlider { get; set; } = null!;
private LabelledSliderBar<double> baseVelocitySlider { get; set; } = null!;
private LabelledSliderBar<double> tickRateSlider { get; set; } = null!;
private FormSliderBar<float> keyCountSlider { get; set; } = null!;
private FormCheckBox specialStyle { get; set; } = null!;
private FormSliderBar<float> healthDrainSlider { get; set; } = null!;
private FormSliderBar<float> overallDifficultySlider { get; set; } = null!;
private FormSliderBar<double> baseVelocitySlider { get; set; } = null!;
private FormSliderBar<double> tickRateSlider { get; set; } = null!;
[Resolved]
private Editor? editor { get; set; }
@ -37,77 +37,81 @@ namespace osu.Game.Rulesets.Mania.Edit.Setup
{
Children = new Drawable[]
{
keyCountSlider = new LabelledSliderBar<float>
keyCountSlider = new FormSliderBar<float>
{
Label = BeatmapsetsStrings.ShowStatsCsMania,
FixedLabelWidth = LABEL_WIDTH,
Description = "The number of columns in the beatmap",
Caption = BeatmapsetsStrings.ShowStatsCsMania,
HintText = "The number of columns in the beatmap",
Current = new BindableFloat(Beatmap.Difficulty.CircleSize)
{
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0,
MaxValue = 10,
Precision = 1,
}
},
specialStyle = new LabelledSwitchButton
TransferValueOnCommit = true,
TabbableContentContainer = this,
},
specialStyle = new FormCheckBox
{
Label = "Use special (N+1) style",
FixedLabelWidth = LABEL_WIDTH,
Description = "Changes one column to act as a classic \"scratch\" or \"special\" column, which can be moved around by the user's skin (to the left/right/centre). Generally used in 6K (5+1) or 8K (7+1) configurations.",
Caption = "Use special (N+1) style",
HintText = "Changes one column to act as a classic \"scratch\" or \"special\" column, which can be moved around by the user's skin (to the left/right/centre). Generally used in 6K (5+1) or 8K (7+1) configurations.",
Current = { Value = Beatmap.BeatmapInfo.SpecialStyle }
},
healthDrainSlider = new LabelledSliderBar<float>
healthDrainSlider = new FormSliderBar<float>
{
Label = BeatmapsetsStrings.ShowStatsDrain,
FixedLabelWidth = LABEL_WIDTH,
Description = EditorSetupStrings.DrainRateDescription,
Caption = BeatmapsetsStrings.ShowStatsDrain,
HintText = EditorSetupStrings.DrainRateDescription,
Current = new BindableFloat(Beatmap.Difficulty.DrainRate)
{
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0,
MaxValue = 10,
Precision = 0.1f,
}
},
overallDifficultySlider = new LabelledSliderBar<float>
TransferValueOnCommit = true,
TabbableContentContainer = this,
},
overallDifficultySlider = new FormSliderBar<float>
{
Label = BeatmapsetsStrings.ShowStatsAccuracy,
FixedLabelWidth = LABEL_WIDTH,
Description = EditorSetupStrings.OverallDifficultyDescription,
Caption = BeatmapsetsStrings.ShowStatsAccuracy,
HintText = EditorSetupStrings.OverallDifficultyDescription,
Current = new BindableFloat(Beatmap.Difficulty.OverallDifficulty)
{
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0,
MaxValue = 10,
Precision = 0.1f,
}
},
baseVelocitySlider = new LabelledSliderBar<double>
TransferValueOnCommit = true,
TabbableContentContainer = this,
},
baseVelocitySlider = new FormSliderBar<double>
{
Label = EditorSetupStrings.BaseVelocity,
FixedLabelWidth = LABEL_WIDTH,
Description = EditorSetupStrings.BaseVelocityDescription,
Caption = EditorSetupStrings.BaseVelocity,
HintText = EditorSetupStrings.BaseVelocityDescription,
Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier)
{
Default = 1.4,
MinValue = 0.4,
MaxValue = 3.6,
Precision = 0.01f,
}
},
tickRateSlider = new LabelledSliderBar<double>
TransferValueOnCommit = true,
TabbableContentContainer = this,
},
tickRateSlider = new FormSliderBar<double>
{
Label = EditorSetupStrings.TickRate,
FixedLabelWidth = LABEL_WIDTH,
Description = EditorSetupStrings.TickRateDescription,
Caption = EditorSetupStrings.TickRate,
HintText = EditorSetupStrings.TickRateDescription,
Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate)
{
Default = 1,
MinValue = 1,
MaxValue = 4,
Precision = 1,
}
},
TransferValueOnCommit = true,
TabbableContentContainer = this,
},
};

View File

@ -419,9 +419,12 @@ namespace osu.Game.Rulesets.Mania
return new ManiaFilterCriteria();
}
public override IEnumerable<SetupSection> CreateEditorSetupSections() =>
public override IEnumerable<Drawable> CreateEditorSetupSections() =>
[
new MetadataSection(),
new ManiaDifficultySection(),
new ResourcesSection(),
new DesignSection(),
];
public int GetKeyCount(IBeatmapInfo beatmapInfo, IReadOnlyList<Mod>? mods = null)

View File

@ -14,6 +14,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
public partial class TestSceneHitCirclePlacementBlueprint : PlacementBlueprintTestScene
{
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableHitCircle((HitCircle)hitObject);
protected override PlacementBlueprint CreateBlueprint() => new HitCirclePlacementBlueprint();
protected override HitObjectPlacementBlueprint CreateBlueprint() => new HitCirclePlacementBlueprint();
}
}

View File

@ -116,7 +116,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
() => EditorBeatmap.HitObjects.OfType<HitCircle>().ElementAt(1).Position,
() => Is.EqualTo(OsuPlayfield.BASE_SIZE - new Vector2(200)));
AddStep("change rotation origin", () => getPopover().ChildrenOfType<EditorRadioButton>().ElementAt(1).TriggerClick());
AddStep("change rotation origin", () => getPopover().ChildrenOfType<EditorRadioButton>().ElementAt(2).TriggerClick());
AddAssert("first object rotated 90deg around selection centre",
() => EditorBeatmap.HitObjects.OfType<HitCircle>().ElementAt(0).Position, () => Is.EqualTo(new Vector2(200, 200)));
AddAssert("second object rotated 90deg around selection centre",

View File

@ -514,6 +514,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
private Slider? getSlider() => HitObjectContainer.Count > 0 ? ((DrawableSlider)HitObjectContainer[0]).HitObject : null;
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableSlider((Slider)hitObject);
protected override PlacementBlueprint CreateBlueprint() => new SliderPlacementBlueprint();
protected override HitObjectPlacementBlueprint CreateBlueprint() => new SliderPlacementBlueprint();
}
}

View File

@ -1,8 +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.
#nullable disable
using NUnit.Framework;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
@ -22,9 +20,9 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
{
public partial class TestSceneSliderSelectionBlueprint : SelectionBlueprintTestScene
{
private Slider slider;
private DrawableSlider drawableObject;
private TestSliderBlueprint blueprint;
private Slider slider = null!;
private DrawableSlider drawableObject = null!;
private TestSliderBlueprint blueprint = null!;
[SetUp]
public void Setup() => Schedule(() =>
@ -218,6 +216,9 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
AddAssert("tail positioned correctly",
() => Precision.AlmostEquals(blueprint.TailOverlay.CirclePiece.ScreenSpaceDrawQuad.Centre, drawableObject.TailCircle.ScreenSpaceDrawQuad.Centre));
AddAssert("end drag marker positioned correctly",
() => Precision.AlmostEquals(blueprint.TailOverlay.EndDragMarker!.ToScreenSpace(blueprint.TailOverlay.EndDragMarker.OriginPosition), drawableObject.TailCircle.ScreenSpaceDrawQuad.Centre, 2));
}
private void moveMouseToControlPoint(int index)
@ -230,14 +231,14 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
}
private void checkControlPointSelected(int index, bool selected)
=> AddAssert($"control point {index} {(selected ? "selected" : "not selected")}", () => blueprint.ControlPointVisualiser.Pieces[index].IsSelected.Value == selected);
=> AddAssert($"control point {index} {(selected ? "selected" : "not selected")}", () => blueprint.ControlPointVisualiser!.Pieces[index].IsSelected.Value == selected);
private partial class TestSliderBlueprint : SliderSelectionBlueprint
{
public new SliderBodyPiece BodyPiece => base.BodyPiece;
public new TestSliderCircleOverlay HeadOverlay => (TestSliderCircleOverlay)base.HeadOverlay;
public new TestSliderCircleOverlay TailOverlay => (TestSliderCircleOverlay)base.TailOverlay;
public new PathControlPointVisualiser<Slider> ControlPointVisualiser => base.ControlPointVisualiser;
public new PathControlPointVisualiser<Slider>? ControlPointVisualiser => base.ControlPointVisualiser;
public TestSliderBlueprint(Slider slider)
: base(slider)

View File

@ -15,6 +15,6 @@ namespace osu.Game.Rulesets.Osu.Tests.Editor
{
protected override DrawableHitObject CreateHitObject(HitObject hitObject) => new DrawableSpinner((Spinner)hitObject);
protected override PlacementBlueprint CreateBlueprint() => new SpinnerPlacementBlueprint();
protected override HitObjectPlacementBlueprint CreateBlueprint() => new SpinnerPlacementBlueprint();
}
}

View File

@ -15,22 +15,22 @@ namespace osu.Game.Rulesets.Osu.Tests
{
protected override string ResourceAssembly => "osu.Game.Rulesets.Osu.Tests";
[TestCase(6.710442985146793d, 239, "diffcalc-test")]
[TestCase(1.4386882251130073d, 54, "zero-length-sliders")]
[TestCase(0.42506480230838789d, 4, "very-fast-slider")]
[TestCase(0.14102693012101306d, 2, "nan-slider")]
[TestCase(6.7171144000821119d, 239, "diffcalc-test")]
[TestCase(1.4485749025771304d, 54, "zero-length-sliders")]
[TestCase(0.42630400627180914d, 4, "very-fast-slider")]
[TestCase(0.14143808967817237d, 2, "nan-slider")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name);
[TestCase(8.9742952703071666d, 239, "diffcalc-test")]
[TestCase(1.743180218215227d, 54, "zero-length-sliders")]
[TestCase(0.55071082800473514d, 4, "very-fast-slider")]
[TestCase(8.9825709931204205d, 239, "diffcalc-test")]
[TestCase(1.7550169162648608d, 54, "zero-length-sliders")]
[TestCase(0.55231632896800109d, 4, "very-fast-slider")]
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime());
[TestCase(6.710442985146793d, 239, "diffcalc-test")]
[TestCase(1.4386882251130073d, 54, "zero-length-sliders")]
[TestCase(0.42506480230838789d, 4, "very-fast-slider")]
[TestCase(6.7171144000821119d, 239, "diffcalc-test")]
[TestCase(1.4485749025771304d, 54, "zero-length-sliders")]
[TestCase(0.42630400627180914d, 4, "very-fast-slider")]
public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic());

View File

@ -2,6 +2,8 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
@ -10,8 +12,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
{
public static class RhythmEvaluator
{
private const int history_time_max = 5000; // 5 seconds of calculatingRhythmBonus max.
private const double rhythm_multiplier = 0.75;
private const int history_time_max = 5 * 1000; // 5 seconds
private const int history_objects_max = 32;
private const double rhythm_overall_multiplier = 0.95;
private const double rhythm_ratio_multiplier = 12.0;
/// <summary>
/// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current <see cref="OsuDifficultyHitObject"/>.
@ -21,15 +25,22 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
if (current.BaseObject is Spinner)
return 0;
int previousIslandSize = 0;
double rhythmComplexitySum = 0;
int islandSize = 1;
double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindowGreat * 0.3;
var island = new Island(deltaDifferenceEpsilon);
var previousIsland = new Island(deltaDifferenceEpsilon);
// we can't use dictionary here because we need to compare island with a tolerance
// which is impossible to pass into the hash comparer
var islandCounts = new List<(Island Island, int Count)>();
double startRatio = 0; // store the ratio of the current start of an island to buff for tighter rhythms
bool firstDeltaSwitch = false;
int historicalNoteCount = Math.Min(current.Index, 32);
int historicalNoteCount = Math.Min(current.Index, history_objects_max);
int rhythmStart = 0;
@ -39,74 +50,177 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
OsuDifficultyHitObject prevObj = (OsuDifficultyHitObject)current.Previous(rhythmStart);
OsuDifficultyHitObject lastObj = (OsuDifficultyHitObject)current.Previous(rhythmStart + 1);
// we go from the furthest object back to the current one
for (int i = rhythmStart; i > 0; i--)
{
OsuDifficultyHitObject currObj = (OsuDifficultyHitObject)current.Previous(i - 1);
double currHistoricalDecay = (history_time_max - (current.StartTime - currObj.StartTime)) / history_time_max; // scales note 0 to 1 from history to now
// scales note 0 to 1 from history to now
double timeDecay = (history_time_max - (current.StartTime - currObj.StartTime)) / history_time_max;
double noteDecay = (double)(historicalNoteCount - i) / historicalNoteCount;
currHistoricalDecay = Math.Min((double)(historicalNoteCount - i) / historicalNoteCount, currHistoricalDecay); // either we're limited by time or limited by object count.
double currHistoricalDecay = Math.Min(noteDecay, timeDecay); // either we're limited by time or limited by object count.
double currDelta = currObj.StrainTime;
double prevDelta = prevObj.StrainTime;
double lastDelta = lastObj.StrainTime;
double currRatio = 1.0 + 6.0 * Math.Min(0.5, Math.Pow(Math.Sin(Math.PI / (Math.Min(prevDelta, currDelta) / Math.Max(prevDelta, currDelta))), 2)); // fancy function to calculate rhythmbonuses.
double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - currObj.HitWindowGreat * 0.3) / (currObj.HitWindowGreat * 0.3));
// calculate how much current delta difference deserves a rhythm bonus
// this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200)
double deltaDifferenceRatio = Math.Min(prevDelta, currDelta) / Math.Max(prevDelta, currDelta);
double currRatio = 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, Math.Pow(Math.Sin(Math.PI / deltaDifferenceRatio), 2));
windowPenalty = Math.Min(1, windowPenalty);
// reduce ratio bonus if delta difference is too big
double fraction = Math.Max(prevDelta / currDelta, currDelta / prevDelta);
double fractionMultiplier = Math.Clamp(2.0 - fraction / 8.0, 0.0, 1.0);
double effectiveRatio = windowPenalty * currRatio;
double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon);
double effectiveRatio = windowPenalty * currRatio * fractionMultiplier;
if (firstDeltaSwitch)
{
if (!(prevDelta > 1.25 * currDelta || prevDelta * 1.25 < currDelta))
if (Math.Abs(prevDelta - currDelta) < deltaDifferenceEpsilon)
{
if (islandSize < 7)
islandSize++; // island is still progressing, count size.
// island is still progressing
island.AddDelta((int)currDelta);
}
else
{
if (currObj.BaseObject is Slider) // bpm change is into slider, this is easy acc window
// bpm change is into slider, this is easy acc window
if (currObj.BaseObject is Slider)
effectiveRatio *= 0.125;
if (prevObj.BaseObject is Slider) // bpm change was from a slider, this is easier typically than circle -> circle
effectiveRatio *= 0.25;
// bpm change was from a slider, this is easier typically than circle -> circle
// unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders
if (prevObj.BaseObject is Slider)
effectiveRatio *= 0.3;
if (previousIslandSize == islandSize) // repeated island size (ex: triplet -> triplet)
effectiveRatio *= 0.25;
// repeated island polarity (2 -> 4, 3 -> 5)
if (island.IsSimilarPolarity(previousIsland))
effectiveRatio *= 0.5;
if (previousIslandSize % 2 == islandSize % 2) // repeated island polartiy (2 -> 4, 3 -> 5)
effectiveRatio *= 0.50;
if (lastDelta > prevDelta + 10 && prevDelta > currDelta + 10) // previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this.
// previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this.
if (lastDelta > prevDelta + deltaDifferenceEpsilon && prevDelta > currDelta + deltaDifferenceEpsilon)
effectiveRatio *= 0.125;
rhythmComplexitySum += Math.Sqrt(effectiveRatio * startRatio) * currHistoricalDecay * Math.Sqrt(4 + islandSize) / 2 * Math.Sqrt(4 + previousIslandSize) / 2;
// repeated island size (ex: triplet -> triplet)
// TODO: remove this nerf since its staying here only for balancing purposes because of the flawed ratio calculation
if (previousIsland.DeltaCount == island.DeltaCount)
effectiveRatio *= 0.5;
var islandCount = islandCounts.FirstOrDefault(x => x.Island.Equals(island));
if (islandCount != default)
{
int countIndex = islandCounts.IndexOf(islandCount);
// only add island to island counts if they're going one after another
if (previousIsland.Equals(island))
islandCount.Count++;
// repeated island (ex: triplet -> triplet)
double power = logistic(island.Delta, 2.75, 0.24, 14);
effectiveRatio *= Math.Min(3.0 / islandCount.Count, Math.Pow(1.0 / islandCount.Count, power));
islandCounts[countIndex] = (islandCount.Island, islandCount.Count);
}
else
{
islandCounts.Add((island, 1));
}
// scale down the difficulty if the object is doubletappable
double doubletapness = prevObj.GetDoubletapness(currObj);
effectiveRatio *= 1 - doubletapness * 0.75;
rhythmComplexitySum += Math.Sqrt(effectiveRatio * startRatio) * currHistoricalDecay;
startRatio = effectiveRatio;
previousIslandSize = islandSize; // log the last island size.
previousIsland = island;
if (prevDelta * 1.25 < currDelta) // we're slowing down, stop counting
if (prevDelta + deltaDifferenceEpsilon < currDelta) // we're slowing down, stop counting
firstDeltaSwitch = false; // if we're speeding up, this stays true and we keep counting island size.
islandSize = 1;
island = new Island((int)currDelta, deltaDifferenceEpsilon);
}
}
else if (prevDelta > 1.25 * currDelta) // we want to be speeding up.
else if (prevDelta > currDelta + deltaDifferenceEpsilon) // we're speeding up
{
// Begin counting island until we change speed again.
firstDeltaSwitch = true;
// bpm change is into slider, this is easy acc window
if (currObj.BaseObject is Slider)
effectiveRatio *= 0.6;
// bpm change was from a slider, this is easier typically than circle -> circle
// unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders
if (prevObj.BaseObject is Slider)
effectiveRatio *= 0.6;
startRatio = effectiveRatio;
islandSize = 1;
island = new Island((int)currDelta, deltaDifferenceEpsilon);
}
lastObj = prevObj;
prevObj = currObj;
}
return Math.Sqrt(4 + rhythmComplexitySum * rhythm_multiplier) / 2; //produces multiplier that can be applied to strain. range [1, infinity) (not really though)
return Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though)
}
private static double logistic(double x, double maxValue, double multiplier, double offset) => (maxValue / (1 + Math.Pow(Math.E, offset - (multiplier * x))));
private class Island : IEquatable<Island>
{
private readonly double deltaDifferenceEpsilon;
public Island(double epsilon)
{
deltaDifferenceEpsilon = epsilon;
}
public Island(int delta, double epsilon)
{
deltaDifferenceEpsilon = epsilon;
Delta = Math.Max(delta, OsuDifficultyHitObject.MIN_DELTA_TIME);
DeltaCount++;
}
public int Delta { get; private set; } = int.MaxValue;
public int DeltaCount { get; private set; }
public void AddDelta(int delta)
{
if (Delta == int.MaxValue)
Delta = Math.Max(delta, OsuDifficultyHitObject.MIN_DELTA_TIME);
DeltaCount++;
}
public bool IsSimilarPolarity(Island other)
{
// TODO: consider islands to be of similar polarity only if they're having the same average delta (we don't want to consider 3 singletaps similar to a triple)
// naively adding delta check here breaks _a lot_ of maps because of the flawed ratio calculation
return DeltaCount % 2 == other.DeltaCount % 2;
}
public bool Equals(Island? other)
{
if (other == null)
return false;
return Math.Abs(Delta - other.Delta) < deltaDifferenceEpsilon &&
DeltaCount == other.DeltaCount;
}
public override string ToString()
{
return $"{Delta}x{DeltaCount}";
}
}
}
}

View File

@ -13,7 +13,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
private const double single_spacing_threshold = 125; // 1.25 circles distance between centers
private const double min_speed_bonus = 75; // ~200BPM
private const double speed_balancing_factor = 40;
private const double distance_multiplier = 0.94; // WARNING - DECREASE DISTANCE MULTIPLIER TO AVOID JASHIN BUFF
private const double distance_multiplier = 0.94;
/// <summary>
/// Evaluates the difficulty of tapping the current object, based on:
@ -31,21 +31,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
// derive strainTime for calculation
var osuCurrObj = (OsuDifficultyHitObject)current;
var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null;
var osuNextObj = (OsuDifficultyHitObject?)current.Next(0);
double strainTime = osuCurrObj.StrainTime;
double doubletapness = 1;
// Nerf doubletappable doubles.
if (osuNextObj != null)
{
double currDeltaTime = Math.Max(1, osuCurrObj.DeltaTime);
double nextDeltaTime = Math.Max(1, osuNextObj.DeltaTime);
double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime);
double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference);
double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / osuCurrObj.HitWindowGreat), 2);
doubletapness = Math.Pow(speedRatio, 1 - windowRatio);
}
double doubletapness = 1.0 - osuCurrObj.GetDoubletapness((OsuDifficultyHitObject?)osuCurrObj.Next(0));
// Cap deltatime to the OD 300 hitwindow.
// 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap.
@ -68,7 +56,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double distanceBonus = Math.Pow(distance / single_spacing_threshold, 3.95) * distance_multiplier;
// Base difficulty with all bonuses
// WARNING - CHANGED TO ADDITIVE TO AVOID AKOLIBED BUFF
double difficulty = (1 + speedBonus + distanceBonus) * 1000 / strainTime;
// Apply penalty if there's doubletappable doubles

View File

@ -64,6 +64,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("slider_factor")]
public double SliderFactor { get; set; }
[JsonProperty("aim_difficult_strain_count")]
public double AimDifficultStrainCount { get; set; }
[JsonProperty("speed_difficult_strain_count")]
public double SpeedDifficultStrainCount { get; set; }
/// <summary>
/// The perceived approach rate inclusive of rate-adjusting mods (DT/HT/etc).
/// </summary>
@ -114,6 +120,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
yield return (ATTRIB_ID_DIFFICULTY, StarRating);
yield return (ATTRIB_ID_FLASHLIGHT, FlashlightDifficulty);
yield return (ATTRIB_ID_SLIDER_FACTOR, SliderFactor);
yield return (ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT, AimDifficultStrainCount);
yield return (ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT, SpeedDifficultStrainCount);
yield return (ATTRIB_ID_SPEED_NOTE_COUNT, SpeedNoteCount);
}
@ -128,8 +137,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
StarRating = values[ATTRIB_ID_DIFFICULTY];
FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT);
SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR];
AimDifficultStrainCount = values[ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT];
SpeedDifficultStrainCount = values[ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT];
SpeedNoteCount = values[ATTRIB_ID_SPEED_NOTE_COUNT];
DrainRate = onlineInfo.DrainRate;
HitCircleCount = onlineInfo.CircleCount;
SliderCount = onlineInfo.SliderCount;

View File

@ -63,6 +63,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{
baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating);
}
double aimDifficultyStrainCount = ((OsuStrainSkill)skills[0]).CountDifficultStrains();
double speedDifficultyStrainCount = ((OsuStrainSkill)skills[2]).CountDifficultStrains();
if (mods.Any(m => m is OsuModTouchDevice))
{
@ -96,7 +98,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate;
double drainRate = beatmap.Difficulty.DrainRate;
int maxCombo = beatmap.GetMaxCombo();
int hitCirclesCount = beatmap.HitObjects.Count(h => h is HitCircle);
int sliderCount = beatmap.HitObjects.Count(h => h is Slider);
@ -132,10 +133,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty
HiddenDifficulty = hiddenRating,
FlashlightDifficulty = flashlightRating,
SliderFactor = sliderFactor,
AimDifficultStrainCount = aimDifficultyStrainCount,
SpeedDifficultStrainCount = speedDifficultyStrainCount,
ApproachRate = IBeatmapDifficultyInfo.InverseDifficultyRange(preempt, 1800, 1200, 450),
OverallDifficulty = (80 - hitWindowGreat) / 6,
DrainRate = drainRate,
MaxCombo = maxCombo,
MaxCombo = beatmap.GetMaxCombo(),
HitCircleCount = hitCirclesCount,
SliderCount = sliderCount,
SpinnerCount = spinnerCount,

View File

@ -14,7 +14,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{
public class OsuPerformanceCalculator : PerformanceCalculator
{
public const double PERFORMANCE_BASE_MULTIPLIER = 1.14; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things.
public const double PERFORMANCE_BASE_MULTIPLIER = 1.15; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things.
private bool usingClassicSliderAccuracy;
private double accuracy;
private int scoreMaxCombo;
@ -34,6 +36,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
{
var osuAttributes = (OsuDifficultyAttributes)attributes;
usingClassicSliderAccuracy = score.Mods.OfType<OsuModClassic>().Any(m => m.NoSliderHeadAccuracy.Value);
accuracy = score.Accuracy;
scoreMaxCombo = score.MaxCombo;
countGreat = score.Statistics.GetValueOrDefault(HitResult.Great);
@ -133,11 +137,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double lengthBonus = CalculateDefaultLengthBonus(totalHits);
aimValue *= lengthBonus;
// Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
if (effectiveMissCount > 0)
aimValue *= 0.97 * Math.Pow(1 - Math.Pow(effectiveMissCount / totalHits, 0.775), effectiveMissCount);
aimValue *= getComboScalingFactor(attributes);
aimValue *= calculateMissPenalty(effectiveMissCount, attributes.AimDifficultStrainCount);
if (score.Mods.Any(m => m is OsuModBlinds))
aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * attributes.DrainRate * attributes.DrainRate);
@ -174,11 +175,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double lengthBonus = CalculateDefaultLengthBonus(totalHits);
speedValue *= lengthBonus;
// Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
if (effectiveMissCount > 0)
speedValue *= 0.97 * Math.Pow(1 - Math.Pow(effectiveMissCount / totalHits, 0.775), Math.Pow(effectiveMissCount, .875));
speedValue *= getComboScalingFactor(attributes);
speedValue *= calculateMissPenalty(effectiveMissCount, attributes.SpeedDifficultStrainCount);
if (score.Mods.Any(m => m is OsuModBlinds))
{
@ -199,7 +197,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double relevantAccuracy = attributes.SpeedNoteCount == 0 ? 0 : (relevantCountGreat * 6.0 + relevantCountOk * 2.0 + relevantCountMeh) / (attributes.SpeedNoteCount * 6.0);
// Scale the speed value with accuracy and OD.
speedValue *= (0.95 + Math.Pow(attributes.OverallDifficulty, 2) / 750) * Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - Math.Max(attributes.OverallDifficulty, 8)) / 2);
speedValue *= (0.95 + Math.Pow(attributes.OverallDifficulty, 2) / 750) * Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - attributes.OverallDifficulty) / 2);
// Scale the speed value with # of 50s to punish doubletapping.
speedValue *= Math.Pow(0.99, countMeh < totalHits / 500.0 ? 0 : countMeh - totalHits / 500.0);
@ -215,6 +213,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
// This percentage only considers HitCircles of any value - in this part of the calculation we focus on hitting the timing hit window.
double betterAccuracyPercentage;
int amountHitObjectsWithAccuracy = attributes.HitCircleCount;
if (!usingClassicSliderAccuracy)
amountHitObjectsWithAccuracy += attributes.SliderCount;
if (amountHitObjectsWithAccuracy > 0)
betterAccuracyPercentage = ((countGreat - (totalHits - amountHitObjectsWithAccuracy)) * 6 + countOk * 2 + countMeh) / (double)(amountHitObjectsWithAccuracy * 6);
@ -442,6 +442,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty
return 1 + result;
}
// Miss penalty assumes that a player will miss on the hardest parts of a map,
// so we use the amount of relatively difficult sections to adjust miss penalty
// to make it more punishing on maps with lower amount of hard sections.
private double calculateMissPenalty(double missCount, double difficultStrainCount) => 0.96 / ((missCount / (4 * Math.Pow(Math.Log(difficultStrainCount), 0.94))) + 1);
private double getComboScalingFactor(OsuDifficultyAttributes attributes) => attributes.MaxCombo <= 0 ? 1.0 : Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(attributes.MaxCombo, 0.8), 1.0);
private int totalHits => countGreat + countOk + countMeh + countMiss;

View File

@ -22,7 +22,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
/// </summary>
public const int NORMALISED_RADIUS = 50; // Change radius to 50 to make 100 the diameter. Easier for mental maths.
private const int min_delta_time = 25;
public const int MIN_DELTA_TIME = 25;
private const float maximum_slider_radius = NORMALISED_RADIUS * 2.4f;
private const float assumed_slider_radius = NORMALISED_RADIUS * 1.8f;
@ -152,7 +153,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
ClockRate = clockRate;
// Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
StrainTime = Math.Max(DeltaTime, min_delta_time);
StrainTime = Math.Max(DeltaTime, MIN_DELTA_TIME);
if (BaseObject is Slider sliderObject)
{
@ -509,6 +510,24 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
return Math.Clamp((time - fadeInStartTime) / fadeInDuration, 0.0, 1.0);
}
/// <summary>
/// Returns how possible is it to doubletap this object together with the next one and get perfect judgement in range from 0 to 1
/// </summary>
public double GetDoubletapness(OsuDifficultyHitObject? osuNextObj)
{
if (osuNextObj != null)
{
double currDeltaTime = Math.Max(1, DeltaTime);
double nextDeltaTime = Math.Max(1, osuNextObj.DeltaTime);
double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime);
double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference);
double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / HitWindowGreat), 2);
return 1.0 - Math.Pow(speedRatio, 1 - windowRatio);
}
return 0;
}
private void setDistances(double clockRate)
{
if (BaseObject is Slider currentSlider)
@ -516,7 +535,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
computeSliderCursorPosition(currentSlider);
// Bonus for repeat sliders until a better per nested object strain system can be achieved.
TravelDistance = currentSlider.LazyTravelDistance * (float)Math.Pow(1 + currentSlider.RepeatCount / 2.5, 1.0 / 2.5);
TravelTime = Math.Max(currentSlider.LazyTravelTime / clockRate, min_delta_time);
TravelTime = Math.Max(currentSlider.LazyTravelTime / clockRate, MIN_DELTA_TIME);
}
// We don't need to calculate either angle or distance when one of the last->curr objects is a spinner
@ -540,8 +559,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
if (lastObject is Slider lastSlider)
{
double lastTravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, min_delta_time);
MinimumJumpTime = Math.Max(StrainTime - lastTravelTime, min_delta_time);
double lastTravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, MIN_DELTA_TIME);
MinimumJumpTime = Math.Max(StrainTime - lastTravelTime, MIN_DELTA_TIME);
//
// There are two types of slider-to-object patterns to consider in order to better approximate the real movement a player will take to jump between the hitobjects.

View File

@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
private readonly bool withSliders;
protected double CurrentStrain;
protected double SkillMultiplier => 25.15; // WARNING - INCREASED FROM 24.963 FOR BALANCING
protected double SkillMultiplier => 25.18; // WARNING - INCREASED FROM 24.963 FOR BALANCING
protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => CurrentStrain * StrainDecay(time - current.Previous(0).StartTime);
@ -30,6 +30,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
{
CurrentStrain *= StrainDecay(current.DeltaTime);
CurrentStrain += AimEvaluator.EvaluateDifficultyOf(current, withSliders) * SkillMultiplier;
ObjectStrains.Add(currentStrain);
return CurrentStrain;
}

View File

@ -26,6 +26,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
protected virtual double StrainDecayBase => 0.15;
protected double StrainDecay(double ms) => Math.Pow(StrainDecayBase, ms / 1000);
protected List<double> ObjectStrains = new List<double>();
protected double Difficulty;
protected OsuStrainSkill(Mod[] mods)
: base(mods)
@ -34,7 +36,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
public override double DifficultyValue()
{
double difficulty = 0;
Difficulty = 0;
double weight = 1;
// Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
@ -54,11 +56,25 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
// We're sorting from highest to lowest strain.
foreach (double strain in strains.OrderDescending())
{
difficulty += strain * weight;
Difficulty += strain * weight;
weight *= DecayWeight;
}
return difficulty;
return Difficulty;
}
/// <summary>
/// Returns the number of strains weighted against the top strain.
/// The result is scaled by clock rate as it affects the total number of strains.
/// </summary>
public double CountDifficultStrains()
{
if (Difficulty == 0)
return 0.0;
double consistentTopStrain = Difficulty / 10; // What would the top strain be if all strain values were identical
// Use a weighted sum of all strains. Constants are arbitrary and give nice values
return ObjectStrains.Sum(s => 1.1 / (1 + Math.Exp(-10 * (s / consistentTopStrain - 0.88))));
}
/// <summary>

View File

@ -6,7 +6,6 @@ using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Evaluators;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using System.Collections.Generic;
using System.Linq;
namespace osu.Game.Rulesets.Osu.Difficulty.Skills
@ -24,8 +23,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
protected override int ReducedSectionCount => 5;
private readonly List<double> objectStrains = new List<double>();
public Speed(Mod[] mods)
: base(mods)
{
@ -43,22 +40,21 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
CurrentRhythm = currODHO.RhythmDifficulty;
double totalStrain = CurrentStrain * CurrentRhythm;
objectStrains.Add(totalStrain);
ObjectStrains.Add(totalStrain);
return totalStrain;
}
public double RelevantNoteCount()
{
if (objectStrains.Count == 0)
if (ObjectStrains.Count == 0)
return 0;
double maxStrain = objectStrains.Max();
double maxStrain = ObjectStrains.Max();
if (maxStrain == 0)
return 0;
return objectStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0))));
return ObjectStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0))));
}
}
}

View File

@ -9,7 +9,7 @@ using osuTK.Input;
namespace osu.Game.Rulesets.Osu.Edit.Blueprints.HitCircles
{
public partial class HitCirclePlacementBlueprint : PlacementBlueprint
public partial class HitCirclePlacementBlueprint : HitObjectPlacementBlueprint
{
public new HitCircle HitObject => (HitCircle)base.HitObject;

View File

@ -107,7 +107,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
if (segment.Count == 0)
return;
var first = segment[0];
PathControlPoint first = segment[0];
if (first.Type != PathType.PERFECT_CURVE)
return;
@ -273,10 +273,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
if (selectedPieces.Length != 1)
return false;
var selectedPiece = selectedPieces.Single();
var selectedPoint = selectedPiece.ControlPoint;
PathControlPointPiece<T> selectedPiece = selectedPieces.Single();
PathControlPoint selectedPoint = selectedPiece.ControlPoint;
var validTypes = path_types;
PathType?[] validTypes = path_types;
if (selectedPoint == controlPoints[0])
validTypes = validTypes.Where(t => t != null).ToArray();
@ -313,7 +313,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
if (Pieces.All(p => !p.IsSelected.Value))
return false;
var type = path_types[e.Key - Key.Number1];
PathType? type = path_types[e.Key - Key.Number1];
// The first control point can never be inherit type
if (Pieces[0].IsSelected.Value && type == null)
@ -353,9 +353,11 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
{
changeHandler?.BeginChange();
double originalDistance = hitObject.Path.Distance;
foreach (var p in Pieces.Where(p => p.IsSelected.Value))
{
var pointsInSegment = hitObject.Path.PointsInSegment(p.ControlPoint);
List<PathControlPoint> pointsInSegment = hitObject.Path.PointsInSegment(p.ControlPoint);
int indexInSegment = pointsInSegment.IndexOf(p.ControlPoint);
if (type?.Type == SplineType.PerfectCurve)
@ -375,6 +377,11 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
EnsureValidPathTypes();
if (hitObject.Path.Distance < originalDistance)
hitObject.SnapTo(distanceSnapProvider);
else
hitObject.Path.ExpectedDistance.Value = originalDistance;
changeHandler?.EndChange();
}
@ -405,14 +412,14 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
public void DragInProgress(DragEvent e)
{
Vector2[] oldControlPoints = hitObject.Path.ControlPoints.Select(cp => cp.Position).ToArray();
var oldPosition = hitObject.Position;
Vector2 oldPosition = hitObject.Position;
double oldStartTime = hitObject.StartTime;
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 = positionSnapProvider?.FindSnappedPositionAndTime(newHeadPosition);
SnapResult result = positionSnapProvider?.FindSnappedPositionAndTime(newHeadPosition);
Vector2 movementDelta = Parent!.ToLocalSpace(result?.ScreenSpacePosition ?? newHeadPosition) - hitObject.Position;
@ -421,7 +428,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
for (int i = 1; i < hitObject.Path.ControlPoints.Count; i++)
{
var controlPoint = hitObject.Path.ControlPoints[i];
PathControlPoint controlPoint = hitObject.Path.ControlPoints[i];
// Since control points are relative to the position of the hit object, all points that are _not_ selected
// need to be offset _back_ by the delta corresponding to the movement of the head point.
// All other selected control points (if any) will move together with the head point
@ -432,13 +439,13 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
}
else
{
var result = positionSnapProvider?.FindSnappedPositionAndTime(Parent!.ToScreenSpace(e.MousePosition), SnapType.GlobalGrids);
SnapResult result = positionSnapProvider?.FindSnappedPositionAndTime(Parent!.ToScreenSpace(e.MousePosition), SnapType.GlobalGrids);
Vector2 movementDelta = Parent!.ToLocalSpace(result?.ScreenSpacePosition ?? Parent!.ToScreenSpace(e.MousePosition)) - dragStartPositions[draggedControlPointIndex] - hitObject.Position;
for (int i = 0; i < controlPoints.Count; ++i)
{
var controlPoint = controlPoints[i];
PathControlPoint controlPoint = controlPoints[i];
if (selectedControlPoints.Contains(controlPoint))
controlPoint.Position = dragStartPositions[i] + movementDelta;
}
@ -488,8 +495,10 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
curveTypeItems = new List<MenuItem>();
foreach (PathType? type in path_types)
for (int i = 0; i < path_types.Length; ++i)
{
PathType? type = path_types[i];
// special inherit case
if (type == null)
{
@ -499,7 +508,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
curveTypeItems.Add(new OsuMenuItemSpacer());
}
curveTypeItems.Add(createMenuItemForPathType(type));
curveTypeItems.Add(createMenuItemForPathType(type, InputKey.Number1 + i));
}
if (selectedPieces.Any(piece => piece.ControlPoint.Type?.Type == SplineType.Catmull))
@ -533,7 +542,15 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
return menuItems.ToArray();
CurveTypeMenuItem createMenuItemForPathType(PathType? type) => new CurveTypeMenuItem(type, _ => updatePathTypeOfSelectedPieces(type));
CurveTypeMenuItem createMenuItemForPathType(PathType? type, InputKey? key = null)
{
Hotkey hotkey = default;
if (key != null)
hotkey = new Hotkey(new KeyCombination(InputKey.Alt, key.Value));
return new CurveTypeMenuItem(type, _ => updatePathTypeOfSelectedPieces(type)) { Hotkey = hotkey };
}
}
}

View File

@ -70,7 +70,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
if (endDragMarkerContainer != null)
{
endDragMarkerContainer.Position = circle.Position;
endDragMarkerContainer.Position = circle.Position + slider.StackOffset;
endDragMarkerContainer.Scale = CirclePiece.Scale * 1.2f;
var diff = slider.Path.PositionAt(1) - slider.Path.PositionAt(0.99f);
endDragMarkerContainer.Rotation = float.RadiansToDegrees(MathF.Atan2(diff.Y, diff.X));

View File

@ -21,7 +21,7 @@ using osuTK.Input;
namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
{
public partial class SliderPlacementBlueprint : PlacementBlueprint
public partial class SliderPlacementBlueprint : HitObjectPlacementBlueprint
{
public new Slider HitObject => (Slider)base.HitObject;
@ -401,7 +401,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
if (state == SliderPlacementState.Drawing)
HitObject.Path.ExpectedDistance.Value = (float)HitObject.Path.CalculatedDistance;
else
HitObject.Path.ExpectedDistance.Value = distanceSnapProvider?.FindSnappedDistance(HitObject, (float)HitObject.Path.CalculatedDistance) ?? (float)HitObject.Path.CalculatedDistance;
HitObject.Path.ExpectedDistance.Value = distanceSnapProvider?.FindSnappedDistance(HitObject, (float)HitObject.Path.CalculatedDistance, DistanceSnapTarget.Start) ?? (float)HitObject.Path.CalculatedDistance;
bodyPiece.UpdateFrom(HitObject);
headCirclePiece.UpdateFrom(HitObject.HeadCircle);

View File

@ -11,6 +11,7 @@ using osu.Framework.Caching;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Utils;
using osu.Game.Audio;
@ -269,7 +270,7 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
{
double minDistance = distanceSnapProvider?.GetBeatSnapDistanceAt(HitObject, false) * oldVelocityMultiplier ?? 1;
// Add a small amount to the proposed distance to make it easier to snap to the full length of the slider.
proposedDistance = distanceSnapProvider?.FindSnappedDistance(HitObject, (float)proposedDistance + 1) ?? proposedDistance;
proposedDistance = distanceSnapProvider?.FindSnappedDistance(HitObject, (float)proposedDistance + 1, DistanceSnapTarget.Start) ?? proposedDistance;
proposedDistance = MathHelper.Clamp(proposedDistance, minDistance, HitObject.Path.CalculatedDistance);
}
@ -593,8 +594,14 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
changeHandler?.BeginChange();
addControlPoint(lastRightClickPosition);
changeHandler?.EndChange();
}),
new OsuMenuItem("Convert to stream", MenuItemType.Destructive, convertToStream),
})
{
Hotkey = new Hotkey(new KeyCombination(InputKey.Control, InputKey.MouseLeft))
},
new OsuMenuItem("Convert to stream", MenuItemType.Destructive, convertToStream)
{
Hotkey = new Hotkey(new KeyCombination(InputKey.Control, InputKey.Shift, InputKey.F))
},
};
// Always refer to the drawable object's slider body so subsequent movement deltas are calculated with updated positions.

View File

@ -13,7 +13,7 @@ using osuTK.Input;
namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Spinners
{
public partial class SpinnerPlacementBlueprint : PlacementBlueprint
public partial class SpinnerPlacementBlueprint : HitObjectPlacementBlueprint
{
public new Spinner HitObject => (Spinner)base.HitObject;

View File

@ -10,7 +10,7 @@ using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Edit
{
public class HitCircleCompositionTool : HitObjectCompositionTool
public class HitCircleCompositionTool : CompositionTool
{
public HitCircleCompositionTool()
: base(nameof(HitCircle))
@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Osu.Edit
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles);
public override PlacementBlueprint CreatePlacementBlueprint() => new HitCirclePlacementBlueprint();
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new HitCirclePlacementBlueprint();
}
}

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.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
@ -10,10 +11,12 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Framework.Utils;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input.Bindings;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Components.RadioButtons;
@ -90,6 +93,8 @@ namespace osu.Game.Rulesets.Osu.Edit
private ExpandableSlider<float> gridLinesRotationSlider = null!;
private EditorRadioButtonCollection gridTypeButtons = null!;
private ExpandableButton useSelectedObjectPositionButton = null!;
public OsuGridToolboxGroup()
: base("grid")
{
@ -112,6 +117,20 @@ namespace osu.Game.Rulesets.Osu.Edit
Current = StartPositionY,
KeyboardStep = 1,
},
useSelectedObjectPositionButton = new ExpandableButton
{
ExpandedLabelText = "Centre on selected object",
Action = () =>
{
if (editorBeatmap.SelectedHitObjects.Count != 1)
return;
var position = ((IHasPosition)editorBeatmap.SelectedHitObjects.Single()).Position;
StartPosition.Value = new Vector2(MathF.Round(position.X), MathF.Round(position.Y));
updateEnabledStates();
},
RelativeSizeAxes = Axes.X,
},
spacingSlider = new ExpandableSlider<float>
{
Current = Spacing,
@ -172,6 +191,13 @@ namespace osu.Game.Rulesets.Osu.Edit
StartPosition.Value = new Vector2(StartPosition.Value.X, y.NewValue);
}, true);
StartPosition.BindValueChanged(pos =>
{
StartPositionX.Value = pos.NewValue.X;
StartPositionY.Value = pos.NewValue.Y;
updateEnabledStates();
});
Spacing.BindValueChanged(spacing =>
{
spacingSlider.ContractedLabelText = $"S: {spacing.NewValue:N0}";
@ -186,12 +212,6 @@ namespace osu.Game.Rulesets.Osu.Edit
gridLinesRotationSlider.ExpandedLabelText = $"Rotation: {rotation.NewValue:#,0.##}";
}, true);
expandingContainer?.Expanded.BindValueChanged(v =>
{
gridTypeButtons.FadeTo(v.NewValue ? 1f : 0f, 500, Easing.OutQuint);
gridTypeButtons.BypassAutoSizeAxes = !v.NewValue ? Axes.Y : Axes.None;
}, true);
GridType.BindValueChanged(v =>
{
GridLinesRotation.Disabled = v.NewValue == PositionSnapGridType.Circle;
@ -211,6 +231,22 @@ namespace osu.Game.Rulesets.Osu.Edit
break;
}
}, true);
editorBeatmap.BeatmapReprocessed += updateEnabledStates;
editorBeatmap.SelectedHitObjects.BindCollectionChanged((_, _) => updateEnabledStates());
expandingContainer?.Expanded.BindValueChanged(v =>
{
gridTypeButtons.FadeTo(v.NewValue ? 1f : 0f, 500, Easing.OutQuint);
gridTypeButtons.BypassAutoSizeAxes = !v.NewValue ? Axes.Y : Axes.None;
updateEnabledStates();
}, true);
}
private void updateEnabledStates()
{
useSelectedObjectPositionButton.Enabled.Value = expandingContainer?.Expanded.Value == true
&& editorBeatmap.SelectedHitObjects.Count == 1
&& !Precision.AlmostEquals(StartPosition.Value, ((IHasPosition)editorBeatmap.SelectedHitObjects.Single()).Position, 0.5f);
}
private void nextGridSize()

View File

@ -41,7 +41,7 @@ namespace osu.Game.Rulesets.Osu.Edit
protected override DrawableRuleset<OsuHitObject> CreateDrawableRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList<Mod> mods)
=> new DrawableOsuEditorRuleset(ruleset, beatmap, mods);
protected override IReadOnlyList<HitObjectCompositionTool> CompositionTools => new HitObjectCompositionTool[]
protected override IReadOnlyList<CompositionTool> CompositionTools => new CompositionTool[]
{
new HitCircleCompositionTool(),
new SliderCompositionTool(),
@ -106,6 +106,7 @@ namespace osu.Game.Rulesets.Osu.Edit
{
RotationHandler = BlueprintContainer.SelectionHandler.RotationHandler,
ScaleHandler = (OsuSelectionScaleHandler)BlueprintContainer.SelectionHandler.ScaleHandler,
GridToolbox = OsuGridToolboxGroup,
},
new GenerateToolboxGroup(),
FreehandSliderToolboxGroup
@ -368,6 +369,8 @@ namespace osu.Game.Rulesets.Osu.Edit
gridSnapMomentary = shiftPressed;
rectangularGridSnapToggle.Value = rectangularGridSnapToggle.Value == TernaryState.False ? TernaryState.True : TernaryState.False;
}
DistanceSnapProvider.HandleToggleViaKey(key);
}
private DistanceSnapGrid createDistanceSnapGrid(IEnumerable<HitObject> selectedHitObjects)

View File

@ -3,6 +3,7 @@
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Primitives;
using osu.Framework.Graphics.UserInterface;
@ -25,6 +26,9 @@ namespace osu.Game.Rulesets.Osu.Edit
{
public partial class OsuSelectionHandler : EditorSelectionHandler
{
[Resolved]
private OsuGridToolboxGroup gridToolbox { get; set; } = null!;
protected override void OnSelectionChanged()
{
base.OnSelectionChanged();
@ -123,13 +127,43 @@ namespace osu.Game.Rulesets.Osu.Edit
{
var hitObjects = selectedMovableObjects;
var flipQuad = flipOverOrigin ? new Quad(0, 0, OsuPlayfield.BASE_SIZE.X, OsuPlayfield.BASE_SIZE.Y) : GeometryUtils.GetSurroundingQuad(hitObjects);
// If we're flipping over the origin, we take the grid origin position from the grid toolbox.
var flipQuad = flipOverOrigin ? new Quad(gridToolbox.StartPositionX.Value, gridToolbox.StartPositionY.Value, 0, 0) : GeometryUtils.GetSurroundingQuad(hitObjects);
Vector2 flipAxis = direction == Direction.Vertical ? Vector2.UnitY : Vector2.UnitX;
if (flipOverOrigin)
{
// If we're flipping over the origin, we take one of the axes of the grid.
// Take the axis closest to the direction we want to flip over.
switch (gridToolbox.GridType.Value)
{
case PositionSnapGridType.Square:
flipAxis = GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 360 + 45) % 90 - 45));
flipAxis = direction == Direction.Vertical ? flipAxis.PerpendicularLeft : flipAxis;
break;
case PositionSnapGridType.Triangle:
// Hex grid has 3 axes, so you can not directly flip over one of the axes,
// however it's still possible to achieve that flip by combining multiple flips over the other axes.
// Angle degree range for vertical = (-120, -60]
// Angle degree range for horizontal = [-30, 30)
flipAxis = direction == Direction.Vertical
? GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 360 + 30) % 60 + 60))
: GeometryUtils.RotateVector(Vector2.UnitX, -((gridToolbox.GridLinesRotation.Value + 360) % 60 - 30));
break;
}
}
var controlPointFlipQuad = new Quad();
bool didFlip = false;
foreach (var h in hitObjects)
{
var flippedPosition = GeometryUtils.GetFlippedPosition(direction, flipQuad, h.Position);
var flippedPosition = GeometryUtils.GetFlippedPosition(flipAxis, flipQuad, h.Position);
// Clamp the flipped position inside the playfield bounds, because the flipped position might be outside the playfield bounds if the origin is not centered.
flippedPosition = Vector2.Clamp(flippedPosition, Vector2.Zero, OsuPlayfield.BASE_SIZE);
if (!Precision.AlmostEquals(flippedPosition, h.Position))
{
@ -142,12 +176,7 @@ namespace osu.Game.Rulesets.Osu.Edit
didFlip = true;
foreach (var cp in slider.Path.ControlPoints)
{
cp.Position = new Vector2(
(direction == Direction.Horizontal ? -1 : 1) * cp.Position.X,
(direction == Direction.Vertical ? -1 : 1) * cp.Position.Y
);
}
cp.Position = GeometryUtils.GetFlippedPosition(flipAxis, controlPointFlipQuad, cp.Position);
}
}

View File

@ -47,7 +47,6 @@ namespace osu.Game.Rulesets.Osu.Edit
private OsuHitObject[]? objectsInRotation;
private Vector2? defaultOrigin;
private Dictionary<OsuHitObject, Vector2>? originalPositions;
private Dictionary<IHasPath, Vector2[]>? originalPathControlPointPositions;
@ -61,7 +60,7 @@ namespace osu.Game.Rulesets.Osu.Edit
changeHandler?.BeginChange();
objectsInRotation = selectedMovableObjects.ToArray();
defaultOrigin = GeometryUtils.GetSurroundingQuad(objectsInRotation).Centre;
DefaultOrigin = GeometryUtils.MinimumEnclosingCircle(objectsInRotation).Item1;
originalPositions = objectsInRotation.ToDictionary(obj => obj, obj => obj.Position);
originalPathControlPointPositions = objectsInRotation.OfType<IHasPath>().ToDictionary(
obj => obj,
@ -73,9 +72,9 @@ namespace osu.Game.Rulesets.Osu.Edit
if (!OperationInProgress.Value)
throw new InvalidOperationException($"Cannot {nameof(Update)} a rotate operation without calling {nameof(Begin)} first!");
Debug.Assert(objectsInRotation != null && originalPositions != null && originalPathControlPointPositions != null && defaultOrigin != null);
Debug.Assert(objectsInRotation != null && originalPositions != null && originalPathControlPointPositions != null && DefaultOrigin != null);
Vector2 actualOrigin = origin ?? defaultOrigin.Value;
Vector2 actualOrigin = origin ?? DefaultOrigin.Value;
foreach (var ho in objectsInRotation)
{
@ -103,7 +102,7 @@ namespace osu.Game.Rulesets.Osu.Edit
objectsInRotation = null;
originalPositions = null;
originalPathControlPointPositions = null;
defaultOrigin = null;
DefaultOrigin = null;
}
private IEnumerable<OsuHitObject> selectedMovableObjects => selectedItems.Cast<OsuHitObject>()

View File

@ -69,6 +69,7 @@ namespace osu.Game.Rulesets.Osu.Edit
private Dictionary<OsuHitObject, OriginalHitObjectState>? objectsInScale;
private Vector2? defaultOrigin;
private List<Vector2>? originalConvexHull;
public override void Begin()
{
@ -83,10 +84,13 @@ namespace osu.Game.Rulesets.Osu.Edit
OriginalSurroundingQuad = objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider
? GeometryUtils.GetSurroundingQuad(slider.Path.ControlPoints.Select(p => slider.Position + p.Position))
: GeometryUtils.GetSurroundingQuad(objectsInScale.Keys);
defaultOrigin = OriginalSurroundingQuad.Value.Centre;
originalConvexHull = objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider2
? GeometryUtils.GetConvexHull(slider2.Path.ControlPoints.Select(p => slider2.Position + p.Position))
: GeometryUtils.GetConvexHull(objectsInScale.Keys);
defaultOrigin = GeometryUtils.MinimumEnclosingCircle(originalConvexHull).Item1;
}
public override void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both)
public override void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both, float axisRotation = 0)
{
if (!OperationInProgress.Value)
throw new InvalidOperationException($"Cannot {nameof(Update)} a scale operation without calling {nameof(Begin)} first!");
@ -94,23 +98,22 @@ namespace osu.Game.Rulesets.Osu.Edit
Debug.Assert(objectsInScale != null && defaultOrigin != null && OriginalSurroundingQuad != null);
Vector2 actualOrigin = origin ?? defaultOrigin.Value;
scale = clampScaleToAdjustAxis(scale, adjustAxis);
// for the time being, allow resizing of slider paths only if the slider is
// the only hit object selected. with a group selection, it's likely the user
// is not looking to change the duration of the slider but expand the whole pattern.
if (objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider)
{
var originalInfo = objectsInScale[slider];
Debug.Assert(originalInfo.PathControlPointPositions != null && originalInfo.PathControlPointTypes != null);
scaleSlider(slider, scale, originalInfo.PathControlPointPositions, originalInfo.PathControlPointTypes);
scaleSlider(slider, scale, actualOrigin, objectsInScale[slider], axisRotation);
}
else
{
scale = ClampScaleToPlayfieldBounds(scale, actualOrigin);
scale = ClampScaleToPlayfieldBounds(scale, actualOrigin, adjustAxis, axisRotation);
foreach (var (ho, originalState) in objectsInScale)
{
ho.Position = GeometryUtils.GetScaledPosition(scale, actualOrigin, originalState.Position);
ho.Position = GeometryUtils.GetScaledPosition(scale, actualOrigin, originalState.Position, axisRotation);
}
}
@ -134,21 +137,45 @@ namespace osu.Game.Rulesets.Osu.Edit
private IEnumerable<OsuHitObject> selectedMovableObjects => selectedItems.Cast<OsuHitObject>()
.Where(h => h is not Spinner);
private void scaleSlider(Slider slider, Vector2 scale, Vector2[] originalPathPositions, PathType?[] originalPathTypes)
private Vector2 clampScaleToAdjustAxis(Vector2 scale, Axes adjustAxis)
{
switch (adjustAxis)
{
case Axes.Y:
scale.X = 1;
break;
case Axes.X:
scale.Y = 1;
break;
case Axes.None:
scale = Vector2.One;
break;
}
return scale;
}
private void scaleSlider(Slider slider, Vector2 scale, Vector2 origin, OriginalHitObjectState originalInfo, float axisRotation = 0)
{
Debug.Assert(originalInfo.PathControlPointPositions != null && originalInfo.PathControlPointTypes != null);
scale = Vector2.ComponentMax(scale, new Vector2(Precision.FLOAT_EPSILON));
// Maintain the path types in case they were defaulted to bezier at some point during scaling
for (int i = 0; i < slider.Path.ControlPoints.Count; i++)
{
slider.Path.ControlPoints[i].Position = originalPathPositions[i] * scale;
slider.Path.ControlPoints[i].Type = originalPathTypes[i];
slider.Path.ControlPoints[i].Position = GeometryUtils.GetScaledPosition(scale, Vector2.Zero, originalInfo.PathControlPointPositions[i], axisRotation);
slider.Path.ControlPoints[i].Type = originalInfo.PathControlPointTypes[i];
}
// Snap the slider's length to the current beat divisor
// to calculate the final resulting duration / bounding box before the final checks.
slider.SnapTo(snapProvider);
slider.Position = GeometryUtils.GetScaledPosition(scale, origin, originalInfo.Position, axisRotation);
//if sliderhead or sliderend end up outside playfield, revert scaling.
Quad scaledQuad = GeometryUtils.GetSurroundingQuad(new OsuHitObject[] { slider });
(bool xInBounds, bool yInBounds) = isQuadInBounds(scaledQuad);
@ -157,7 +184,9 @@ namespace osu.Game.Rulesets.Osu.Edit
return;
for (int i = 0; i < slider.Path.ControlPoints.Count; i++)
slider.Path.ControlPoints[i].Position = originalPathPositions[i];
slider.Path.ControlPoints[i].Position = originalInfo.PathControlPointPositions[i];
slider.Position = originalInfo.Position;
// Snap the slider's length again to undo the potentially-invalid length applied by the previous snap.
slider.SnapTo(snapProvider);
@ -176,11 +205,13 @@ namespace osu.Game.Rulesets.Osu.Edit
/// </summary>
/// <param name="origin">The origin from which the scale operation is performed</param>
/// <param name="scale">The scale to be clamped</param>
/// <param name="adjustAxis">The axes to adjust the scale in.</param>
/// <param name="axisRotation">The rotation of the axes in degrees</param>
/// <returns>The clamped scale vector</returns>
public Vector2 ClampScaleToPlayfieldBounds(Vector2 scale, Vector2? origin = null)
public Vector2 ClampScaleToPlayfieldBounds(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both, float axisRotation = 0)
{
//todo: this is not always correct for selections involving sliders. This approximation assumes each point is scaled independently, but sliderends move with the sliderhead.
if (objectsInScale == null)
if (objectsInScale == null || adjustAxis == Axes.None)
return scale;
Debug.Assert(defaultOrigin != null && OriginalSurroundingQuad != null);
@ -188,24 +219,60 @@ namespace osu.Game.Rulesets.Osu.Edit
if (objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider)
origin = slider.Position;
float cos = MathF.Cos(float.DegreesToRadians(-axisRotation));
float sin = MathF.Sin(float.DegreesToRadians(-axisRotation));
scale = clampScaleToAdjustAxis(scale, adjustAxis);
Vector2 actualOrigin = origin ?? defaultOrigin.Value;
IEnumerable<Vector2> points;
if (axisRotation == 0)
{
var selectionQuad = OriginalSurroundingQuad.Value;
points = new[]
{
selectionQuad.TopLeft,
selectionQuad.TopRight,
selectionQuad.BottomLeft,
selectionQuad.BottomRight
};
}
else
points = originalConvexHull!;
var tl1 = Vector2.Divide(-actualOrigin, selectionQuad.TopLeft - actualOrigin);
var tl2 = Vector2.Divide(OsuPlayfield.BASE_SIZE - actualOrigin, selectionQuad.TopLeft - actualOrigin);
var br1 = Vector2.Divide(-actualOrigin, selectionQuad.BottomRight - actualOrigin);
var br2 = Vector2.Divide(OsuPlayfield.BASE_SIZE - actualOrigin, selectionQuad.BottomRight - actualOrigin);
if (!Precision.AlmostEquals(selectionQuad.TopLeft.X - actualOrigin.X, 0))
scale.X = selectionQuad.TopLeft.X - actualOrigin.X < 0 ? MathHelper.Clamp(scale.X, tl2.X, tl1.X) : MathHelper.Clamp(scale.X, tl1.X, tl2.X);
if (!Precision.AlmostEquals(selectionQuad.TopLeft.Y - actualOrigin.Y, 0))
scale.Y = selectionQuad.TopLeft.Y - actualOrigin.Y < 0 ? MathHelper.Clamp(scale.Y, tl2.Y, tl1.Y) : MathHelper.Clamp(scale.Y, tl1.Y, tl2.Y);
if (!Precision.AlmostEquals(selectionQuad.BottomRight.X - actualOrigin.X, 0))
scale.X = selectionQuad.BottomRight.X - actualOrigin.X < 0 ? MathHelper.Clamp(scale.X, br2.X, br1.X) : MathHelper.Clamp(scale.X, br1.X, br2.X);
if (!Precision.AlmostEquals(selectionQuad.BottomRight.Y - actualOrigin.Y, 0))
scale.Y = selectionQuad.BottomRight.Y - actualOrigin.Y < 0 ? MathHelper.Clamp(scale.Y, br2.Y, br1.Y) : MathHelper.Clamp(scale.Y, br1.Y, br2.Y);
foreach (var point in points)
{
scale = clampToBound(scale, point, Vector2.Zero);
scale = clampToBound(scale, point, OsuPlayfield.BASE_SIZE);
}
return Vector2.ComponentMax(scale, new Vector2(Precision.FLOAT_EPSILON));
float minPositiveComponent(Vector2 v) => MathF.Min(v.X < 0 ? float.PositiveInfinity : v.X, v.Y < 0 ? float.PositiveInfinity : v.Y);
Vector2 clampToBound(Vector2 s, Vector2 p, Vector2 bound)
{
p -= actualOrigin;
bound -= actualOrigin;
var a = new Vector2(cos * cos * p.X - sin * cos * p.Y, -sin * cos * p.X + sin * sin * p.Y);
var b = new Vector2(sin * sin * p.X + sin * cos * p.Y, sin * cos * p.X + cos * cos * p.Y);
switch (adjustAxis)
{
case Axes.X:
s.X = MathF.Min(scale.X, minPositiveComponent(Vector2.Divide(bound - b, a)));
break;
case Axes.Y:
s.Y = MathF.Min(scale.Y, minPositiveComponent(Vector2.Divide(bound - a, b)));
break;
case Axes.Both:
s = Vector2.ComponentMin(s, s * minPositiveComponent(Vector2.Divide(bound, a * s.X + b * s.Y)));
break;
}
return s;
}
}
private void moveSelectionInBounds()

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.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
@ -19,16 +20,19 @@ namespace osu.Game.Rulesets.Osu.Edit
{
private readonly SelectionRotationHandler rotationHandler;
private readonly Bindable<PreciseRotationInfo> rotationInfo = new Bindable<PreciseRotationInfo>(new PreciseRotationInfo(0, RotationOrigin.PlayfieldCentre));
private readonly OsuGridToolboxGroup gridToolbox;
private readonly Bindable<PreciseRotationInfo> rotationInfo = new Bindable<PreciseRotationInfo>(new PreciseRotationInfo(0, RotationOrigin.GridCentre));
private SliderWithTextBoxInput<float> angleInput = null!;
private EditorRadioButtonCollection rotationOrigin = null!;
private RadioButton selectionCentreButton = null!;
public PreciseRotationPopover(SelectionRotationHandler rotationHandler)
public PreciseRotationPopover(SelectionRotationHandler rotationHandler, OsuGridToolboxGroup gridToolbox)
{
this.rotationHandler = rotationHandler;
this.gridToolbox = gridToolbox;
AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight };
}
@ -58,6 +62,9 @@ namespace osu.Game.Rulesets.Osu.Edit
RelativeSizeAxes = Axes.X,
Items = new[]
{
new RadioButton("Grid centre",
() => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.GridCentre },
() => new SpriteIcon { Icon = FontAwesome.Regular.PlusSquare }),
new RadioButton("Playfield centre",
() => rotationInfo.Value = rotationInfo.Value with { Origin = RotationOrigin.PlayfieldCentre },
() => new SpriteIcon { Icon = FontAwesome.Regular.Square }),
@ -93,10 +100,19 @@ namespace osu.Game.Rulesets.Osu.Edit
rotationInfo.BindValueChanged(rotation =>
{
rotationHandler.Update(rotation.NewValue.Degrees, rotation.NewValue.Origin == RotationOrigin.PlayfieldCentre ? OsuPlayfield.BASE_SIZE / 2 : null);
rotationHandler.Update(rotation.NewValue.Degrees, getOriginPosition(rotation.NewValue));
});
}
private Vector2? getOriginPosition(PreciseRotationInfo rotation) =>
rotation.Origin switch
{
RotationOrigin.GridCentre => gridToolbox.StartPosition.Value,
RotationOrigin.PlayfieldCentre => OsuPlayfield.BASE_SIZE / 2,
RotationOrigin.SelectionCentre => null,
_ => throw new ArgumentOutOfRangeException(nameof(rotation))
};
protected override void PopIn()
{
base.PopIn();
@ -114,6 +130,7 @@ namespace osu.Game.Rulesets.Osu.Edit
public enum RotationOrigin
{
GridCentre,
PlayfieldCentre,
SelectionCentre
}

View File

@ -10,7 +10,10 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Components.RadioButtons;
using osuTK;
@ -20,28 +23,36 @@ namespace osu.Game.Rulesets.Osu.Edit
{
private readonly OsuSelectionScaleHandler scaleHandler;
private readonly Bindable<PreciseScaleInfo> scaleInfo = new Bindable<PreciseScaleInfo>(new PreciseScaleInfo(1, ScaleOrigin.PlayfieldCentre, true, true));
private readonly OsuGridToolboxGroup gridToolbox;
private readonly Bindable<PreciseScaleInfo> scaleInfo = new Bindable<PreciseScaleInfo>(new PreciseScaleInfo(1, ScaleOrigin.GridCentre, true, true));
private SliderWithTextBoxInput<float> scaleInput = null!;
private BindableNumber<float> scaleInputBindable = null!;
private EditorRadioButtonCollection scaleOrigin = null!;
private RadioButton gridCentreButton = null!;
private RadioButton playfieldCentreButton = null!;
private RadioButton selectionCentreButton = null!;
private OsuCheckbox xCheckBox = null!;
private OsuCheckbox yCheckBox = null!;
public PreciseScalePopover(OsuSelectionScaleHandler scaleHandler)
private BindableList<HitObject> selectedItems { get; } = new BindableList<HitObject>();
public PreciseScalePopover(OsuSelectionScaleHandler scaleHandler, OsuGridToolboxGroup gridToolbox)
{
this.scaleHandler = scaleHandler;
this.gridToolbox = gridToolbox;
AllowableAnchors = new[] { Anchor.CentreLeft, Anchor.CentreRight };
}
[BackgroundDependencyLoader]
private void load()
private void load(EditorBeatmap editorBeatmap)
{
selectedItems.BindTo(editorBeatmap.SelectedHitObjects);
Child = new FillFlowContainer
{
Width = 220,
@ -66,6 +77,9 @@ namespace osu.Game.Rulesets.Osu.Edit
RelativeSizeAxes = Axes.X,
Items = new[]
{
gridCentreButton = new RadioButton("Grid centre",
() => setOrigin(ScaleOrigin.GridCentre),
() => new SpriteIcon { Icon = FontAwesome.Regular.PlusSquare }),
playfieldCentreButton = new RadioButton("Playfield centre",
() => setOrigin(ScaleOrigin.PlayfieldCentre),
() => new SpriteIcon { Icon = FontAwesome.Regular.Square }),
@ -97,6 +111,10 @@ namespace osu.Game.Rulesets.Osu.Edit
},
}
};
gridCentreButton.Selected.DisabledChanged += isDisabled =>
{
gridCentreButton.TooltipText = isDisabled ? "The current selection cannot be scaled relative to grid centre." : string.Empty;
};
playfieldCentreButton.Selected.DisabledChanged += isDisabled =>
{
playfieldCentreButton.TooltipText = isDisabled ? "The current selection cannot be scaled relative to playfield centre." : string.Empty;
@ -123,19 +141,20 @@ namespace osu.Game.Rulesets.Osu.Edit
selectionCentreButton.Selected.Disabled = !(scaleHandler.CanScaleX.Value || scaleHandler.CanScaleY.Value);
playfieldCentreButton.Selected.Disabled = scaleHandler.IsScalingSlider.Value && !selectionCentreButton.Selected.Disabled;
gridCentreButton.Selected.Disabled = playfieldCentreButton.Selected.Disabled;
scaleOrigin.Items.First(b => !b.Selected.Disabled).Select();
scaleInfo.BindValueChanged(scale =>
{
var newScale = new Vector2(scale.NewValue.XAxis ? scale.NewValue.Scale : 1, scale.NewValue.YAxis ? scale.NewValue.Scale : 1);
scaleHandler.Update(newScale, getOriginPosition(scale.NewValue));
var newScale = new Vector2(scale.NewValue.Scale, scale.NewValue.Scale);
scaleHandler.Update(newScale, getOriginPosition(scale.NewValue), getAdjustAxis(scale.NewValue), getRotation(scale.NewValue));
});
}
private void updateAxisCheckBoxesEnabled()
{
if (scaleInfo.Value.Origin == ScaleOrigin.PlayfieldCentre)
if (scaleInfo.Value.Origin != ScaleOrigin.SelectionCentre)
{
toggleAxisAvailable(xCheckBox.Current, true);
toggleAxisAvailable(yCheckBox.Current, true);
@ -162,7 +181,7 @@ namespace osu.Game.Rulesets.Osu.Edit
return;
const float max_scale = 10;
var scale = scaleHandler.ClampScaleToPlayfieldBounds(new Vector2(max_scale), getOriginPosition(scaleInfo.Value));
var scale = scaleHandler.ClampScaleToPlayfieldBounds(new Vector2(max_scale), getOriginPosition(scaleInfo.Value), getAdjustAxis(scaleInfo.Value), getRotation(scaleInfo.Value));
if (!scaleInfo.Value.XAxis)
scale.X = max_scale;
@ -179,7 +198,30 @@ namespace osu.Game.Rulesets.Osu.Edit
updateAxisCheckBoxesEnabled();
}
private Vector2? getOriginPosition(PreciseScaleInfo scale) => scale.Origin == ScaleOrigin.PlayfieldCentre ? OsuPlayfield.BASE_SIZE / 2 : null;
private Vector2? getOriginPosition(PreciseScaleInfo scale)
{
switch (scale.Origin)
{
case ScaleOrigin.GridCentre:
return gridToolbox.StartPosition.Value;
case ScaleOrigin.PlayfieldCentre:
return OsuPlayfield.BASE_SIZE / 2;
case ScaleOrigin.SelectionCentre:
if (selectedItems.Count == 1 && selectedItems.First() is Slider slider)
return slider.Position;
return null;
default:
throw new ArgumentOutOfRangeException(nameof(scale));
}
}
private Axes getAdjustAxis(PreciseScaleInfo scale) => scale.XAxis ? scale.YAxis ? Axes.Both : Axes.X : Axes.Y;
private float getRotation(PreciseScaleInfo scale) => scale.Origin == ScaleOrigin.GridCentre ? gridToolbox.GridLinesRotation.Value : 0;
private void setAxis(bool x, bool y)
{
@ -204,6 +246,7 @@ namespace osu.Game.Rulesets.Osu.Edit
public enum ScaleOrigin
{
GridCentre,
PlayfieldCentre,
SelectionCentre
}

View File

@ -16,13 +16,13 @@ namespace osu.Game.Rulesets.Osu.Edit.Setup
{
public partial class OsuDifficultySection : SetupSection
{
private LabelledSliderBar<float> circleSizeSlider { get; set; } = null!;
private LabelledSliderBar<float> healthDrainSlider { get; set; } = null!;
private LabelledSliderBar<float> approachRateSlider { get; set; } = null!;
private LabelledSliderBar<float> overallDifficultySlider { get; set; } = null!;
private LabelledSliderBar<double> baseVelocitySlider { get; set; } = null!;
private LabelledSliderBar<double> tickRateSlider { get; set; } = null!;
private LabelledSliderBar<float> stackLeniency { get; set; } = null!;
private FormSliderBar<float> circleSizeSlider { get; set; } = null!;
private FormSliderBar<float> healthDrainSlider { get; set; } = null!;
private FormSliderBar<float> approachRateSlider { get; set; } = null!;
private FormSliderBar<float> overallDifficultySlider { get; set; } = null!;
private FormSliderBar<double> baseVelocitySlider { get; set; } = null!;
private FormSliderBar<double> tickRateSlider { get; set; } = null!;
private FormSliderBar<float> stackLeniency { get; set; } = null!;
public override LocalisableString Title => EditorSetupStrings.DifficultyHeader;
@ -31,103 +31,110 @@ namespace osu.Game.Rulesets.Osu.Edit.Setup
{
Children = new Drawable[]
{
circleSizeSlider = new LabelledSliderBar<float>
circleSizeSlider = new FormSliderBar<float>
{
Label = BeatmapsetsStrings.ShowStatsCs,
FixedLabelWidth = LABEL_WIDTH,
Description = EditorSetupStrings.CircleSizeDescription,
Caption = BeatmapsetsStrings.ShowStatsCs,
HintText = EditorSetupStrings.CircleSizeDescription,
Current = new BindableFloat(Beatmap.Difficulty.CircleSize)
{
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0,
MaxValue = 10,
Precision = 0.1f,
}
},
healthDrainSlider = new LabelledSliderBar<float>
TransferValueOnCommit = true,
TabbableContentContainer = this,
},
healthDrainSlider = new FormSliderBar<float>
{
Label = BeatmapsetsStrings.ShowStatsDrain,
FixedLabelWidth = LABEL_WIDTH,
Description = EditorSetupStrings.DrainRateDescription,
Caption = BeatmapsetsStrings.ShowStatsDrain,
HintText = EditorSetupStrings.DrainRateDescription,
Current = new BindableFloat(Beatmap.Difficulty.DrainRate)
{
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0,
MaxValue = 10,
Precision = 0.1f,
}
},
approachRateSlider = new LabelledSliderBar<float>
TransferValueOnCommit = true,
TabbableContentContainer = this,
},
approachRateSlider = new FormSliderBar<float>
{
Label = BeatmapsetsStrings.ShowStatsAr,
FixedLabelWidth = LABEL_WIDTH,
Description = EditorSetupStrings.ApproachRateDescription,
Caption = BeatmapsetsStrings.ShowStatsAr,
HintText = EditorSetupStrings.ApproachRateDescription,
Current = new BindableFloat(Beatmap.Difficulty.ApproachRate)
{
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0,
MaxValue = 10,
Precision = 0.1f,
}
},
overallDifficultySlider = new LabelledSliderBar<float>
TransferValueOnCommit = true,
TabbableContentContainer = this,
},
overallDifficultySlider = new FormSliderBar<float>
{
Label = BeatmapsetsStrings.ShowStatsAccuracy,
FixedLabelWidth = LABEL_WIDTH,
Description = EditorSetupStrings.OverallDifficultyDescription,
Caption = BeatmapsetsStrings.ShowStatsAccuracy,
HintText = EditorSetupStrings.OverallDifficultyDescription,
Current = new BindableFloat(Beatmap.Difficulty.OverallDifficulty)
{
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0,
MaxValue = 10,
Precision = 0.1f,
}
},
baseVelocitySlider = new LabelledSliderBar<double>
TransferValueOnCommit = true,
TabbableContentContainer = this,
},
baseVelocitySlider = new FormSliderBar<double>
{
Label = EditorSetupStrings.BaseVelocity,
FixedLabelWidth = LABEL_WIDTH,
Description = EditorSetupStrings.BaseVelocityDescription,
Caption = EditorSetupStrings.BaseVelocity,
HintText = EditorSetupStrings.BaseVelocityDescription,
Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier)
{
Default = 1.4,
MinValue = 0.4,
MaxValue = 3.6,
Precision = 0.01f,
}
},
tickRateSlider = new LabelledSliderBar<double>
TransferValueOnCommit = true,
TabbableContentContainer = this,
},
tickRateSlider = new FormSliderBar<double>
{
Label = EditorSetupStrings.TickRate,
FixedLabelWidth = LABEL_WIDTH,
Description = EditorSetupStrings.TickRateDescription,
Caption = EditorSetupStrings.TickRate,
HintText = EditorSetupStrings.TickRateDescription,
Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate)
{
Default = 1,
MinValue = 1,
MaxValue = 4,
Precision = 1,
}
},
stackLeniency = new LabelledSliderBar<float>
TransferValueOnCommit = true,
TabbableContentContainer = this,
},
stackLeniency = new FormSliderBar<float>
{
Label = "Stack Leniency",
FixedLabelWidth = LABEL_WIDTH,
Description = "In play mode, osu! automatically stacks notes which occur at the same location. Increasing this value means it is more likely to snap notes of further time-distance.",
Caption = "Stack Leniency",
HintText = "In play mode, osu! automatically stacks notes which occur at the same location. Increasing this value means it is more likely to snap notes of further time-distance.",
Current = new BindableFloat(Beatmap.BeatmapInfo.StackLeniency)
{
Default = 0.7f,
MinValue = 0,
MaxValue = 1,
Precision = 0.1f
}
},
TransferValueOnCommit = true,
TabbableContentContainer = this,
},
};
foreach (var item in Children.OfType<LabelledSliderBar<float>>())
foreach (var item in Children.OfType<FormSliderBar<float>>())
item.Current.ValueChanged += _ => updateValues();
foreach (var item in Children.OfType<LabelledSliderBar<double>>())
foreach (var item in Children.OfType<FormSliderBar<double>>())
item.Current.ValueChanged += _ => updateValues();
}

View File

@ -10,7 +10,7 @@ using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Edit
{
public class SliderCompositionTool : HitObjectCompositionTool
public class SliderCompositionTool : CompositionTool
{
public SliderCompositionTool()
: base(nameof(Slider))
@ -26,6 +26,6 @@ namespace osu.Game.Rulesets.Osu.Edit
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders);
public override PlacementBlueprint CreatePlacementBlueprint() => new SliderPlacementBlueprint();
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new SliderPlacementBlueprint();
}
}

View File

@ -10,7 +10,7 @@ using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Edit
{
public class SpinnerCompositionTool : HitObjectCompositionTool
public class SpinnerCompositionTool : CompositionTool
{
public SpinnerCompositionTool()
: base(nameof(Spinner))
@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Osu.Edit
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners);
public override PlacementBlueprint CreatePlacementBlueprint() => new SpinnerPlacementBlueprint();
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new SpinnerPlacementBlueprint();
}
}

View File

@ -27,6 +27,8 @@ namespace osu.Game.Rulesets.Osu.Edit
public SelectionRotationHandler RotationHandler { get; init; } = null!;
public OsuSelectionScaleHandler ScaleHandler { get; init; } = null!;
public OsuGridToolboxGroup GridToolbox { get; init; } = null!;
public TransformToolboxGroup()
: base("transform")
{
@ -44,10 +46,10 @@ namespace osu.Game.Rulesets.Osu.Edit
{
rotateButton = new EditorToolButton("Rotate",
() => new SpriteIcon { Icon = FontAwesome.Solid.Undo },
() => new PreciseRotationPopover(RotationHandler)),
() => new PreciseRotationPopover(RotationHandler, GridToolbox)),
scaleButton = new EditorToolButton("Scale",
() => new SpriteIcon { Icon = FontAwesome.Solid.ArrowsAlt },
() => new PreciseScalePopover(ScaleHandler))
() => new PreciseScalePopover(ScaleHandler, GridToolbox))
}
};
}

View File

@ -91,19 +91,34 @@ namespace osu.Game.Rulesets.Osu.Objects.Drawables
drawableObjectPiece.ApplyCustomUpdateState -= applyDimToDrawableHitObject;
drawableObjectPiece.ApplyCustomUpdateState += applyDimToDrawableHitObject;
}
else
// but at the end apply the transforms now regardless of whether this is a DHO or not.
// the above is just to ensure they don't get overwritten later.
applyDim(piece);
}
}
void applyDim(Drawable piece)
protected override void ClearNestedHitObjects()
{
base.ClearNestedHitObjects();
// any dimmable pieces that are DHOs will be pooled separately.
// `applyDimToDrawableHitObject` is a closure that implicitly captures `this`,
// and because of separate pooling of parent and child objects, there is no guarantee that the pieces will be associated with `this` again on re-use.
// therefore, clean up the subscription here to avoid crosstalk.
// not doing so can result in the callback attempting to read things from `this` when it is in a completely bogus state (not in use or similar).
foreach (var piece in DimmablePieces.OfType<DrawableHitObject>())
piece.ApplyCustomUpdateState -= applyDimToDrawableHitObject;
}
private void applyDim(Drawable piece)
{
piece.FadeColour(new Color4(195, 195, 195, 255));
using (piece.BeginDelayedSequence(InitialLifetimeOffset - OsuHitWindows.MISS_WINDOW))
piece.FadeColour(Color4.White, 100);
}
void applyDimToDrawableHitObject(DrawableHitObject dho, ArmedState _) => applyDim(dho);
}
private void applyDimToDrawableHitObject(DrawableHitObject dho, ArmedState _) => applyDim(dho);
protected sealed override double InitialLifetimeOffset => HitObject.TimePreempt;

View File

@ -204,6 +204,7 @@ namespace osu.Game.Rulesets.Osu.Objects
SpanStartTime = e.SpanStartTime,
StartTime = e.Time,
Position = Position + Path.PositionAt(e.PathProgress),
PathProgress = e.PathProgress,
StackHeight = StackHeight,
});
break;
@ -236,6 +237,7 @@ namespace osu.Game.Rulesets.Osu.Objects
StartTime = StartTime + (e.SpanIndex + 1) * SpanDuration,
Position = Position + Path.PositionAt(e.PathProgress),
StackHeight = StackHeight,
PathProgress = e.PathProgress,
});
break;
}
@ -248,14 +250,27 @@ namespace osu.Game.Rulesets.Osu.Objects
{
endPositionCache.Invalidate();
if (HeadCircle != null)
HeadCircle.Position = Position;
foreach (var nested in NestedHitObjects)
{
switch (nested)
{
case SliderHeadCircle headCircle:
headCircle.Position = Position;
break;
if (TailCircle != null)
TailCircle.Position = EndPosition;
case SliderTailCircle tailCircle:
tailCircle.Position = EndPosition;
break;
if (LastRepeat != null)
LastRepeat.Position = RepeatCount % 2 == 0 ? Position : Position + Path.PositionAt(1);
case SliderRepeat repeat:
repeat.Position = Position + Path.PositionAt(repeat.PathProgress);
break;
case SliderTick tick:
tick.Position = Position + Path.PositionAt(tick.PathProgress);
break;
}
}
}
protected void UpdateNestedSamples()

View File

@ -5,6 +5,8 @@ namespace osu.Game.Rulesets.Osu.Objects
{
public class SliderRepeat : SliderEndCircle
{
public double PathProgress { get; set; }
public SliderRepeat(Slider slider)
: base(slider)
{

View File

@ -13,6 +13,7 @@ namespace osu.Game.Rulesets.Osu.Objects
{
public int SpanIndex { get; set; }
public double SpanStartTime { get; set; }
public double PathProgress { get; set; }
protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty)
{

View File

@ -5,6 +5,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Bindings;
using osu.Framework.Localisation;
@ -39,6 +40,7 @@ using osu.Game.Scoring;
using osu.Game.Screens.Edit.Setup;
using osu.Game.Screens.Ranking.Statistics;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Rulesets.Osu
{
@ -336,10 +338,28 @@ namespace osu.Game.Rulesets.Osu
};
}
public override IEnumerable<SetupSection> CreateEditorSetupSections() =>
public override IEnumerable<Drawable> CreateEditorSetupSections() =>
[
new MetadataSection(),
new OsuDifficultySection(),
new ColoursSection(),
new FillFlowContainer
{
AutoSizeAxes = Axes.Y,
Direction = FillDirection.Vertical,
Spacing = new Vector2(SetupScreen.SPACING),
Children = new Drawable[]
{
new ResourcesSection
{
RelativeSizeAxes = Axes.X,
},
new ColoursSection
{
RelativeSizeAxes = Axes.X,
}
}
},
new DesignSection(),
];
/// <seealso cref="OsuHitObject.ApplyDefaultsToSelf"/>

View File

@ -232,8 +232,6 @@ namespace osu.Game.Rulesets.Osu.Utils
slider.Position = workingObject.PositionModified = new Vector2(newX, newY);
workingObject.EndPositionModified = slider.EndPosition;
shiftNestedObjects(slider, workingObject.PositionModified - workingObject.PositionOriginal);
return workingObject.PositionModified - previousPosition;
}
@ -307,22 +305,6 @@ namespace osu.Game.Rulesets.Osu.Utils
return new RectangleF(left, top, right - left, bottom - top);
}
/// <summary>
/// Shifts all nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s by the specified shift.
/// </summary>
/// <param name="slider"><see cref="Slider"/> whose nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s should be shifted</param>
/// <param name="shift">The <see cref="Vector2"/> the <see cref="Slider"/>'s nested <see cref="SliderTick"/>s and <see cref="SliderRepeat"/>s should be shifted by</param>
private static void shiftNestedObjects(Slider slider, Vector2 shift)
{
foreach (var hitObject in slider.NestedHitObjects.Where(o => o is SliderTick || o is SliderRepeat))
{
if (!(hitObject is OsuHitObject osuHitObject))
continue;
osuHitObject.Position += shift;
}
}
/// <summary>
/// Clamp a position to playfield, keeping a specified distance from the edges.
/// </summary>
@ -431,7 +413,6 @@ namespace osu.Game.Rulesets.Osu.Utils
private class WorkingObject
{
public float RotationOriginal { get; }
public Vector2 PositionOriginal { get; }
public Vector2 PositionModified { get; set; }
public Vector2 EndPositionModified { get; set; }
@ -442,7 +423,7 @@ namespace osu.Game.Rulesets.Osu.Utils
{
PositionInfo = positionInfo;
RotationOriginal = HitObject is Slider slider ? getSliderRotation(slider) : 0;
PositionModified = PositionOriginal = HitObject.Position;
PositionModified = HitObject.Position;
EndPositionModified = HitObject.EndPosition;
}
}

View File

@ -0,0 +1,27 @@
// 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.Audio;
using osu.Game.Rulesets.Taiko.Skinning.Argon;
namespace osu.Game.Rulesets.Taiko.Tests
{
[TestFixture]
public class VolumeAwareHitSampleInfoTest
{
[Test]
public void TestVolumeAwareHitSampleInfoIsNotEqualToItsUnderlyingSample(
[Values(HitSampleInfo.HIT_NORMAL, HitSampleInfo.HIT_CLAP)]
string sample,
[Values(HitSampleInfo.BANK_NORMAL, HitSampleInfo.BANK_SOFT)]
string bank,
[Values(30, 70, 100)] int volume)
{
var underlyingSample = new HitSampleInfo(sample, bank, volume: volume);
var volumeAwareSample = new VolumeAwareHitSampleInfo(underlyingSample);
Assert.That(underlyingSample, Is.Not.EqualTo(volumeAwareSample));
}
}
}

View File

@ -43,6 +43,15 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
[JsonProperty("great_hit_window")]
public double GreatHitWindow { get; set; }
/// <summary>
/// The perceived hit window for an OK hit inclusive of rate-adjusting mods (DT/HT/etc).
/// </summary>
/// <remarks>
/// Rate-adjusting mods don't directly affect the hit window, but have a perceived effect as a result of adjusting audio timing.
/// </remarks>
[JsonProperty("ok_hit_window")]
public double OkHitWindow { get; set; }
public override IEnumerable<(int attributeId, object value)> ToDatabaseAttributes()
{
foreach (var v in base.ToDatabaseAttributes())
@ -50,6 +59,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
yield return (ATTRIB_ID_DIFFICULTY, StarRating);
yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow);
yield return (ATTRIB_ID_OK_HIT_WINDOW, OkHitWindow);
}
public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo)
@ -58,6 +68,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
StarRating = values[ATTRIB_ID_DIFFICULTY];
GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW];
OkHitWindow = values[ATTRIB_ID_OK_HIT_WINDOW];
}
}
}

View File

@ -14,7 +14,6 @@ using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Colour;
using osu.Game.Rulesets.Taiko.Difficulty.Skills;
using osu.Game.Rulesets.Taiko.Mods;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.Taiko.Scoring;
namespace osu.Game.Rulesets.Taiko.Difficulty
@ -100,7 +99,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
ColourDifficulty = colourRating,
PeakDifficulty = combinedRating,
GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate,
MaxCombo = beatmap.HitObjects.Count(h => h is Hit),
OkHitWindow = hitWindows.WindowFor(HitResult.Ok) / clockRate,
MaxCombo = beatmap.GetMaxCombo(),
};
return attributes;

View File

@ -18,6 +18,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
[JsonProperty("effective_miss_count")]
public double EffectiveMissCount { get; set; }
[JsonProperty("estimated_unstable_rate")]
public double? EstimatedUnstableRate { get; set; }
public override IEnumerable<PerformanceDisplayAttribute> GetAttributesForDisplay()
{
foreach (var attribute in base.GetAttributesForDisplay())

View File

@ -9,6 +9,7 @@ using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Scoring;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Taiko.Difficulty
{
@ -18,7 +19,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
private int countOk;
private int countMeh;
private int countMiss;
private double accuracy;
private double? estimatedUnstableRate;
private double effectiveMissCount;
@ -35,7 +36,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
countOk = score.Statistics.GetValueOrDefault(HitResult.Ok);
countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh);
countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss);
accuracy = customAccuracy;
estimatedUnstableRate = computeDeviationUpperBound(taikoAttributes) * 10;
// The effectiveMissCount is calculated by gaining a ratio for totalSuccessfulHits and increasing the miss penalty for shorter object counts lower than 1000.
if (totalSuccessfulHits > 0)
@ -65,6 +66,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
Difficulty = difficultyValue,
Accuracy = accuracyValue,
EffectiveMissCount = effectiveMissCount,
EstimatedUnstableRate = estimatedUnstableRate,
Total = totalValue
};
}
@ -85,35 +87,94 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
difficultyValue *= 1.025;
if (score.Mods.Any(m => m is ModHardRock))
difficultyValue *= 1.050;
difficultyValue *= 1.10;
if (score.Mods.Any(m => m is ModFlashlight<TaikoHitObject>))
difficultyValue *= 1.050 * lengthBonus;
return difficultyValue * Math.Pow(accuracy, 2.0);
if (estimatedUnstableRate == null)
return 0;
return difficultyValue * Math.Pow(SpecialFunctions.Erf(400 / (Math.Sqrt(2) * estimatedUnstableRate.Value)), 2.0);
}
private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert)
{
if (attributes.GreatHitWindow <= 0)
if (attributes.GreatHitWindow <= 0 || estimatedUnstableRate == null)
return 0;
double accuracyValue = Math.Pow(60.0 / attributes.GreatHitWindow, 1.1) * Math.Pow(accuracy, 8.0) * Math.Pow(attributes.StarRating, 0.4) * 27.0;
double accuracyValue = Math.Pow(70 / estimatedUnstableRate.Value, 1.1) * Math.Pow(attributes.StarRating, 0.4) * 100.0;
double lengthBonus = Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3));
accuracyValue *= lengthBonus;
// Slight HDFL Bonus for accuracy. A clamp is used to prevent against negative values.
if (score.Mods.Any(m => m is ModFlashlight<TaikoHitObject>) && score.Mods.Any(m => m is ModHidden) && !isConvert)
accuracyValue *= Math.Max(1.0, 1.1 * lengthBonus);
accuracyValue *= Math.Max(1.0, 1.05 * lengthBonus);
return accuracyValue;
}
/// <summary>
/// Computes an upper bound on the player's tap deviation based on the OD, number of circles and sliders,
/// and the hit judgements, assuming the player's mean hit error is 0. The estimation is consistent in that
/// two SS scores on the same map with the same settings will always return the same deviation.
/// </summary>
private double? computeDeviationUpperBound(TaikoDifficultyAttributes attributes)
{
if (totalSuccessfulHits == 0 || attributes.GreatHitWindow <= 0)
return null;
double h300 = attributes.GreatHitWindow;
double h100 = attributes.OkHitWindow;
const double z = 2.32634787404; // 99% critical value for the normal distribution (one-tailed).
// The upper bound on deviation, calculated with the ratio of 300s to objects, and the great hit window.
double? calcDeviationGreatWindow()
{
if (countGreat == 0) return null;
double n = totalHits;
// Proportion of greats hit.
double p = countGreat / n;
// We can be 99% confident that p is at least this value.
double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4);
// We can be 99% confident that the deviation is not higher than:
return h300 / (Math.Sqrt(2) * SpecialFunctions.ErfInv(pLowerBound));
}
// The upper bound on deviation, calculated with the ratio of 300s + 100s to objects, and the good hit window.
// This will return a lower value than the first method when the number of 100s is high, but the miss count is low.
double? calcDeviationGoodWindow()
{
if (totalSuccessfulHits == 0) return null;
double n = totalHits;
// Proportion of greats + goods hit.
double p = totalSuccessfulHits / n;
// We can be 99% confident that p is at least this value.
double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4);
// We can be 99% confident that the deviation is not higher than:
return h100 / (Math.Sqrt(2) * SpecialFunctions.ErfInv(pLowerBound));
}
double? deviationGreatWindow = calcDeviationGreatWindow();
double? deviationGoodWindow = calcDeviationGoodWindow();
if (deviationGreatWindow is null)
return deviationGoodWindow;
return Math.Min(deviationGreatWindow.Value, deviationGoodWindow!.Value);
}
private int totalHits => countGreat + countOk + countMeh + countMiss;
private int totalSuccessfulHits => countGreat + countOk + countMeh;
private double customAccuracy => totalHits > 0 ? (countGreat * 300 + countOk * 150) / (totalHits * 300.0) : 0;
}
}

View File

@ -10,7 +10,7 @@ using osuTK.Input;
namespace osu.Game.Rulesets.Taiko.Edit.Blueprints
{
public partial class HitPlacementBlueprint : PlacementBlueprint
public partial class HitPlacementBlueprint : HitObjectPlacementBlueprint
{
private readonly HitPiece piece;

View File

@ -17,7 +17,7 @@ using osuTK.Input;
namespace osu.Game.Rulesets.Taiko.Edit.Blueprints
{
public partial class TaikoSpanPlacementBlueprint : PlacementBlueprint
public partial class TaikoSpanPlacementBlueprint : HitObjectPlacementBlueprint
{
private readonly HitPiece headPiece;
private readonly HitPiece tailPiece;

View File

@ -10,7 +10,7 @@ using osu.Game.Rulesets.Taiko.Objects;
namespace osu.Game.Rulesets.Taiko.Edit
{
public class DrumRollCompositionTool : HitObjectCompositionTool
public class DrumRollCompositionTool : CompositionTool
{
public DrumRollCompositionTool()
: base(nameof(DrumRoll))
@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Taiko.Edit
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Sliders);
public override PlacementBlueprint CreatePlacementBlueprint() => new DrumRollPlacementBlueprint();
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new DrumRollPlacementBlueprint();
}
}

View File

@ -10,7 +10,7 @@ using osu.Game.Rulesets.Taiko.Objects;
namespace osu.Game.Rulesets.Taiko.Edit
{
public class HitCompositionTool : HitObjectCompositionTool
public class HitCompositionTool : CompositionTool
{
public HitCompositionTool()
: base(nameof(Hit))
@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Taiko.Edit
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Circles);
public override PlacementBlueprint CreatePlacementBlueprint() => new HitPlacementBlueprint();
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new HitPlacementBlueprint();
}
}

View File

@ -16,10 +16,10 @@ namespace osu.Game.Rulesets.Taiko.Edit.Setup
{
public partial class TaikoDifficultySection : SetupSection
{
private LabelledSliderBar<float> healthDrainSlider { get; set; } = null!;
private LabelledSliderBar<float> overallDifficultySlider { get; set; } = null!;
private LabelledSliderBar<double> baseVelocitySlider { get; set; } = null!;
private LabelledSliderBar<double> tickRateSlider { get; set; } = null!;
private FormSliderBar<float> healthDrainSlider { get; set; } = null!;
private FormSliderBar<float> overallDifficultySlider { get; set; } = null!;
private FormSliderBar<double> baseVelocitySlider { get; set; } = null!;
private FormSliderBar<double> tickRateSlider { get; set; } = null!;
public override LocalisableString Title => EditorSetupStrings.DifficultyHeader;
@ -28,64 +28,68 @@ namespace osu.Game.Rulesets.Taiko.Edit.Setup
{
Children = new Drawable[]
{
healthDrainSlider = new LabelledSliderBar<float>
healthDrainSlider = new FormSliderBar<float>
{
Label = BeatmapsetsStrings.ShowStatsDrain,
FixedLabelWidth = LABEL_WIDTH,
Description = EditorSetupStrings.DrainRateDescription,
Caption = BeatmapsetsStrings.ShowStatsDrain,
HintText = EditorSetupStrings.DrainRateDescription,
Current = new BindableFloat(Beatmap.Difficulty.DrainRate)
{
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0,
MaxValue = 10,
Precision = 0.1f,
}
},
overallDifficultySlider = new LabelledSliderBar<float>
TransferValueOnCommit = true,
TabbableContentContainer = this,
},
overallDifficultySlider = new FormSliderBar<float>
{
Label = BeatmapsetsStrings.ShowStatsAccuracy,
FixedLabelWidth = LABEL_WIDTH,
Description = EditorSetupStrings.OverallDifficultyDescription,
Caption = BeatmapsetsStrings.ShowStatsAccuracy,
HintText = EditorSetupStrings.OverallDifficultyDescription,
Current = new BindableFloat(Beatmap.Difficulty.OverallDifficulty)
{
Default = BeatmapDifficulty.DEFAULT_DIFFICULTY,
MinValue = 0,
MaxValue = 10,
Precision = 0.1f,
}
},
baseVelocitySlider = new LabelledSliderBar<double>
TransferValueOnCommit = true,
TabbableContentContainer = this,
},
baseVelocitySlider = new FormSliderBar<double>
{
Label = EditorSetupStrings.BaseVelocity,
FixedLabelWidth = LABEL_WIDTH,
Description = EditorSetupStrings.BaseVelocityDescription,
Caption = EditorSetupStrings.BaseVelocity,
HintText = EditorSetupStrings.BaseVelocityDescription,
Current = new BindableDouble(Beatmap.Difficulty.SliderMultiplier)
{
Default = 1.4,
MinValue = 0.4,
MaxValue = 3.6,
Precision = 0.01f,
}
},
tickRateSlider = new LabelledSliderBar<double>
TransferValueOnCommit = true,
TabbableContentContainer = this,
},
tickRateSlider = new FormSliderBar<double>
{
Label = EditorSetupStrings.TickRate,
FixedLabelWidth = LABEL_WIDTH,
Description = EditorSetupStrings.TickRateDescription,
Caption = EditorSetupStrings.TickRate,
HintText = EditorSetupStrings.TickRateDescription,
Current = new BindableDouble(Beatmap.Difficulty.SliderTickRate)
{
Default = 1,
MinValue = 1,
MaxValue = 4,
Precision = 1,
}
},
TransferValueOnCommit = true,
TabbableContentContainer = this,
},
};
foreach (var item in Children.OfType<LabelledSliderBar<float>>())
foreach (var item in Children.OfType<FormSliderBar<float>>())
item.Current.ValueChanged += _ => updateValues();
foreach (var item in Children.OfType<LabelledSliderBar<double>>())
foreach (var item in Children.OfType<FormSliderBar<double>>())
item.Current.ValueChanged += _ => updateValues();
}

View File

@ -10,7 +10,7 @@ using osu.Game.Rulesets.Taiko.Objects;
namespace osu.Game.Rulesets.Taiko.Edit
{
public class SwellCompositionTool : HitObjectCompositionTool
public class SwellCompositionTool : CompositionTool
{
public SwellCompositionTool()
: base(nameof(Swell))
@ -19,6 +19,6 @@ namespace osu.Game.Rulesets.Taiko.Edit
public override Drawable CreateIcon() => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Spinners);
public override PlacementBlueprint CreatePlacementBlueprint() => new SwellPlacementBlueprint();
public override HitObjectPlacementBlueprint CreatePlacementBlueprint() => new SwellPlacementBlueprint();
}
}

View File

@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Taiko.Edit
{
}
protected override IReadOnlyList<HitObjectCompositionTool> CompositionTools => new HitObjectCompositionTool[]
protected override IReadOnlyList<CompositionTool> CompositionTools => new CompositionTool[]
{
new HitCompositionTool(),
new DrumRollCompositionTool(),

View File

@ -6,6 +6,7 @@ using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input.Bindings;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
@ -86,10 +87,22 @@ namespace osu.Game.Rulesets.Taiko.Edit
protected override IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumerable<SelectionBlueprint<HitObject>> selection)
{
if (selection.All(s => s.Item is Hit))
yield return new TernaryStateToggleMenuItem("Rim") { State = { BindTarget = selectionRimState } };
{
yield return new TernaryStateToggleMenuItem("Rim")
{
State = { BindTarget = selectionRimState },
Hotkey = new Hotkey(new KeyCombination(InputKey.W), new KeyCombination(InputKey.R)),
};
}
if (selection.All(s => s.Item is TaikoHitObject))
yield return new TernaryStateToggleMenuItem("Strong") { State = { BindTarget = selectionStrongState } };
{
yield return new TernaryStateToggleMenuItem("Strong")
{
State = { BindTarget = selectionStrongState },
Hotkey = new Hotkey(new KeyCombination(InputKey.E)),
};
}
foreach (var item in base.GetContextMenuItemsForSelection(selection))
yield return item;

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.Collections.Generic;
using osu.Game.Audio;
@ -48,5 +49,24 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon
return originalBank;
}
}
public override bool Equals(HitSampleInfo? other) => other is VolumeAwareHitSampleInfo && base.Equals(other);
/// <remarks>
/// <para>
/// This override attempts to match the <see cref="Equals"/> override above, but in theory it is not strictly necessary.
/// Recall that <see cref="GetHashCode"/> <a href="https://learn.microsoft.com/en-us/dotnet/api/system.object.gethashcode?view=net-8.0#notes-to-inheritors">must meet the following requirements</a>:
/// </para>
/// <para>
/// "If two objects compare as equal, the <see cref="GetHashCode"/> method for each object must return the same value.
/// However, if two objects do not compare as equal, <see cref="GetHashCode"/> methods for the two objects do not have to return different values."
/// </para>
/// <para>
/// Making this override combine the value generated by the base <see cref="GetHashCode"/> implementation with a constant means
/// that <see cref="HitSampleInfo"/> and <see cref="VolumeAwareHitSampleInfo"/> instances which have the same values of their members
/// will not have equal hash codes, which is slightly more efficient when these objects are used as dictionary keys.
/// </para>
/// </remarks>
public override int GetHashCode() => HashCode.Combine(base.GetHashCode(), 1);
}
}

View File

@ -190,9 +190,12 @@ namespace osu.Game.Rulesets.Taiko
public override HitObjectComposer CreateHitObjectComposer() => new TaikoHitObjectComposer(this);
public override IEnumerable<SetupSection> CreateEditorSetupSections() =>
public override IEnumerable<Drawable> CreateEditorSetupSections() =>
[
new MetadataSection(),
new TaikoDifficultySection(),
new ResourcesSection(),
new DesignSection(),
];
public override IBeatmapVerifier CreateBeatmapVerifier() => new TaikoBeatmapVerifier();

View File

@ -112,6 +112,7 @@ namespace osu.Game.Tests.Editing
{
SliderVelocityMultiplier = slider_velocity
};
AddStep("add to beatmap", () => composer.EditorBeatmap.Add(referenceObject));
assertSnapDistance(base_distance * slider_velocity, referenceObject, true);
assertSnappedDistance(base_distance * slider_velocity + 10, base_distance * slider_velocity, referenceObject);
@ -227,26 +228,65 @@ namespace osu.Game.Tests.Editing
assertSnappedDistance(400, 400);
}
[Test]
public void TestUnsnappedObject()
{
var slider = new Slider
{
StartTime = 0,
Path = new SliderPath
{
ControlPoints =
{
new PathControlPoint(),
// simulate object snapped to 1/3rds
// this object's end time will be 2000 / 3 = 666.66... ms
new PathControlPoint(new Vector2(200 / 3f, 0)),
}
}
};
AddStep("add slider", () => composer.EditorBeatmap.Add(slider));
AddStep("set snap to 1/4", () => BeatDivisor.Value = 4);
// with default beat length of 1000ms and snap at 1/4, the valid snap times are 500ms, 750ms, and 1000ms
// with default settings, the snapped distance will be a tenth of the difference of the time delta
// (500 - 666.66...) / 10 = -16.66... = -100 / 6
assertSnappedDistance(0, -100 / 6f, slider);
assertSnappedDistance(7, -100 / 6f, slider);
// (750 - 666.66...) / 10 = 8.33... = 100 / 12
assertSnappedDistance(9, 100 / 12f, slider);
assertSnappedDistance(33, 100 / 12f, slider);
// (1000 - 666.66...) / 10 = 33.33... = 100 / 3
assertSnappedDistance(34, 100 / 3f, slider);
}
[Test]
public void TestUseCurrentSnap()
{
ExpandableButton getCurrentSnapButton() => composer.ChildrenOfType<EditorToolboxGroup>().Single(g => g.Name == "snapping")
.ChildrenOfType<ExpandableButton>().Single();
AddStep("add objects to beatmap", () =>
{
editorBeatmap.Add(new HitCircle { StartTime = 1000 });
editorBeatmap.Add(new HitCircle { Position = new Vector2(100), StartTime = 2000 });
});
AddStep("hover use current snap button", () => InputManager.MoveMouseTo(composer.ChildrenOfType<ExpandableButton>().Single()));
AddUntilStep("use current snap expanded", () => composer.ChildrenOfType<ExpandableButton>().Single().Expanded.Value, () => Is.True);
AddStep("hover use current snap button", () => InputManager.MoveMouseTo(getCurrentSnapButton()));
AddUntilStep("use current snap expanded", () => getCurrentSnapButton().Expanded.Value, () => Is.True);
AddStep("seek before first object", () => EditorClock.Seek(0));
AddUntilStep("use current snap not available", () => composer.ChildrenOfType<ExpandableButton>().Single().Enabled.Value, () => Is.False);
AddUntilStep("use current snap not available", () => getCurrentSnapButton().Enabled.Value, () => Is.False);
AddStep("seek to between objects", () => EditorClock.Seek(1500));
AddUntilStep("use current snap available", () => composer.ChildrenOfType<ExpandableButton>().Single().Enabled.Value, () => Is.True);
AddUntilStep("use current snap available", () => getCurrentSnapButton().Enabled.Value, () => Is.True);
AddStep("seek after last object", () => EditorClock.Seek(2500));
AddUntilStep("use current snap not available", () => composer.ChildrenOfType<ExpandableButton>().Single().Enabled.Value, () => Is.False);
AddUntilStep("use current snap not available", () => getCurrentSnapButton().Enabled.Value, () => Is.False);
}
private void assertSnapDistance(float expectedDistance, HitObject? referenceObject, bool includeSliderVelocity)
@ -262,7 +302,7 @@ namespace osu.Game.Tests.Editing
=> AddAssert($"distance = {distance} -> duration = {expectedDuration} (snapped)", () => composer.DistanceSnapProvider.FindSnappedDuration(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDuration).Within(Precision.FLOAT_EPSILON));
private void assertSnappedDistance(float distance, float expectedDistance, HitObject? referenceObject = null)
=> AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)", () => composer.DistanceSnapProvider.FindSnappedDistance(referenceObject ?? new HitObject(), distance), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON));
=> AddAssert($"distance = {distance} -> distance = {expectedDistance} (snapped)", () => composer.DistanceSnapProvider.FindSnappedDistance(referenceObject ?? new HitObject(), distance, DistanceSnapTarget.End), () => Is.EqualTo(expectedDistance).Within(Precision.FLOAT_EPSILON));
private partial class TestHitObjectComposer : OsuHitObjectComposer
{

View File

@ -0,0 +1,52 @@
// 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.Framework.Bindables;
using osu.Game.Utils;
namespace osu.Game.Tests.Utils
{
[TestFixture]
public class BindableValueAccessorTest
{
[Test]
public void GetValue()
{
const int value = 1337;
BindableInt bindable = new BindableInt(value);
Assert.That(BindableValueAccessor.GetValue(bindable), Is.EqualTo(value));
}
[Test]
public void SetValue()
{
const int value = 1337;
BindableInt bindable = new BindableInt();
BindableValueAccessor.SetValue(bindable, value);
Assert.That(bindable.Value, Is.EqualTo(value));
}
[Test]
public void GetInvalidBindable()
{
BindableList<object> list = new BindableList<object>();
Assert.That(BindableValueAccessor.GetValue(list), Is.EqualTo(list));
}
[Test]
public void SetInvalidBindable()
{
const int value = 1337;
BindableList<int> list = new BindableList<int> { value };
BindableValueAccessor.SetValue(list, 2);
Assert.That(list, Has.Exactly(1).Items);
Assert.That(list[0], Is.EqualTo(value));
}
}
}

View File

@ -0,0 +1,51 @@
// 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.Utils;
using osuTK;
namespace osu.Game.Tests.Utils
{
[TestFixture]
public class GeometryUtilsTest
{
[TestCase(new int[] { }, new int[] { })]
[TestCase(new[] { 0, 0 }, new[] { 0, 0 })]
[TestCase(new[] { 0, 0, 1, 1, 1, -1, 2, 0 }, new[] { 0, 0, 1, 1, 2, 0, 1, -1 })]
[TestCase(new[] { 0, 0, 1, 1, 1, -1, 2, 0, 1, 0 }, new[] { 0, 0, 1, 1, 2, 0, 1, -1 })]
[TestCase(new[] { 0, 0, 1, 1, 2, -1, 2, 0, 1, 0, 4, 10 }, new[] { 0, 0, 4, 10, 2, -1 })]
public void TestConvexHull(int[] values, int[] expected)
{
var points = new Vector2[values.Length / 2];
for (int i = 0; i < values.Length; i += 2)
points[i / 2] = new Vector2(values[i], values[i + 1]);
var expectedPoints = new Vector2[expected.Length / 2];
for (int i = 0; i < expected.Length; i += 2)
expectedPoints[i / 2] = new Vector2(expected[i], expected[i + 1]);
var hull = GeometryUtils.GetConvexHull(points);
Assert.That(hull, Is.EquivalentTo(expectedPoints));
}
[TestCase(new int[] { }, 0, 0, 0)]
[TestCase(new[] { 0, 0 }, 0, 0, 0)]
[TestCase(new[] { 0, 0, 1, 1, 1, -1, 2, 0 }, 1, 0, 1)]
[TestCase(new[] { 0, 0, 1, 1, 1, -1, 2, 0, 1, 0 }, 1, 0, 1)]
[TestCase(new[] { 0, 0, 1, 1, 2, -1, 2, 0, 1, 0, 4, 10 }, 3, 4.5f, 5.5901699f)]
public void TestMinimumEnclosingCircle(int[] values, float x, float y, float r)
{
var points = new Vector2[values.Length / 2];
for (int i = 0; i < values.Length; i += 2)
points[i / 2] = new Vector2(values[i], values[i + 1]);
(var centre, float radius) = GeometryUtils.MinimumEnclosingCircle(points);
Assert.That(centre.X, Is.EqualTo(x).Within(0.0001));
Assert.That(centre.Y, Is.EqualTo(y).Within(0.0001));
Assert.That(radius, Is.EqualTo(r).Within(0.0001));
}
}
}

View File

@ -0,0 +1,123 @@
// 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.Graphics;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Overlays;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Setup;
using osu.Game.Skinning;
using osuTK.Graphics;
namespace osu.Game.Tests.Visual.Editing
{
[HeadlessTest]
public partial class TestSceneColoursSection : OsuManualInputManagerTestScene
{
[Test]
public void TestNoBeatmapSkinColours()
{
LegacyBeatmapSkin skin = null!;
ColoursSection coloursSection = null!;
AddStep("create beatmap skin", () => skin = new LegacyBeatmapSkin(new BeatmapInfo(), null));
AddStep("create colours section", () => Child = new DependencyProvidingContainer
{
RelativeSizeAxes = Axes.Both,
CachedDependencies =
[
(typeof(EditorBeatmap), new EditorBeatmap(new Beatmap
{
BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }
}, skin)),
(typeof(OverlayColourProvider), new OverlayColourProvider(OverlayColourScheme.Aquamarine))
],
Child = coloursSection = new ColoursSection
{
RelativeSizeAxes = Axes.X,
}
});
AddAssert("beatmap skin has no colours", () => skin.Configuration.CustomComboColours, () => Is.Empty);
AddAssert("section displays default combo colours",
() => coloursSection.ChildrenOfType<FormColourPalette>().Single().Colours,
() => Is.EquivalentTo(new Colour4[]
{
SkinConfiguration.DefaultComboColours[1],
SkinConfiguration.DefaultComboColours[2],
SkinConfiguration.DefaultComboColours[3],
SkinConfiguration.DefaultComboColours[0],
}));
AddStep("add a colour", () => coloursSection.ChildrenOfType<FormColourPalette>().Single().Colours.Add(Colour4.Aqua));
AddAssert("beatmap skin has colours",
() => skin.Configuration.CustomComboColours,
() => Is.EquivalentTo(new[]
{
SkinConfiguration.DefaultComboColours[1],
SkinConfiguration.DefaultComboColours[2],
SkinConfiguration.DefaultComboColours[3],
Color4.Aqua,
SkinConfiguration.DefaultComboColours[0],
}));
}
[Test]
public void TestExistingColours()
{
LegacyBeatmapSkin skin = null!;
ColoursSection coloursSection = null!;
AddStep("create beatmap skin", () =>
{
skin = new LegacyBeatmapSkin(new BeatmapInfo(), null);
skin.Configuration.CustomComboColours = new List<Color4>
{
Color4.Azure,
Color4.Beige,
Color4.Chartreuse
};
});
AddStep("create colours section", () => Child = new DependencyProvidingContainer
{
RelativeSizeAxes = Axes.Both,
CachedDependencies =
[
(typeof(EditorBeatmap), new EditorBeatmap(new Beatmap
{
BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo }
}, skin)),
(typeof(OverlayColourProvider), new OverlayColourProvider(OverlayColourScheme.Aquamarine))
],
Child = coloursSection = new ColoursSection
{
RelativeSizeAxes = Axes.X,
}
});
AddAssert("section displays combo colours",
() => coloursSection.ChildrenOfType<FormColourPalette>().Single().Colours,
() => Is.EquivalentTo(new[]
{
Colour4.Beige,
Colour4.Chartreuse,
Colour4.Azure,
}));
AddStep("add a colour", () => coloursSection.ChildrenOfType<FormColourPalette>().Single().Colours.Add(Colour4.Aqua));
AddAssert("beatmap skin has colours",
() => skin.Configuration.CustomComboColours,
() => Is.EquivalentTo(new[]
{
Color4.Azure,
Color4.Beige,
Color4.Aqua,
Color4.Chartreuse
}));
}
}
}

View File

@ -84,6 +84,7 @@ namespace osu.Game.Tests.Visual.Editing
targetContainer = getTargetContainer();
initialRotation = targetContainer!.Rotation;
DefaultOrigin = ToLocalSpace(targetContainer.ToScreenSpace(Vector2.Zero));
base.Begin();
}
@ -134,7 +135,7 @@ namespace osu.Game.Tests.Visual.Editing
OriginalSurroundingQuad = new Quad(targetContainer!.X, targetContainer.Y, targetContainer.Width, targetContainer.Height);
}
public override void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both)
public override void Update(Vector2 scale, Vector2? origin = null, Axes adjustAxis = Axes.Both, float axisRotation = 0)
{
if (targetContainer == null)
throw new InvalidOperationException($"Cannot {nameof(Update)} a scale operation without calling {nameof(Begin)} first!");

View File

@ -4,9 +4,11 @@
using System;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Cursor;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets;
@ -36,6 +38,9 @@ namespace osu.Game.Tests.Visual.Editing
private ContextMenuContainer contextMenuContainer
=> Editor.ChildrenOfType<ContextMenuContainer>().First();
private SelectionBoxScaleHandle getScaleHandle(Anchor anchor)
=> Editor.ChildrenOfType<SelectionBoxScaleHandle>().First(it => it.Anchor == anchor);
private void moveMouseToObject(Func<HitObject> targetFunc)
{
AddStep("move mouse to object", () =>
@ -78,7 +83,7 @@ namespace osu.Game.Tests.Visual.Editing
}
[Test]
public void TestNudgeSelection()
public void TestNudgeSelectionTime()
{
HitCircle[] addedObjects = null!;
@ -99,6 +104,51 @@ namespace osu.Game.Tests.Visual.Editing
AddAssert("objects reverted to original position", () => addedObjects[0].StartTime == 100);
}
[Test]
public void TestNudgeSelectionPosition()
{
HitCircle addedObject = null!;
AddStep("add hitobjects", () => EditorBeatmap.AddRange(new[]
{
addedObject = new HitCircle { StartTime = 200, Position = new Vector2(100) },
}));
AddStep("select object", () => EditorBeatmap.SelectedHitObjects.Add(addedObject));
AddStep("nudge up", () =>
{
InputManager.PressKey(Key.ControlLeft);
InputManager.Key(Key.Up);
InputManager.ReleaseKey(Key.ControlLeft);
});
AddAssert("object position moved up", () => addedObject.Position.Y, () => Is.EqualTo(99).Within(Precision.FLOAT_EPSILON));
AddStep("nudge down", () =>
{
InputManager.PressKey(Key.ControlLeft);
InputManager.Key(Key.Down);
InputManager.ReleaseKey(Key.ControlLeft);
});
AddAssert("object position moved down", () => addedObject.Position.Y, () => Is.EqualTo(100).Within(Precision.FLOAT_EPSILON));
AddStep("nudge left", () =>
{
InputManager.PressKey(Key.ControlLeft);
InputManager.Key(Key.Left);
InputManager.ReleaseKey(Key.ControlLeft);
});
AddAssert("object position moved left", () => addedObject.Position.X, () => Is.EqualTo(99).Within(Precision.FLOAT_EPSILON));
AddStep("nudge right", () =>
{
InputManager.PressKey(Key.ControlLeft);
InputManager.Key(Key.Right);
InputManager.ReleaseKey(Key.ControlLeft);
});
AddAssert("object position moved right", () => addedObject.Position.X, () => Is.EqualTo(100).Within(Precision.FLOAT_EPSILON));
}
[Test]
public void TestRotateHotkeys()
{
@ -215,6 +265,51 @@ namespace osu.Game.Tests.Visual.Editing
AddAssert("2 hitobjects selected", () => EditorBeatmap.SelectedHitObjects.Count == 2 && !EditorBeatmap.SelectedHitObjects.Contains(addedObjects[1]));
}
[Test]
public void TestMultiSelectWithDragBox()
{
var addedObjects = new[]
{
new HitCircle { StartTime = 100 },
new HitCircle { StartTime = 200, Position = new Vector2(100) },
new HitCircle { StartTime = 300, Position = new Vector2(512, 0) },
new HitCircle { StartTime = 400, Position = new Vector2(412, 100) },
};
AddStep("add hitobjects", () => EditorBeatmap.AddRange(addedObjects));
AddStep("start dragging", () =>
{
InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.Centre);
InputManager.PressButton(MouseButton.Left);
});
AddStep("drag to left corner", () => InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.TopLeft - new Vector2(5)));
AddStep("end dragging", () => InputManager.ReleaseButton(MouseButton.Left));
AddAssert("2 hitobjects selected", () => EditorBeatmap.SelectedHitObjects, () => Has.Count.EqualTo(2));
AddStep("start dragging with control", () =>
{
InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.Centre);
InputManager.PressButton(MouseButton.Left);
InputManager.PressKey(Key.ControlLeft);
});
AddStep("drag to left corner", () => InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.TopRight + new Vector2(5, -5)));
AddStep("end dragging", () => InputManager.ReleaseButton(MouseButton.Left));
AddStep("release control", () => InputManager.ReleaseKey(Key.ControlLeft));
AddAssert("4 hitobjects selected", () => EditorBeatmap.SelectedHitObjects, () => Has.Count.EqualTo(4));
AddStep("start dragging without control", () =>
{
InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.Centre);
InputManager.PressButton(MouseButton.Left);
});
AddStep("drag to left corner", () => InputManager.MoveMouseTo(blueprintContainer.ScreenSpaceDrawQuad.TopRight + new Vector2(5, -5)));
AddStep("end dragging", () => InputManager.ReleaseButton(MouseButton.Left));
AddAssert("2 hitobjects selected", () => EditorBeatmap.SelectedHitObjects, () => Has.Count.EqualTo(2));
}
[Test]
public void TestNearestSelection()
{
@ -519,5 +614,137 @@ namespace osu.Game.Tests.Visual.Editing
AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft));
}
[Test]
public void TestShiftModifierMaintainsAspectRatio()
{
HitCircle[] addedObjects = null!;
float aspectRatioBeforeDrag = 0;
float getAspectRatio() => (addedObjects[1].X - addedObjects[0].X) / (addedObjects[1].Y - addedObjects[0].Y);
AddStep("add hitobjects", () =>
{
EditorBeatmap.AddRange(addedObjects = new[]
{
new HitCircle { StartTime = 100, Position = new Vector2(150, 150) },
new HitCircle { StartTime = 200, Position = new Vector2(250, 200) },
});
aspectRatioBeforeDrag = getAspectRatio();
});
AddStep("select objects", () => EditorBeatmap.SelectedHitObjects.AddRange(addedObjects));
AddStep("move mouse to handle", () => InputManager.MoveMouseTo(getScaleHandle(Anchor.BottomRight).ScreenSpaceDrawQuad.Centre));
AddStep("begin drag", () => InputManager.PressButton(MouseButton.Left));
AddStep("move mouse", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(50, 0)));
AddStep("aspect ratio does not equal", () => Assert.AreNotEqual(aspectRatioBeforeDrag, getAspectRatio()));
AddStep("press shift", () => InputManager.PressKey(Key.ShiftLeft));
AddStep("aspect ratio does equal", () => Assert.AreEqual(aspectRatioBeforeDrag, getAspectRatio()));
AddStep("end drag", () => InputManager.ReleaseButton(MouseButton.Left));
AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft));
}
[Test]
public void TestAltModifierScalesAroundCenter()
{
HitCircle[] addedObjects = null!;
Vector2 centerBeforeDrag = Vector2.Zero;
Vector2 getCenter() => (addedObjects[0].Position + addedObjects[1].Position) / 2;
AddStep("add hitobjects", () =>
{
EditorBeatmap.AddRange(addedObjects = new[]
{
new HitCircle { StartTime = 100, Position = new Vector2(150, 150) },
new HitCircle { StartTime = 200, Position = new Vector2(250, 200) },
});
centerBeforeDrag = getCenter();
});
AddStep("select objects", () => EditorBeatmap.SelectedHitObjects.AddRange(addedObjects));
AddStep("move mouse to handle", () => InputManager.MoveMouseTo(getScaleHandle(Anchor.BottomRight).ScreenSpaceDrawQuad.Centre));
AddStep("begin drag", () => InputManager.PressButton(MouseButton.Left));
AddStep("move mouse", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(50, 0)));
AddStep("center does not equal", () => Assert.AreNotEqual(centerBeforeDrag, getCenter()));
AddStep("press alt", () => InputManager.PressKey(Key.AltLeft));
AddStep("center does equal", () => Assert.AreEqual(centerBeforeDrag, getCenter()));
AddStep("end drag", () => InputManager.ReleaseButton(MouseButton.Left));
AddStep("release alt", () => InputManager.ReleaseKey(Key.AltLeft));
}
[Test]
public void TestShiftAndAltModifierKeys()
{
HitCircle[] addedObjects = null!;
float aspectRatioBeforeDrag = 0;
Vector2 centerBeforeDrag = Vector2.Zero;
float getAspectRatio() => (addedObjects[1].X - addedObjects[0].X) / (addedObjects[1].Y - addedObjects[0].Y);
Vector2 getCenter() => (addedObjects[0].Position + addedObjects[1].Position) / 2;
AddStep("add hitobjects", () =>
{
EditorBeatmap.AddRange(addedObjects = new[]
{
new HitCircle { StartTime = 100, Position = new Vector2(150, 150) },
new HitCircle { StartTime = 200, Position = new Vector2(250, 200) },
});
aspectRatioBeforeDrag = getAspectRatio();
centerBeforeDrag = getCenter();
});
AddStep("select objects", () => EditorBeatmap.SelectedHitObjects.AddRange(addedObjects));
AddStep("move mouse to handle", () => InputManager.MoveMouseTo(getScaleHandle(Anchor.BottomRight).ScreenSpaceDrawQuad.Centre));
AddStep("begin drag", () => InputManager.PressButton(MouseButton.Left));
AddStep("move mouse", () => InputManager.MoveMouseTo(InputManager.CurrentState.Mouse.Position + new Vector2(50, 0)));
AddStep("aspect ratio does not equal", () => Assert.AreNotEqual(aspectRatioBeforeDrag, getAspectRatio()));
AddStep("center does not equal", () => Assert.AreNotEqual(centerBeforeDrag, getCenter()));
AddStep("press shift", () => InputManager.PressKey(Key.ShiftLeft));
AddStep("aspect ratio does equal", () => Assert.AreEqual(aspectRatioBeforeDrag, getAspectRatio()));
AddStep("center does not equal", () => Assert.AreNotEqual(centerBeforeDrag, getCenter()));
AddStep("press alt", () => InputManager.PressKey(Key.AltLeft));
AddStep("center does equal", () => Assert.AreEqual(centerBeforeDrag, getCenter()));
AddStep("end drag", () => InputManager.ReleaseButton(MouseButton.Left));
AddStep("release shift", () => InputManager.ReleaseKey(Key.ShiftLeft));
AddStep("release alt", () => InputManager.ReleaseKey(Key.AltLeft));
}
}
}

View File

@ -7,12 +7,14 @@ using System;
using System.Globalization;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Overlays;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Setup;
@ -25,6 +27,9 @@ namespace osu.Game.Tests.Visual.Editing
private TestDesignSection designSection;
private EditorBeatmap editorBeatmap { get; set; }
[Cached]
private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine);
[SetUpSteps]
public void SetUp()
{
@ -42,7 +47,7 @@ namespace osu.Game.Tests.Visual.Editing
{
(typeof(EditorBeatmap), editorBeatmap)
},
Child = designSection = new TestDesignSection()
Child = designSection = new TestDesignSection { RelativeSizeAxes = Axes.X }
});
}
@ -99,11 +104,11 @@ namespace osu.Game.Tests.Visual.Editing
private partial class TestDesignSection : DesignSection
{
public new LabelledSwitchButton EnableCountdown => base.EnableCountdown;
public new FormCheckBox EnableCountdown => base.EnableCountdown;
public new FillFlowContainer CountdownSettings => base.CountdownSettings;
public new LabelledEnumDropdown<CountdownType> CountdownSpeed => base.CountdownSpeed;
public new LabelledNumberBox CountdownOffset => base.CountdownOffset;
public new FormEnumDropdown<CountdownType> CountdownSpeed => base.CountdownSpeed;
public new FormTextBox CountdownOffset => base.CountdownOffset;
}
}
}

View File

@ -13,6 +13,7 @@ using osu.Game.Graphics.UserInterface;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Components.Menus;
using osu.Game.Storyboards;
using osu.Game.Tests.Beatmaps.IO;
using osuTK.Input;
@ -60,7 +61,7 @@ namespace osu.Game.Tests.Visual.Editing
beatmapSetHashBefore = Beatmap.Value.BeatmapSetInfo.Hash;
});
AddStep("click File", () => this.ChildrenOfType<DrawableOsuMenuItem>().First().TriggerClick());
AddStep("click File", () => this.ChildrenOfType<EditorMenuBar.DrawableEditorBarMenuItem>().First().TriggerClick());
if (i == 11)
{
@ -107,7 +108,7 @@ namespace osu.Game.Tests.Visual.Editing
EditorBeatmap.EndChange();
});
AddStep("click File", () => this.ChildrenOfType<DrawableOsuMenuItem>().First().TriggerClick());
AddStep("click File", () => this.ChildrenOfType<EditorMenuBar.DrawableEditorBarMenuItem>().First().TriggerClick());
AddStep("click delete", () => getDeleteMenuItem().TriggerClick());
AddUntilStep("wait for dialog", () => DialogOverlay.CurrentDialog != null);

View File

@ -199,7 +199,7 @@ namespace osu.Game.Tests.Visual.Editing
public double FindSnappedDuration(HitObject referenceObject, float distance) => 0;
public float FindSnappedDistance(HitObject referenceObject, float distance) => 0;
public float FindSnappedDistance(HitObject referenceObject, float distance, DistanceSnapTarget target) => 0;
}
}
}

View File

@ -1,11 +1,13 @@
// 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.Linq;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Objects;
using osuTK.Input;
namespace osu.Game.Tests.Visual.Editing
@ -135,9 +137,42 @@ namespace osu.Game.Tests.Visual.Editing
pressAndCheckTime(Key.Up, 0);
}
private void pressAndCheckTime(Key key, double expectedTime)
[Test]
public void TestSeekBetweenObjects()
{
AddStep($"press {key}", () => InputManager.Key(key));
AddStep("add objects", () =>
{
EditorBeatmap.Clear();
EditorBeatmap.AddRange(new[]
{
new HitCircle { StartTime = 1000, },
new HitCircle { StartTime = 2250, },
new HitCircle { StartTime = 3600, },
});
});
AddStep("seek to 0", () => EditorClock.Seek(0));
pressAndCheckTime(Key.Right, 1000, Key.ControlLeft);
pressAndCheckTime(Key.Right, 2250, Key.ControlLeft);
pressAndCheckTime(Key.Right, 3600, Key.ControlLeft);
pressAndCheckTime(Key.Right, 3600, Key.ControlLeft);
pressAndCheckTime(Key.Left, 2250, Key.ControlLeft);
pressAndCheckTime(Key.Left, 1000, Key.ControlLeft);
pressAndCheckTime(Key.Left, 1000, Key.ControlLeft);
}
private void pressAndCheckTime(Key key, double expectedTime, params Key[] modifiers)
{
AddStep($"press {key} with {(modifiers.Any() ? string.Join(',', modifiers) : "no modifiers")}", () =>
{
foreach (var modifier in modifiers)
InputManager.PressKey(modifier);
InputManager.Key(key);
foreach (var modifier in modifiers)
InputManager.ReleaseKey(modifier);
});
AddUntilStep($"time is {expectedTime}", () => EditorClock.CurrentTime, () => Is.EqualTo(expectedTime).Within(1));
}
}

View File

@ -6,11 +6,13 @@
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Graphics.UserInterfaceV2;
using osu.Game.Overlays;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.Edit;
using osu.Game.Screens.Edit.Setup;
@ -20,6 +22,9 @@ namespace osu.Game.Tests.Visual.Editing
{
public partial class TestSceneMetadataSection : OsuManualInputManagerTestScene
{
[Cached]
private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine);
[Cached]
private EditorBeatmap editorBeatmap = new EditorBeatmap(new Beatmap
{
@ -201,7 +206,7 @@ namespace osu.Game.Tests.Visual.Editing
}
private void createSection()
=> AddStep("create metadata section", () => Child = metadataSection = new TestMetadataSection());
=> AddStep("create metadata section", () => Child = metadataSection = new TestMetadataSection { RelativeSizeAxes = Axes.X });
private void assertArtistMetadata(string expected)
=> AddAssert($"artist metadata is {expected}", () => editorBeatmap.Metadata.Artist, () => Is.EqualTo(expected));
@ -226,11 +231,11 @@ namespace osu.Game.Tests.Visual.Editing
private partial class TestMetadataSection : MetadataSection
{
public new LabelledTextBox ArtistTextBox => base.ArtistTextBox;
public new LabelledTextBox RomanisedArtistTextBox => base.RomanisedArtistTextBox;
public new FormTextBox ArtistTextBox => base.ArtistTextBox;
public new FormTextBox RomanisedArtistTextBox => base.RomanisedArtistTextBox;
public new LabelledTextBox TitleTextBox => base.TitleTextBox;
public new LabelledTextBox RomanisedTitleTextBox => base.RomanisedTitleTextBox;
public new FormTextBox TitleTextBox => base.TitleTextBox;
public new FormTextBox RomanisedTitleTextBox => base.RomanisedTitleTextBox;
}
}
}

View File

@ -100,6 +100,20 @@ namespace osu.Game.Tests.Visual.Editing
assertOnScreenAt(EditorScreenMode.Compose, 0);
}
[Test]
public void TestUrlDecodingOfArgs()
{
setUpEditor(new OsuRuleset().RulesetInfo);
AddAssert("is osu! ruleset", () => editorBeatmap.BeatmapInfo.Ruleset.Equals(new OsuRuleset().RulesetInfo));
AddStep("jump to encoded link", () => Game.HandleLink("osu://edit/00:14:142%20(1)"));
AddUntilStep("wait for seek", () => editorClock.SeekingOrStopped.Value);
AddAssert("time is correct", () => editorClock.CurrentTime, () => Is.EqualTo(14_142));
AddAssert("selected object is correct", () => editorBeatmap.SelectedHitObjects.Single().StartTime, () => Is.EqualTo(14_142));
}
private void addStepClickLink(string timestamp, string step = "", bool waitForSeek = true)
{
AddStep($"{step} {timestamp}", () =>

View File

@ -19,6 +19,7 @@ using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Screens;
using osu.Game.Screens.Play;
using osu.Game.Screens.Play.HUD.JudgementCounter;
using osu.Game.Tests.Beatmaps.IO;
using osu.Game.Tests.Gameplay;
using osu.Game.Tests.Visual.Multiplayer;
@ -167,14 +168,16 @@ namespace osu.Game.Tests.Visual.Gameplay
public void TestSpectatingDuringGameplay()
{
start();
sendFrames(300);
sendFrames(300, initialResultCount: 100);
loadSpectatingScreen();
waitForPlayerCurrent();
sendFrames(300);
sendFrames(300, initialResultCount: 100);
AddUntilStep("playing from correct point in time", () => player.ChildrenOfType<DrawableRuleset>().First().FrameStableClock.CurrentTime, () => Is.GreaterThan(30000));
AddAssert("check judgement counts are correct", () => player.ChildrenOfType<JudgementCountController>().Single().Counters.Sum(c => c.ResultCount.Value),
() => Is.GreaterThanOrEqualTo(100));
}
[Test]
@ -405,9 +408,9 @@ namespace osu.Game.Tests.Visual.Gameplay
private void checkPaused(bool state) =>
AddUntilStep($"game is {(state ? "paused" : "playing")}", () => player.ChildrenOfType<DrawableRuleset>().First().IsPaused.Value == state);
private void sendFrames(int count = 10, double startTime = 0)
private void sendFrames(int count = 10, double startTime = 0, int initialResultCount = 0)
{
AddStep("send frames", () => spectatorClient.SendFramesFromUser(streamingUser.Id, count, startTime));
AddStep("send frames", () => spectatorClient.SendFramesFromUser(streamingUser.Id, count, startTime, initialResultCount));
}
private void loadSpectatingScreen()

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