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

Merge pull request #18156 from ekrctb/catch-editor-per-object-sv

Automatically adjust per-hit object slider velocity in osu!catch editor
This commit is contained in:
Dean Herbert 2022-05-19 19:12:38 +09:00 committed by GitHub
commit 77b4bd97f8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 211 additions and 238 deletions

View File

@ -19,7 +19,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
{
public class TestSceneJuiceStreamPlacementBlueprint : CatchPlacementBlueprintTestScene
{
private const double velocity = 0.5;
private const double velocity_factor = 0.5;
private JuiceStream lastObject => LastObject?.HitObject as JuiceStream;
@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
{
var playable = base.GetPlayableBeatmap();
playable.Difficulty.SliderTickRate = 5;
playable.Difficulty.SliderMultiplier = velocity * 10;
playable.Difficulty.SliderMultiplier = velocity_factor * 10;
return playable;
}
@ -43,6 +43,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
AddAssert("end time is correct", () => Precision.AlmostEquals(lastObject.EndTime, times[1]));
AddAssert("start position is correct", () => Precision.AlmostEquals(lastObject.OriginalX, positions[0]));
AddAssert("end position is correct", () => Precision.AlmostEquals(lastObject.EndX, positions[1]));
AddAssert("default slider velocity", () => lastObject.DifficultyControlPoint.SliderVelocityBindable.IsDefault);
}
[Test]
@ -66,28 +67,21 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
}
[Test]
public void TestVelocityLimit()
public void TestSliderVelocityChange()
{
double[] times = { 100, 300 };
float[] positions = { 200, 500 };
addPlacementSteps(times, positions);
addPathCheckStep(times, new float[] { 200, 300 });
}
addPathCheckStep(times, positions);
[Test]
public void TestPreviousVerticesAreFixed()
{
double[] times = { 100, 300, 500, 700 };
float[] positions = { 200, 400, 100, 500 };
addPlacementSteps(times, positions);
addPathCheckStep(times, new float[] { 200, 300, 200, 300 });
AddAssert("slider velocity changed", () => !lastObject.DifficultyControlPoint.SliderVelocityBindable.IsDefault);
}
[Test]
public void TestClampedPositionIsRestored()
{
double[] times = { 100, 300, 500 };
float[] positions = { 200, 200, 0, 250 };
float[] positions = { 200, 200, -3000, 250 };
addMoveAndClickSteps(times[0], positions[0]);
addMoveAndClickSteps(times[1], positions[1]);
@ -97,15 +91,6 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
addPathCheckStep(times, new float[] { 200, 200, 250 });
}
[Test]
public void TestFirstVertexIsFixed()
{
double[] times = { 100, 200 };
float[] positions = { 100, 300 };
addPlacementSteps(times, positions);
addPathCheckStep(times, new float[] { 100, 150 });
}
[Test]
public void TestOutOfOrder()
{

View File

@ -101,31 +101,16 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
}
[Test]
public void TestClampedPositionIsRestored()
public void TestSliderVelocityChange()
{
const double velocity = 0.25;
double[] times = { 100, 500, 700 };
float[] positions = { 100, 100, 100 };
addBlueprintStep(times, positions, velocity);
double[] times = { 100, 300 };
float[] positions = { 200, 300 };
addBlueprintStep(times, positions);
AddAssert("default slider velocity", () => hitObject.DifficultyControlPoint.SliderVelocityBindable.IsDefault);
addDragStartStep(times[1], positions[1]);
AddMouseMoveStep(times[1], 200);
addVertexCheckStep(3, 1, times[1], 200);
addVertexCheckStep(3, 2, times[2], 150);
AddMouseMoveStep(times[1], 100);
addVertexCheckStep(3, 1, times[1], 100);
// Stored position is restored.
addVertexCheckStep(3, 2, times[2], positions[2]);
AddMouseMoveStep(times[1], 300);
addDragEndStep();
addDragStartStep(times[1], 300);
AddMouseMoveStep(times[1], 100);
// Position is different because a changed position is committed when the previous drag is ended.
addVertexCheckStep(3, 2, times[2], 250);
AddMouseMoveStep(times[1], 400);
AddAssert("slider velocity changed", () => !hitObject.DifficultyControlPoint.SliderVelocityBindable.IsDefault);
}
[Test]
@ -174,7 +159,7 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
addAddVertexSteps(500, 150);
addVertexCheckStep(3, 1, 500, 150);
addAddVertexSteps(90, 220);
addAddVertexSteps(90, 200);
addVertexCheckStep(4, 1, times[0], positions[0]);
addAddVertexSteps(750, 180);
@ -234,10 +219,10 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
{
var path = new JuiceStreamPath();
for (int i = 1; i < times.Length; i++)
path.Add((times[i] - times[0]) * velocity, positions[i] - positions[0]);
path.Add(times[i] - times[0], positions[i] - positions[0]);
var sliderPath = new SliderPath();
path.ConvertToSliderPath(sliderPath, 0);
path.ConvertToSliderPath(sliderPath, 0, velocity);
addBlueprintStep(times[0], positions[0], sliderPath, velocity);
}
@ -245,11 +230,11 @@ namespace osu.Game.Rulesets.Catch.Tests.Editor
private void addVertexCheckStep(int count, int index, double time, float x) => AddAssert($"vertex {index} of {count} at {time}, {x}", () =>
{
double expectedDistance = (time - hitObject.StartTime) * hitObject.Velocity;
double expectedTime = time - hitObject.StartTime;
float expectedX = x - hitObject.OriginalX;
var vertices = getVertices();
return vertices.Count == count &&
Precision.AlmostEquals(vertices[index].Distance, expectedDistance, 1e-3) &&
Precision.AlmostEquals(vertices[index].Time, expectedTime, 1e-3) &&
Precision.AlmostEquals(vertices[index].X, expectedX);
});

View File

@ -5,7 +5,6 @@ using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Utils;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
@ -37,14 +36,14 @@ namespace osu.Game.Rulesets.Catch.Tests
{
case 0:
{
double distance = rng.NextDouble() * scale * 2 - scale;
double time = rng.NextDouble() * scale * 2 - scale;
if (integralValues)
distance = Math.Round(distance);
time = Math.Round(time);
float oldX = path.PositionAtDistance(distance);
int index = path.InsertVertex(distance);
float oldX = path.PositionAtTime(time);
int index = path.InsertVertex(time);
Assert.That(path.Vertices.Count, Is.EqualTo(vertexCount + 1));
Assert.That(path.Vertices[index].Distance, Is.EqualTo(distance));
Assert.That(path.Vertices[index].Time, Is.EqualTo(time));
Assert.That(path.Vertices[index].X, Is.EqualTo(oldX));
break;
}
@ -52,20 +51,20 @@ namespace osu.Game.Rulesets.Catch.Tests
case 1:
{
int index = rng.Next(path.Vertices.Count);
double distance = path.Vertices[index].Distance;
double time = path.Vertices[index].Time;
float newX = (float)(rng.NextDouble() * scale * 2 - scale);
if (integralValues)
newX = MathF.Round(newX);
path.SetVertexPosition(index, newX);
Assert.That(path.Vertices.Count, Is.EqualTo(vertexCount));
Assert.That(path.Vertices[index].Distance, Is.EqualTo(distance));
Assert.That(path.Vertices[index].Time, Is.EqualTo(time));
Assert.That(path.Vertices[index].X, Is.EqualTo(newX));
break;
}
}
assertInvariants(path.Vertices, checkSlope);
assertInvariants(path.Vertices);
}
}
@ -76,7 +75,7 @@ namespace osu.Game.Rulesets.Catch.Tests
path.Add(10, 5);
path.Add(20, -5);
int removeCount = path.RemoveVertices((v, i) => v.Distance == 10 && i == 1);
int removeCount = path.RemoveVertices((v, i) => v.Time == 10 && i == 1);
Assert.That(removeCount, Is.EqualTo(1));
Assert.That(path.Vertices, Is.EqualTo(new[]
{
@ -131,8 +130,9 @@ namespace osu.Game.Rulesets.Catch.Tests
}));
}
[Test]
public void TestRandomConvertFromSliderPath()
[TestCase(10)]
[TestCase(0.1)]
public void TestRandomConvertFromSliderPath(double velocity)
{
var rng = new Random(1);
var path = new JuiceStreamPath();
@ -162,28 +162,28 @@ namespace osu.Game.Rulesets.Catch.Tests
else
sliderPath.ExpectedDistance.Value = null;
path.ConvertFromSliderPath(sliderPath);
Assert.That(path.Vertices[0].Distance, Is.EqualTo(0));
Assert.That(path.Distance, Is.EqualTo(sliderPath.Distance).Within(1e-3));
assertInvariants(path.Vertices, true);
path.ConvertFromSliderPath(sliderPath, velocity);
Assert.That(path.Vertices[0].Time, Is.EqualTo(0));
Assert.That(path.Duration * velocity, Is.EqualTo(sliderPath.Distance).Within(1e-3));
assertInvariants(path.Vertices);
double[] sampleDistances = Enumerable.Range(0, 10)
.Select(_ => rng.NextDouble() * sliderPath.Distance)
.ToArray();
double[] sampleTimes = Enumerable.Range(0, 10)
.Select(_ => rng.NextDouble() * sliderPath.Distance / velocity)
.ToArray();
foreach (double distance in sampleDistances)
foreach (double time in sampleTimes)
{
float expected = sliderPath.PositionAt(distance / sliderPath.Distance).X;
Assert.That(path.PositionAtDistance(distance), Is.EqualTo(expected).Within(1e-3));
float expected = sliderPath.PositionAt(time * velocity / sliderPath.Distance).X;
Assert.That(path.PositionAtTime(time), Is.EqualTo(expected).Within(1e-3));
}
path.ResampleVertices(sampleDistances);
assertInvariants(path.Vertices, true);
path.ResampleVertices(sampleTimes);
assertInvariants(path.Vertices);
foreach (double distance in sampleDistances)
foreach (double time in sampleTimes)
{
float expected = sliderPath.PositionAt(distance / sliderPath.Distance).X;
Assert.That(path.PositionAtDistance(distance), Is.EqualTo(expected).Within(1e-3));
float expected = sliderPath.PositionAt(time * velocity / sliderPath.Distance).X;
Assert.That(path.PositionAtTime(time), Is.EqualTo(expected).Within(1e-3));
}
}
}
@ -201,17 +201,17 @@ namespace osu.Game.Rulesets.Catch.Tests
do
{
double distance = rng.NextDouble() * 1e3;
double time = rng.NextDouble() * 1e3;
float x = (float)(rng.NextDouble() * 1e3);
path.Add(distance, x);
path.Add(time, x);
} while (rng.Next(5) != 0);
float sliderStartY = (float)(rng.NextDouble() * JuiceStreamPath.OSU_PLAYFIELD_HEIGHT);
path.ConvertToSliderPath(sliderPath, sliderStartY);
Assert.That(sliderPath.Distance, Is.EqualTo(path.Distance).Within(1e-3));
Assert.That(sliderPath.ControlPoints[0].Position.X, Is.EqualTo(path.Vertices[0].X));
assertInvariants(path.Vertices, true);
double requiredVelocity = path.ComputeRequiredVelocity();
double velocity = Math.Clamp(requiredVelocity, 1, 100);
path.ConvertToSliderPath(sliderPath, sliderStartY, velocity);
foreach (var point in sliderPath.ControlPoints)
{
@ -219,11 +219,18 @@ namespace osu.Game.Rulesets.Catch.Tests
Assert.That(sliderStartY + point.Position.Y, Is.InRange(0, JuiceStreamPath.OSU_PLAYFIELD_HEIGHT));
}
Assert.That(sliderPath.ControlPoints[0].Position.X, Is.EqualTo(path.Vertices[0].X));
// The path is preserved only if required velocity is used.
if (velocity < requiredVelocity) continue;
Assert.That(sliderPath.Distance / velocity, Is.EqualTo(path.Duration).Within(1e-3));
for (int i = 0; i < 10; i++)
{
double distance = rng.NextDouble() * path.Distance;
float expected = path.PositionAtDistance(distance);
Assert.That(sliderPath.PositionAt(distance / sliderPath.Distance).X, Is.EqualTo(expected).Within(1e-3));
double time = rng.NextDouble() * path.Duration;
float expected = path.PositionAtTime(time);
Assert.That(sliderPath.PositionAt(time * velocity / sliderPath.Distance).X, Is.EqualTo(expected).Within(3e-3));
}
}
}
@ -244,7 +251,7 @@ namespace osu.Game.Rulesets.Catch.Tests
path.Add(20, 0);
checkNewId();
path.RemoveVertices((v, _) => v.Distance == 20);
path.RemoveVertices((v, _) => v.Time == 20);
checkNewId();
path.ResampleVertices(new double[] { 5, 10, 15 });
@ -253,7 +260,7 @@ namespace osu.Game.Rulesets.Catch.Tests
path.Clear();
checkNewId();
path.ConvertFromSliderPath(new SliderPath());
path.ConvertFromSliderPath(new SliderPath(), 1);
checkNewId();
void checkNewId()
@ -263,25 +270,19 @@ namespace osu.Game.Rulesets.Catch.Tests
}
}
private void assertInvariants(IReadOnlyList<JuiceStreamPathVertex> vertices, bool checkSlope)
private void assertInvariants(IReadOnlyList<JuiceStreamPathVertex> vertices)
{
Assert.That(vertices, Is.Not.Empty);
for (int i = 0; i < vertices.Count; i++)
{
Assert.That(double.IsFinite(vertices[i].Distance));
Assert.That(double.IsFinite(vertices[i].Time));
Assert.That(float.IsFinite(vertices[i].X));
}
for (int i = 1; i < vertices.Count; i++)
{
Assert.That(vertices[i].Distance, Is.GreaterThanOrEqualTo(vertices[i - 1].Distance));
if (!checkSlope) continue;
float xDiff = Math.Abs(vertices[i].X - vertices[i - 1].X);
double distanceDiff = vertices[i].Distance - vertices[i - 1].Distance;
Assert.That(xDiff, Is.LessThanOrEqualTo(distanceDiff).Within(Precision.FLOAT_EPSILON));
Assert.That(vertices[i].Time, Is.GreaterThanOrEqualTo(vertices[i - 1].Time));
}
}
}

View File

@ -26,7 +26,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
public int VertexCount => path.Vertices.Count;
protected readonly Func<float, double> PositionToDistance;
protected readonly Func<float, double> PositionToTime;
protected IReadOnlyList<VertexState> VertexStates => vertexStates;
@ -44,9 +44,9 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
[CanBeNull]
private IBeatSnapProvider beatSnapProvider { get; set; }
protected EditablePath(Func<float, double> positionToDistance)
protected EditablePath(Func<float, double> positionToTime)
{
PositionToDistance = positionToDistance;
PositionToTime = positionToTime;
Anchor = Anchor.BottomLeft;
}
@ -59,13 +59,13 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
while (InternalChildren.Count < path.Vertices.Count)
AddInternal(new VertexPiece());
double distanceToYFactor = -hitObjectContainer.LengthAtTime(hitObject.StartTime, hitObject.StartTime + 1 / hitObject.Velocity);
double timeToYFactor = -hitObjectContainer.LengthAtTime(hitObject.StartTime, hitObject.StartTime + 1);
for (int i = 0; i < VertexCount; i++)
{
var piece = (VertexPiece)InternalChildren[i];
var vertex = path.Vertices[i];
piece.Position = new Vector2(vertex.X, (float)(vertex.Distance * distanceToYFactor));
piece.Position = new Vector2(vertex.X, (float)(vertex.Time * timeToYFactor));
piece.UpdateFrom(vertexStates[i]);
}
}
@ -73,14 +73,14 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
public void InitializeFromHitObject(JuiceStream hitObject)
{
var sliderPath = hitObject.Path;
path.ConvertFromSliderPath(sliderPath);
path.ConvertFromSliderPath(sliderPath, hitObject.Velocity);
// If the original slider path has non-linear type segments, resample the vertices at nested hit object times to reduce the number of vertices.
if (sliderPath.ControlPoints.Any(p => p.Type != null && p.Type != PathType.Linear))
{
path.ResampleVertices(hitObject.NestedHitObjects
.Skip(1).TakeWhile(h => !(h is Fruit)) // Only droplets in the first span are used.
.Select(h => (h.StartTime - hitObject.StartTime) * hitObject.Velocity));
.Select(h => h.StartTime - hitObject.StartTime));
}
vertexStates.Clear();
@ -92,11 +92,26 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
public void UpdateHitObjectFromPath(JuiceStream hitObject)
{
path.ConvertToSliderPath(hitObject.Path, hitObject.LegacyConvertedY);
// The SV setting may need to be changed for the current path.
var svBindable = hitObject.DifficultyControlPoint.SliderVelocityBindable;
double svToVelocityFactor = hitObject.Velocity / svBindable.Value;
double requiredVelocity = path.ComputeRequiredVelocity();
// The value is pre-rounded here because setting it to the bindable will rounded to the nearest value
// but it should be always rounded up to satisfy the required minimum velocity condition.
//
// This is rounded to integers instead of using the precision of the bindable
// because it results in a smaller number of non-redundant control points.
//
// The value is clamped here by the bindable min and max values.
// In case the required velocity is too large, the path is not preserved.
svBindable.Value = Math.Ceiling(requiredVelocity / svToVelocityFactor);
path.ConvertToSliderPath(hitObject.Path, hitObject.LegacyConvertedY, hitObject.Velocity);
if (beatSnapProvider == null) return;
double endTime = hitObject.StartTime + path.Distance / hitObject.Velocity;
double endTime = hitObject.StartTime + path.Duration;
double snappedEndTime = beatSnapProvider.SnapTime(endTime, hitObject.StartTime);
hitObject.Path.ExpectedDistance.Value = (snappedEndTime - hitObject.StartTime) * hitObject.Velocity;
}
@ -108,9 +123,9 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
protected override bool ComputeIsMaskedAway(RectangleF maskingBounds) => false;
protected int AddVertex(double distance, float x)
protected int AddVertex(double time, float x)
{
int index = path.InsertVertex(distance);
int index = path.InsertVertex(time);
path.SetVertexPosition(index, x);
vertexStates.Insert(index, new VertexState());
@ -138,9 +153,9 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
return true;
}
protected void MoveSelectedVertices(double distanceDelta, float xDelta)
protected void MoveSelectedVertices(double timeDelta, float xDelta)
{
// Because the vertex list may be reordered due to distance change, the state list must be reordered as well.
// Because the vertex list may be reordered due to time change, the state list must be reordered as well.
previousVertexStates.Clear();
previousVertexStates.AddRange(vertexStates);
@ -152,11 +167,11 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
for (int i = 1; i < vertexCount; i++)
{
var state = previousVertexStates[i];
double distance = state.VertexBeforeChange.Distance;
double time = state.VertexBeforeChange.Time;
if (state.IsSelected)
distance += distanceDelta;
time += timeDelta;
int newIndex = path.InsertVertex(Math.Max(0, distance));
int newIndex = path.InsertVertex(Math.Max(0, time));
vertexStates.Insert(newIndex, state);
}

View File

@ -15,15 +15,15 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
/// </summary>
private JuiceStreamPathVertex lastVertex;
public PlacementEditablePath(Func<float, double> positionToDistance)
: base(positionToDistance)
public PlacementEditablePath(Func<float, double> positionToTime)
: base(positionToTime)
{
}
public void AddNewVertex()
{
var endVertex = Vertices[^1];
int index = AddVertex(endVertex.Distance, endVertex.X);
int index = AddVertex(endVertex.Time, endVertex.X);
for (int i = 0; i < VertexCount; i++)
{
@ -41,9 +41,9 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
public void MoveLastVertex(Vector2 screenSpacePosition)
{
Vector2 position = ToRelativePosition(screenSpacePosition);
double distanceDelta = PositionToDistance(position.Y) - lastVertex.Distance;
double timeDelta = PositionToTime(position.Y) - lastVertex.Time;
float xDelta = position.X - lastVertex.X;
MoveSelectedVertices(distanceDelta, xDelta);
MoveSelectedVertices(timeDelta, xDelta);
}
}
}

View File

@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
{
private readonly Path drawablePath;
private readonly List<(double Distance, float X)> vertices = new List<(double, float)>();
private readonly List<(double Time, float X)> vertices = new List<(double, float)>();
public ScrollingPath()
{
@ -35,16 +35,16 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
public void UpdatePathFrom(ScrollingHitObjectContainer hitObjectContainer, JuiceStream hitObject)
{
double distanceToYFactor = -hitObjectContainer.LengthAtTime(hitObject.StartTime, hitObject.StartTime + 1 / hitObject.Velocity);
double timeToYFactor = -hitObjectContainer.LengthAtTime(hitObject.StartTime, hitObject.StartTime + 1);
computeDistanceXs(hitObject);
computeTimeXs(hitObject);
drawablePath.Vertices = vertices
.Select(v => new Vector2(v.X, (float)(v.Distance * distanceToYFactor)))
.Select(v => new Vector2(v.X, (float)(v.Time * timeToYFactor)))
.ToArray();
drawablePath.OriginPosition = drawablePath.PositionInBoundingBox(Vector2.Zero);
}
private void computeDistanceXs(JuiceStream hitObject)
private void computeTimeXs(JuiceStream hitObject)
{
vertices.Clear();
@ -54,17 +54,17 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
if (sliderVertices.Count == 0)
return;
double distance = 0;
double time = 0;
Vector2 lastPosition = Vector2.Zero;
for (int repeat = 0; repeat < hitObject.RepeatCount + 1; repeat++)
{
foreach (var position in sliderVertices)
{
distance += Vector2.Distance(lastPosition, position);
time += Vector2.Distance(lastPosition, position) / hitObject.Velocity;
lastPosition = position;
vertices.Add((distance, position.X));
vertices.Add((time, position.X));
}
sliderVertices.Reverse();

View File

@ -27,15 +27,15 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
[CanBeNull]
private IEditorChangeHandler changeHandler { get; set; }
public SelectionEditablePath(Func<float, double> positionToDistance)
: base(positionToDistance)
public SelectionEditablePath(Func<float, double> positionToTime)
: base(positionToTime)
{
}
public void AddVertex(Vector2 relativePosition)
{
double distance = Math.Max(0, PositionToDistance(relativePosition.Y));
int index = AddVertex(distance, relativePosition.X);
double time = Math.Max(0, PositionToTime(relativePosition.Y));
int index = AddVertex(time, relativePosition.X);
selectOnly(index);
}
@ -83,9 +83,9 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints.Components
protected override void OnDrag(DragEvent e)
{
Vector2 mousePosition = ToRelativePosition(e.ScreenSpaceMousePosition);
double distanceDelta = PositionToDistance(mousePosition.Y) - PositionToDistance(dragStartPosition.Y);
double timeDelta = PositionToTime(mousePosition.Y) - PositionToTime(dragStartPosition.Y);
float xDelta = mousePosition.X - dragStartPosition.X;
MoveSelectedVertices(distanceDelta, xDelta);
MoveSelectedVertices(timeDelta, xDelta);
}
protected override void OnDragEnd(DragEndEvent e)

View File

@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints
{
scrollingPath = new ScrollingPath(),
nestedOutlineContainer = new NestedOutlineContainer(),
editablePath = new PlacementEditablePath(positionToDistance)
editablePath = new PlacementEditablePath(positionToTime)
};
}
@ -121,10 +121,10 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints
lastEditablePathId = editablePath.PathId;
}
private double positionToDistance(float relativeYPosition)
private double positionToTime(float relativeYPosition)
{
double time = HitObjectContainer.TimeAtPosition(relativeYPosition, HitObject.StartTime);
return (time - HitObject.StartTime) * HitObject.Velocity;
return time - HitObject.StartTime;
}
}
}

View File

@ -62,7 +62,7 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints
{
scrollingPath = new ScrollingPath(),
nestedOutlineContainer = new NestedOutlineContainer(),
editablePath = new SelectionEditablePath(positionToDistance)
editablePath = new SelectionEditablePath(positionToTime)
};
}
@ -145,10 +145,10 @@ namespace osu.Game.Rulesets.Catch.Edit.Blueprints
return new RectangleF(left, top, right - left, bottom - top).Inflate(objectRadius);
}
private double positionToDistance(float relativeYPosition)
private double positionToTime(float relativeYPosition)
{
double time = HitObjectContainer.TimeAtPosition(relativeYPosition, HitObject.StartTime);
return (time - HitObject.StartTime) * HitObject.Velocity;
return time - HitObject.StartTime;
}
private void initializeJuiceStreamPath()

View File

@ -27,10 +27,16 @@ namespace osu.Game.Rulesets.Catch.Objects
public int RepeatCount { get; set; }
[JsonIgnore]
public double Velocity { get; private set; }
private double velocityFactor;
[JsonIgnore]
public double TickDistance { get; private set; }
private double tickDistanceFactor;
[JsonIgnore]
public double Velocity => velocityFactor * DifficultyControlPoint.SliderVelocity;
[JsonIgnore]
public double TickDistance => tickDistanceFactor * DifficultyControlPoint.SliderVelocity;
/// <summary>
/// The length of one span of this <see cref="JuiceStream"/>.
@ -43,10 +49,8 @@ namespace osu.Game.Rulesets.Catch.Objects
TimingControlPoint timingPoint = controlPointInfo.TimingPointAt(StartTime);
double scoringDistance = base_scoring_distance * difficulty.SliderMultiplier * DifficultyControlPoint.SliderVelocity;
Velocity = scoringDistance / timingPoint.BeatLength;
TickDistance = scoringDistance / difficulty.SliderTickRate;
velocityFactor = base_scoring_distance * difficulty.SliderMultiplier / timingPoint.BeatLength;
tickDistanceFactor = base_scoring_distance * difficulty.SliderMultiplier / difficulty.SliderTickRate;
}
protected override void CreateNestedHitObjects(CancellationToken cancellationToken)

View File

@ -20,11 +20,6 @@ namespace osu.Game.Rulesets.Catch.Objects
/// However, the <see cref="SliderPath"/> representation is difficult to work with.
/// This <see cref="JuiceStreamPath"/> represents the path in a more convenient way, a polyline connecting list of <see cref="JuiceStreamPathVertex"/>s.
/// </para>
/// <para>
/// The path can be regarded as a function from the closed interval <c>[Vertices[0].Distance, Vertices[^1].Distance]</c> to the x position, given by <see cref="PositionAtDistance"/>.
/// To ensure the path is convertible to a <see cref="SliderPath"/>, the slope of the function must not be more than <c>1</c> everywhere,
/// and this slope condition is always maintained as an invariant.
/// </para>
/// </summary>
public class JuiceStreamPath
{
@ -46,9 +41,9 @@ namespace osu.Game.Rulesets.Catch.Objects
public int InvalidationID { get; private set; } = 1;
/// <summary>
/// The difference between first vertex's <see cref="JuiceStreamPathVertex.Distance"/> and last vertex's <see cref="JuiceStreamPathVertex.Distance"/>.
/// The difference between first vertex's <see cref="JuiceStreamPathVertex.Time"/> and last vertex's <see cref="JuiceStreamPathVertex.Time"/>.
/// </summary>
public double Distance => vertices[^1].Distance - vertices[0].Distance;
public double Duration => vertices[^1].Time - vertices[0].Time;
/// <remarks>
/// This list should always be non-empty.
@ -59,15 +54,15 @@ namespace osu.Game.Rulesets.Catch.Objects
};
/// <summary>
/// Compute the x-position of the path at the given <paramref name="distance"/>.
/// Compute the x-position of the path at the given <paramref name="time"/>.
/// </summary>
/// <remarks>
/// When the given distance is outside of the path, the x position at the corresponding endpoint is returned,
/// When the given time is outside of the path, the x position at the corresponding endpoint is returned,
/// </remarks>
public float PositionAtDistance(double distance)
public float PositionAtTime(double time)
{
int index = vertexIndexAtDistance(distance);
return positionAtDistance(distance, index);
int index = vertexIndexAtTime(time);
return positionAtTime(time, index);
}
/// <summary>
@ -81,19 +76,19 @@ namespace osu.Game.Rulesets.Catch.Objects
}
/// <summary>
/// Insert a vertex at given <paramref name="distance"/>.
/// The <see cref="PositionAtDistance"/> is used as the position of the new vertex.
/// Insert a vertex at given <paramref name="time"/>.
/// The <see cref="PositionAtTime"/> is used as the position of the new vertex.
/// Thus, the set of points of the path is not changed (up to floating-point precision).
/// </summary>
/// <returns>The index of the new vertex.</returns>
public int InsertVertex(double distance)
public int InsertVertex(double time)
{
if (!double.IsFinite(distance))
throw new ArgumentOutOfRangeException(nameof(distance));
if (!double.IsFinite(time))
throw new ArgumentOutOfRangeException(nameof(time));
int index = vertexIndexAtDistance(distance);
float x = positionAtDistance(distance, index);
vertices.Insert(index, new JuiceStreamPathVertex(distance, x));
int index = vertexIndexAtTime(time);
float x = positionAtTime(time, index);
vertices.Insert(index, new JuiceStreamPathVertex(time, x));
invalidate();
return index;
@ -101,7 +96,6 @@ namespace osu.Game.Rulesets.Catch.Objects
/// <summary>
/// Move the vertex of given <paramref name="index"/> to the given position <paramref name="newX"/>.
/// When the distances between vertices are too small for the new vertex positions, the adjacent vertices are moved towards <paramref name="newX"/>.
/// </summary>
public void SetVertexPosition(int index, float newX)
{
@ -111,32 +105,17 @@ namespace osu.Game.Rulesets.Catch.Objects
if (!float.IsFinite(newX))
throw new ArgumentOutOfRangeException(nameof(newX));
var newVertex = new JuiceStreamPathVertex(vertices[index].Distance, newX);
for (int i = index - 1; i >= 0 && !canConnect(vertices[i], newVertex); i--)
{
float clampedX = clampToConnectablePosition(newVertex, vertices[i]);
vertices[i] = new JuiceStreamPathVertex(vertices[i].Distance, clampedX);
}
for (int i = index + 1; i < vertices.Count; i++)
{
float clampedX = clampToConnectablePosition(newVertex, vertices[i]);
vertices[i] = new JuiceStreamPathVertex(vertices[i].Distance, clampedX);
}
vertices[index] = newVertex;
vertices[index] = new JuiceStreamPathVertex(vertices[index].Time, newX);
invalidate();
}
/// <summary>
/// Add a new vertex at given <paramref name="distance"/> and position.
/// Adjacent vertices are moved when necessary in the same way as <see cref="SetVertexPosition"/>.
/// Add a new vertex at given <paramref name="time"/> and position.
/// </summary>
public void Add(double distance, float x)
public void Add(double time, float x)
{
int index = InsertVertex(distance);
int index = InsertVertex(time);
SetVertexPosition(index, x);
}
@ -163,22 +142,22 @@ namespace osu.Game.Rulesets.Catch.Objects
}
/// <summary>
/// Recreate this path by using difference set of vertices at given distances.
/// In addition to the given <paramref name="sampleDistances"/>, the first vertex and the last vertex are always added to the new path.
/// New vertices use the positions on the original path. Thus, <see cref="PositionAtDistance"/>s at <paramref name="sampleDistances"/> are preserved.
/// Recreate this path by using difference set of vertices at given time points.
/// In addition to the given <paramref name="sampleTimes"/>, the first vertex and the last vertex are always added to the new path.
/// New vertices use the positions on the original path. Thus, <see cref="PositionAtTime"/>s at <paramref name="sampleTimes"/> are preserved.
/// </summary>
public void ResampleVertices(IEnumerable<double> sampleDistances)
public void ResampleVertices(IEnumerable<double> sampleTimes)
{
var sampledVertices = new List<JuiceStreamPathVertex>();
foreach (double distance in sampleDistances)
foreach (double time in sampleTimes)
{
if (!double.IsFinite(distance))
throw new ArgumentOutOfRangeException(nameof(sampleDistances));
if (!double.IsFinite(time))
throw new ArgumentOutOfRangeException(nameof(sampleTimes));
double clampedDistance = Math.Clamp(distance, vertices[0].Distance, vertices[^1].Distance);
float x = PositionAtDistance(clampedDistance);
sampledVertices.Add(new JuiceStreamPathVertex(clampedDistance, x));
double clampedTime = Math.Clamp(time, vertices[0].Time, vertices[^1].Time);
float x = PositionAtTime(clampedTime);
sampledVertices.Add(new JuiceStreamPathVertex(clampedTime, x));
}
sampledVertices.Sort();
@ -196,37 +175,62 @@ namespace osu.Game.Rulesets.Catch.Objects
/// <remarks>
/// Duplicated vertices are automatically removed.
/// </remarks>
public void ConvertFromSliderPath(SliderPath sliderPath)
public void ConvertFromSliderPath(SliderPath sliderPath, double velocity)
{
var sliderPathVertices = new List<Vector2>();
sliderPath.GetPathToProgress(sliderPathVertices, 0, 1);
double distance = 0;
double time = 0;
vertices.Clear();
vertices.Add(new JuiceStreamPathVertex(0, sliderPathVertices.FirstOrDefault().X));
for (int i = 1; i < sliderPathVertices.Count; i++)
{
distance += Vector2.Distance(sliderPathVertices[i - 1], sliderPathVertices[i]);
time += Vector2.Distance(sliderPathVertices[i - 1], sliderPathVertices[i]) / velocity;
if (!Precision.AlmostEquals(vertices[^1].Distance, distance))
vertices.Add(new JuiceStreamPathVertex(distance, sliderPathVertices[i].X));
if (!Precision.AlmostEquals(vertices[^1].Time, time))
Add(time, sliderPathVertices[i].X);
}
invalidate();
}
/// <summary>
/// Computes the minimum slider velocity required to convert this path to a <see cref="SliderPath"/>.
/// </summary>
public double ComputeRequiredVelocity()
{
double maximumSlope = 0;
for (int i = 1; i < vertices.Count; i++)
{
double xDifference = Math.Abs((double)vertices[i].X - vertices[i - 1].X);
double timeDifference = vertices[i].Time - vertices[i - 1].Time;
// A short segment won't affect the resulting path much anyways so ignore it to avoid divide-by-zero.
if (Precision.AlmostEquals(timeDifference, 0))
continue;
maximumSlope = Math.Max(maximumSlope, xDifference / timeDifference);
}
return maximumSlope;
}
/// <summary>
/// Convert the path of this <see cref="JuiceStreamPath"/> to a <see cref="SliderPath"/> and write the result to <paramref name="sliderPath"/>.
/// The resulting slider is "folded" to make it vertically contained in the playfield `(0..<see cref="OSU_PLAYFIELD_HEIGHT"/>)` assuming the slider start position is <paramref name="sliderStartY"/>.
///
/// The velocity of the converted slider is assumed to be <paramref name="velocity"/>.
/// To preserve the path, <paramref name="velocity"/> should be at least the value returned by <see cref="ComputeRequiredVelocity"/>.
/// </summary>
public void ConvertToSliderPath(SliderPath sliderPath, float sliderStartY)
public void ConvertToSliderPath(SliderPath sliderPath, float sliderStartY, double velocity)
{
const float margin = 1;
// Note: these two variables and `sliderPath` are modified by the local functions.
double currentDistance = 0;
double currentTime = 0;
Vector2 lastPosition = new Vector2(vertices[0].X, 0);
sliderPath.ControlPoints.Clear();
@ -237,10 +241,10 @@ namespace osu.Game.Rulesets.Catch.Objects
sliderPath.ControlPoints[^1].Type = PathType.Linear;
float deltaX = vertices[i].X - lastPosition.X;
double length = vertices[i].Distance - currentDistance;
double length = (vertices[i].Time - currentTime) * velocity;
// Should satisfy `deltaX^2 + deltaY^2 = length^2`.
// By invariants, the expression inside the `sqrt` is (almost) non-negative.
// The expression inside the `sqrt` is (almost) non-negative if the slider velocity is large enough.
double deltaY = Math.Sqrt(Math.Max(0, length * length - (double)deltaX * deltaX));
// When `deltaY` is small, one segment is always enough.
@ -280,59 +284,38 @@ namespace osu.Game.Rulesets.Catch.Objects
{
Vector2 nextPosition = new Vector2(nextX, nextY);
sliderPath.ControlPoints.Add(new PathControlPoint(nextPosition));
currentDistance += Vector2.Distance(lastPosition, nextPosition);
currentTime += Vector2.Distance(lastPosition, nextPosition) / velocity;
lastPosition = nextPosition;
}
}
/// <summary>
/// Find the index at which a new vertex with <paramref name="distance"/> can be inserted.
/// Find the index at which a new vertex with <paramref name="time"/> can be inserted.
/// </summary>
private int vertexIndexAtDistance(double distance)
private int vertexIndexAtTime(double time)
{
// The position of `(distance, Infinity)` is uniquely determined because infinite positions are not allowed.
int i = vertices.BinarySearch(new JuiceStreamPathVertex(distance, float.PositiveInfinity));
// The position of `(time, Infinity)` is uniquely determined because infinite positions are not allowed.
int i = vertices.BinarySearch(new JuiceStreamPathVertex(time, float.PositiveInfinity));
return i < 0 ? ~i : i;
}
/// <summary>
/// Compute the position at the given <paramref name="distance"/>, assuming <paramref name="index"/> is the vertex index returned by <see cref="vertexIndexAtDistance"/>.
/// Compute the position at the given <paramref name="time"/>, assuming <paramref name="index"/> is the vertex index returned by <see cref="vertexIndexAtTime"/>.
/// </summary>
private float positionAtDistance(double distance, int index)
private float positionAtTime(double time, int index)
{
if (index <= 0)
return vertices[0].X;
if (index >= vertices.Count)
return vertices[^1].X;
double length = vertices[index].Distance - vertices[index - 1].Distance;
if (Precision.AlmostEquals(length, 0))
double duration = vertices[index].Time - vertices[index - 1].Time;
if (Precision.AlmostEquals(duration, 0))
return vertices[index].X;
float deltaX = vertices[index].X - vertices[index - 1].X;
return (float)(vertices[index - 1].X + deltaX * ((distance - vertices[index - 1].Distance) / length));
}
/// <summary>
/// Check the two vertices can connected directly while satisfying the slope condition.
/// </summary>
private bool canConnect(JuiceStreamPathVertex vertex1, JuiceStreamPathVertex vertex2, float allowance = 0)
{
double xDistance = Math.Abs((double)vertex2.X - vertex1.X);
float length = (float)Math.Abs(vertex2.Distance - vertex1.Distance);
return xDistance <= length + allowance;
}
/// <summary>
/// Move the position of <paramref name="movableVertex"/> towards the position of <paramref name="fixedVertex"/>
/// until the vertex pair satisfies the condition <see cref="canConnect"/>.
/// </summary>
/// <returns>The resulting position of <paramref name="movableVertex"/>.</returns>
private float clampToConnectablePosition(JuiceStreamPathVertex fixedVertex, JuiceStreamPathVertex movableVertex)
{
float length = (float)Math.Abs(movableVertex.Distance - fixedVertex.Distance);
return Math.Clamp(movableVertex.X, fixedVertex.X - length, fixedVertex.X + length);
return (float)(vertices[index - 1].X + deltaX * ((time - vertices[index - 1].Time) / duration));
}
private void invalidate() => InvalidationID++;

View File

@ -12,22 +12,22 @@ namespace osu.Game.Rulesets.Catch.Objects
/// </summary>
public readonly struct JuiceStreamPathVertex : IComparable<JuiceStreamPathVertex>
{
public readonly double Distance;
public readonly double Time;
public readonly float X;
public JuiceStreamPathVertex(double distance, float x)
public JuiceStreamPathVertex(double time, float x)
{
Distance = distance;
Time = time;
X = x;
}
public int CompareTo(JuiceStreamPathVertex other)
{
int c = Distance.CompareTo(other.Distance);
int c = Time.CompareTo(other.Time);
return c != 0 ? c : X.CompareTo(other.X);
}
public override string ToString() => $"({Distance}, {X})";
public override string ToString() => $"({Time}, {X})";
}
}