1
0
mirror of https://github.com/ppy/osu.git synced 2024-09-21 22:07:25 +08:00

Merge branch 'master' into markdown-heading-font-size

This commit is contained in:
Dan Balasescu 2021-06-22 20:21:25 +09:00 committed by GitHub
commit 883b3e56f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1020 additions and 16 deletions

View File

@ -1,7 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
@ -34,7 +33,7 @@ namespace osu.Game.Rulesets.Catch.UI
// only check the X position; handle all vertical space.
base.ReceivePositionalInputAt(new Vector2(screenSpacePos.X, ScreenSpaceDrawQuad.Centre.Y));
public CatchPlayfield(BeatmapDifficulty difficulty, Func<CatchHitObject, DrawableHitObject<CatchHitObject>> createDrawableRepresentation)
public CatchPlayfield(BeatmapDifficulty difficulty)
{
var droppedObjectContainer = new Container<CaughtObject>
{

View File

@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Catch.UI
protected override ReplayRecorder CreateReplayRecorder(Score score) => new CatchReplayRecorder(score, (CatchPlayfield)Playfield);
protected override Playfield CreatePlayfield() => new CatchPlayfield(Beatmap.BeatmapInfo.BaseDifficulty, CreateDrawableRepresentation);
protected override Playfield CreatePlayfield() => new CatchPlayfield(Beatmap.BeatmapInfo.BaseDifficulty);
public override PlayfieldAdjustmentContainer CreatePlayfieldAdjustmentContainer() => new CatchPlayfieldAdjustmentContainer();

View File

@ -0,0 +1,260 @@
// 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 Moq;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Edit.Checks;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Tests.Beatmaps;
using osuTK;
namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks
{
[TestFixture]
public class CheckLowDiffOverlapsTest
{
private CheckLowDiffOverlaps check;
[SetUp]
public void Setup()
{
check = new CheckLowDiffOverlaps();
}
[Test]
public void TestNoOverlapFarApart()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(200, 0) }
}
});
}
[Test]
public void TestNoOverlapClose()
{
assertShouldProbablyOverlap(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 167, Position = new Vector2(200, 0) }
}
});
}
[Test]
public void TestNoOverlapTooClose()
{
assertShouldOverlap(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 100, Position = new Vector2(200, 0) }
}
});
}
[Test]
public void TestNoOverlapTooCloseExpert()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 100, Position = new Vector2(200, 0) }
}
}, DifficultyRating.Expert);
}
[Test]
public void TestOverlapClose()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 167, Position = new Vector2(20, 0) }
}
});
}
[Test]
public void TestOverlapFarApart()
{
assertShouldNotOverlap(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(20, 0) }
}
});
}
[Test]
public void TestAlmostOverlapFarApart()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
// Default circle diameter is 128 px, but part of that is the fade/border of the circle.
// We want this to only be a problem when it actually looks like an overlap.
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(125, 0) }
}
});
}
[Test]
public void TestAlmostNotOverlapFarApart()
{
assertShouldNotOverlap(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(110, 0) }
}
});
}
[Test]
public void TestOverlapFarApartExpert()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(20, 0) }
}
}, DifficultyRating.Expert);
}
[Test]
public void TestOverlapTooFarApart()
{
// Far apart enough to where the objects are not visible at the same time, and so overlapping is fine.
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 2000, Position = new Vector2(20, 0) }
}
});
}
[Test]
public void TestSliderTailOverlapFarApart()
{
assertShouldNotOverlap(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
getSliderMock(startTime: 0, endTime: 500, startPosition: new Vector2(0, 0), endPosition: new Vector2(100, 0)).Object,
new HitCircle { StartTime = 1000, Position = new Vector2(120, 0) }
}
});
}
[Test]
public void TestSliderTailOverlapClose()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
getSliderMock(startTime: 0, endTime: 900, startPosition: new Vector2(0, 0), endPosition: new Vector2(100, 0)).Object,
new HitCircle { StartTime = 1000, Position = new Vector2(120, 0) }
}
});
}
[Test]
public void TestSliderTailNoOverlapFarApart()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
getSliderMock(startTime: 0, endTime: 500, startPosition: new Vector2(0, 0), endPosition: new Vector2(100, 0)).Object,
new HitCircle { StartTime = 1000, Position = new Vector2(300, 0) }
}
});
}
[Test]
public void TestSliderTailNoOverlapClose()
{
// If these were circles they would need to overlap, but overlapping with slider tails is not required.
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
getSliderMock(startTime: 0, endTime: 900, startPosition: new Vector2(0, 0), endPosition: new Vector2(100, 0)).Object,
new HitCircle { StartTime = 1000, Position = new Vector2(300, 0) }
}
});
}
private Mock<Slider> getSliderMock(double startTime, double endTime, Vector2 startPosition, Vector2 endPosition)
{
var mockSlider = new Mock<Slider>();
mockSlider.SetupGet(s => s.StartTime).Returns(startTime);
mockSlider.SetupGet(s => s.Position).Returns(startPosition);
mockSlider.SetupGet(s => s.EndPosition).Returns(endPosition);
mockSlider.As<IHasDuration>().Setup(d => d.EndTime).Returns(endTime);
return mockSlider;
}
private void assertOk(IBeatmap beatmap, DifficultyRating difficultyRating = DifficultyRating.Easy)
{
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), difficultyRating);
Assert.That(check.Run(context), Is.Empty);
}
private void assertShouldProbablyOverlap(IBeatmap beatmap, int count = 1)
{
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy);
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(count));
Assert.That(issues.All(issue => issue.Template is CheckLowDiffOverlaps.IssueTemplateShouldProbablyOverlap));
}
private void assertShouldOverlap(IBeatmap beatmap, int count = 1)
{
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy);
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(count));
Assert.That(issues.All(issue => issue.Template is CheckLowDiffOverlaps.IssueTemplateShouldOverlap));
}
private void assertShouldNotOverlap(IBeatmap beatmap, int count = 1)
{
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy);
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(count));
Assert.That(issues.All(issue => issue.Template is CheckLowDiffOverlaps.IssueTemplateShouldNotOverlap));
}
}
}

View File

@ -0,0 +1,324 @@
// 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 Moq;
using NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Edit.Checks;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Tests.Beatmaps;
using osuTK;
namespace osu.Game.Rulesets.Osu.Tests.Editor.Checks
{
[TestFixture]
public class CheckTimeDistanceEqualityTest
{
private CheckTimeDistanceEquality check;
[SetUp]
public void Setup()
{
check = new CheckTimeDistanceEquality();
}
[Test]
public void TestCirclesEquidistant()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new HitCircle { StartTime = 1000, Position = new Vector2(100, 0) },
new HitCircle { StartTime = 1500, Position = new Vector2(150, 0) }
}
});
}
[Test]
public void TestCirclesOneSlightlyOff()
{
assertWarning(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new HitCircle { StartTime = 1000, Position = new Vector2(80, 0) }, // Distance a quite low compared to previous.
new HitCircle { StartTime = 1500, Position = new Vector2(130, 0) }
}
});
}
[Test]
public void TestCirclesOneOff()
{
assertProblem(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new HitCircle { StartTime = 1000, Position = new Vector2(150, 0) }, // Twice the regular spacing.
new HitCircle { StartTime = 1500, Position = new Vector2(100, 0) }
}
});
}
[Test]
public void TestCirclesTwoOff()
{
assertProblem(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new HitCircle { StartTime = 1000, Position = new Vector2(150, 0) }, // Twice the regular spacing.
new HitCircle { StartTime = 1500, Position = new Vector2(250, 0) } // Also twice the regular spacing.
}
}, count: 2);
}
[Test]
public void TestCirclesStacked()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new HitCircle { StartTime = 1000, Position = new Vector2(50, 0) }, // Stacked, is fine.
new HitCircle { StartTime = 1500, Position = new Vector2(100, 0) }
}
});
}
[Test]
public void TestCirclesStacking()
{
assertWarning(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new HitCircle { StartTime = 1000, Position = new Vector2(50, 0), StackHeight = 1 },
new HitCircle { StartTime = 1500, Position = new Vector2(50, 0), StackHeight = 2 },
new HitCircle { StartTime = 2000, Position = new Vector2(50, 0), StackHeight = 3 },
new HitCircle { StartTime = 2500, Position = new Vector2(50, 0), StackHeight = 4 }, // Ends up far from (50; 0), causing irregular spacing.
new HitCircle { StartTime = 3000, Position = new Vector2(100, 0) }
}
});
}
[Test]
public void TestCirclesHalfStack()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new HitCircle { StartTime = 1000, Position = new Vector2(55, 0) }, // Basically stacked, so is fine.
new HitCircle { StartTime = 1500, Position = new Vector2(105, 0) }
}
});
}
[Test]
public void TestCirclesPartialOverlap()
{
assertProblem(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new HitCircle { StartTime = 1000, Position = new Vector2(65, 0) }, // Really low distance compared to previous.
new HitCircle { StartTime = 1500, Position = new Vector2(115, 0) }
}
});
}
[Test]
public void TestCirclesSlightlyDifferent()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
// Does not need to be perfect, as long as the distance is approximately correct it's sight-readable.
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(52, 0) },
new HitCircle { StartTime = 1000, Position = new Vector2(97, 0) },
new HitCircle { StartTime = 1500, Position = new Vector2(165, 0) }
}
});
}
[Test]
public void TestCirclesSlowlyChanging()
{
const float multiplier = 1.2f;
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new HitCircle { StartTime = 1000, Position = new Vector2(50 + 50 * multiplier, 0) },
// This gap would be a warning if it weren't for the previous pushing the average spacing up.
new HitCircle { StartTime = 1500, Position = new Vector2(50 + 50 * multiplier + 50 * multiplier * multiplier, 0) }
}
});
}
[Test]
public void TestCirclesQuicklyChanging()
{
const float multiplier = 1.6f;
var beatmap = new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new HitCircle { StartTime = 1000, Position = new Vector2(50 + 50 * multiplier, 0) }, // Warning
new HitCircle { StartTime = 1500, Position = new Vector2(50 + 50 * multiplier + 50 * multiplier * multiplier, 0) } // Problem
}
};
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy);
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(2));
Assert.That(issues.First().Template is CheckTimeDistanceEquality.IssueTemplateIrregularSpacingWarning);
Assert.That(issues.Last().Template is CheckTimeDistanceEquality.IssueTemplateIrregularSpacingProblem);
}
[Test]
public void TestCirclesTooFarApart()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new HitCircle { StartTime = 4000, Position = new Vector2(200, 0) }, // 2 seconds apart from previous, so can start from wherever.
new HitCircle { StartTime = 4500, Position = new Vector2(250, 0) }
}
});
}
[Test]
public void TestCirclesOneOffExpert()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new HitCircle { StartTime = 1000, Position = new Vector2(150, 0) }, // Jumps are allowed in higher difficulties.
new HitCircle { StartTime = 1500, Position = new Vector2(100, 0) }
}
}, DifficultyRating.Expert);
}
[Test]
public void TestSpinner()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
new Spinner { StartTime = 500, EndTime = 1000 }, // Distance to and from the spinner should be ignored. If it isn't this should give a problem.
new HitCircle { StartTime = 1500, Position = new Vector2(100, 0) },
new HitCircle { StartTime = 2000, Position = new Vector2(150, 0) }
}
});
}
[Test]
public void TestSliders()
{
assertOk(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
getSliderMock(startTime: 1000, endTime: 1500, startPosition: new Vector2(100, 0), endPosition: new Vector2(150, 0)).Object,
getSliderMock(startTime: 2000, endTime: 2500, startPosition: new Vector2(200, 0), endPosition: new Vector2(250, 0)).Object,
new HitCircle { StartTime = 2500, Position = new Vector2(300, 0) }
}
});
}
[Test]
public void TestSlidersOneOff()
{
assertProblem(new Beatmap<HitObject>
{
HitObjects = new List<HitObject>
{
new HitCircle { StartTime = 0, Position = new Vector2(0) },
new HitCircle { StartTime = 500, Position = new Vector2(50, 0) },
getSliderMock(startTime: 1000, endTime: 1500, startPosition: new Vector2(100, 0), endPosition: new Vector2(150, 0)).Object,
getSliderMock(startTime: 2000, endTime: 2500, startPosition: new Vector2(250, 0), endPosition: new Vector2(300, 0)).Object, // Twice the spacing.
new HitCircle { StartTime = 2500, Position = new Vector2(300, 0) }
}
});
}
private Mock<Slider> getSliderMock(double startTime, double endTime, Vector2 startPosition, Vector2 endPosition)
{
var mockSlider = new Mock<Slider>();
mockSlider.SetupGet(s => s.StartTime).Returns(startTime);
mockSlider.SetupGet(s => s.Position).Returns(startPosition);
mockSlider.SetupGet(s => s.EndPosition).Returns(endPosition);
mockSlider.As<IHasDuration>().Setup(d => d.EndTime).Returns(endTime);
return mockSlider;
}
private void assertOk(IBeatmap beatmap, DifficultyRating difficultyRating = DifficultyRating.Easy)
{
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), difficultyRating);
Assert.That(check.Run(context), Is.Empty);
}
private void assertWarning(IBeatmap beatmap, int count = 1)
{
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy);
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(count));
Assert.That(issues.All(issue => issue.Template is CheckTimeDistanceEquality.IssueTemplateIrregularSpacingWarning));
}
private void assertProblem(IBeatmap beatmap, int count = 1)
{
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), DifficultyRating.Easy);
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(count));
Assert.That(issues.All(issue => issue.Template is CheckTimeDistanceEquality.IssueTemplateIrregularSpacingProblem));
}
}
}

View File

@ -3,6 +3,7 @@
<ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.10.0" />
<PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />

View File

@ -0,0 +1,109 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Checks.Components;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Edit.Checks
{
public class CheckLowDiffOverlaps : ICheck
{
// For the lowest difficulties, the osu! Ranking Criteria encourages overlapping ~180 BPM 1/2, but discourages ~180 BPM 1/1.
private const double should_overlap_threshold = 150; // 200 BPM 1/2
private const double should_probably_overlap_threshold = 175; // 170 BPM 1/2
private const double should_not_overlap_threshold = 250; // 120 BPM 1/2 = 240 BPM 1/1
/// <summary>
/// Objects need to overlap this much before being treated as an overlap, else it may just be the borders slightly touching.
/// </summary>
private const double overlap_leniency = 5;
public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Spread, "Missing or unexpected overlaps");
public IEnumerable<IssueTemplate> PossibleTemplates => new IssueTemplate[]
{
new IssueTemplateShouldOverlap(this),
new IssueTemplateShouldProbablyOverlap(this),
new IssueTemplateShouldNotOverlap(this)
};
public IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
// TODO: This should also apply to *lowest difficulty* Normals - they are skipped for now.
if (context.InterpretedDifficulty > DifficultyRating.Easy)
yield break;
var hitObjects = context.Beatmap.HitObjects;
for (int i = 0; i < hitObjects.Count - 1; ++i)
{
if (!(hitObjects[i] is OsuHitObject hitObject) || hitObject is Spinner)
continue;
if (!(hitObjects[i + 1] is OsuHitObject nextHitObject) || nextHitObject is Spinner)
continue;
var deltaTime = nextHitObject.StartTime - hitObject.GetEndTime();
if (deltaTime >= hitObject.TimeFadeIn + hitObject.TimePreempt)
// The objects are not visible at the same time (without mods), hence skipping.
continue;
var distanceSq = (hitObject.StackedEndPosition - nextHitObject.StackedPosition).LengthSquared;
var diameter = (hitObject.Radius - overlap_leniency) * 2;
var diameterSq = diameter * diameter;
bool areOverlapping = distanceSq < diameterSq;
// Slider ends do not need to be overlapped because of slider leniency.
if (!areOverlapping && !(hitObject is Slider))
{
if (deltaTime < should_overlap_threshold)
yield return new IssueTemplateShouldOverlap(this).Create(deltaTime, hitObject, nextHitObject);
else if (deltaTime < should_probably_overlap_threshold)
yield return new IssueTemplateShouldProbablyOverlap(this).Create(deltaTime, hitObject, nextHitObject);
}
if (areOverlapping && deltaTime > should_not_overlap_threshold)
yield return new IssueTemplateShouldNotOverlap(this).Create(deltaTime, hitObject, nextHitObject);
}
}
public abstract class IssueTemplateOverlap : IssueTemplate
{
protected IssueTemplateOverlap(ICheck check, IssueType issueType, string unformattedMessage)
: base(check, issueType, unformattedMessage)
{
}
public Issue Create(double deltaTime, params HitObject[] hitObjects) => new Issue(hitObjects, this, deltaTime);
}
public class IssueTemplateShouldOverlap : IssueTemplateOverlap
{
public IssueTemplateShouldOverlap(ICheck check)
: base(check, IssueType.Problem, "These are {0} ms apart and so should be overlapping.")
{
}
}
public class IssueTemplateShouldProbablyOverlap : IssueTemplateOverlap
{
public IssueTemplateShouldProbablyOverlap(ICheck check)
: base(check, IssueType.Warning, "These are {0} ms apart and so should probably be overlapping.")
{
}
}
public class IssueTemplateShouldNotOverlap : IssueTemplateOverlap
{
public IssueTemplateShouldNotOverlap(ICheck check)
: base(check, IssueType.Problem, "These are {0} ms apart and so should NOT be overlapping.")
{
}
}
}
}

View File

@ -0,0 +1,179 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Checks.Components;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Edit.Checks
{
public class CheckTimeDistanceEquality : ICheck
{
/// <summary>
/// Two objects this many ms apart or more are skipped. (200 BPM 2/1)
/// </summary>
private const double pattern_lifetime = 600;
/// <summary>
/// Two objects this distance apart or less are skipped.
/// </summary>
private const double stack_leniency = 12;
/// <summary>
/// How long an observation is relevant for comparison. (120 BPM 8/1)
/// </summary>
private const double observation_lifetime = 4000;
/// <summary>
/// How different two delta times can be to still be compared. (240 BPM 1/16)
/// </summary>
private const double similar_time_leniency = 16;
/// <summary>
/// How many pixels are subtracted from the difference between current and expected distance.
/// </summary>
private const double distance_leniency_absolute_warning = 10;
/// <summary>
/// How much of the current distance that the difference can make out.
/// </summary>
private const double distance_leniency_percent_warning = 0.15;
private const double distance_leniency_absolute_problem = 20;
private const double distance_leniency_percent_problem = 0.3;
public CheckMetadata Metadata { get; } = new CheckMetadata(CheckCategory.Spread, "Object too close or far away from previous");
public IEnumerable<IssueTemplate> PossibleTemplates => new IssueTemplate[]
{
new IssueTemplateIrregularSpacingProblem(this),
new IssueTemplateIrregularSpacingWarning(this)
};
/// <summary>
/// Represents an observation of the time and distance between two objects.
/// </summary>
private readonly struct ObservedTimeDistance
{
public readonly double ObservationTime;
public readonly double DeltaTime;
public readonly double Distance;
public ObservedTimeDistance(double observationTime, double deltaTime, double distance)
{
ObservationTime = observationTime;
DeltaTime = deltaTime;
Distance = distance;
}
}
public IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
if (context.InterpretedDifficulty > DifficultyRating.Normal)
yield break;
var prevObservedTimeDistances = new List<ObservedTimeDistance>();
var hitObjects = context.Beatmap.HitObjects;
for (int i = 0; i < hitObjects.Count - 1; ++i)
{
if (!(hitObjects[i] is OsuHitObject hitObject) || hitObject is Spinner)
continue;
if (!(hitObjects[i + 1] is OsuHitObject nextHitObject) || nextHitObject is Spinner)
continue;
var deltaTime = nextHitObject.StartTime - hitObject.GetEndTime();
// Ignore objects that are far enough apart in time to not be considered the same pattern.
if (deltaTime > pattern_lifetime)
continue;
// Relying on FastInvSqrt is probably good enough here. We'll be taking the difference between distances later, hence square not being sufficient.
var distance = (hitObject.StackedEndPosition - nextHitObject.StackedPosition).LengthFast;
// Ignore stacks and half-stacks, as these are close enough to where they can't be confused for being time-distanced.
if (distance < stack_leniency)
continue;
var observedTimeDistance = new ObservedTimeDistance(nextHitObject.StartTime, deltaTime, distance);
var expectedDistance = getExpectedDistance(prevObservedTimeDistances, observedTimeDistance);
if (expectedDistance == 0)
{
// There was nothing relevant to compare to.
prevObservedTimeDistances.Add(observedTimeDistance);
continue;
}
if ((Math.Abs(expectedDistance - distance) - distance_leniency_absolute_problem) / distance > distance_leniency_percent_problem)
yield return new IssueTemplateIrregularSpacingProblem(this).Create(expectedDistance, distance, hitObject, nextHitObject);
else if ((Math.Abs(expectedDistance - distance) - distance_leniency_absolute_warning) / distance > distance_leniency_percent_warning)
yield return new IssueTemplateIrregularSpacingWarning(this).Create(expectedDistance, distance, hitObject, nextHitObject);
else
{
// We use `else` here to prevent issues from cascading; an object spaced too far could cause regular spacing to be considered "too short" otherwise.
prevObservedTimeDistances.Add(observedTimeDistance);
}
}
}
private double getExpectedDistance(IEnumerable<ObservedTimeDistance> prevObservedTimeDistances, ObservedTimeDistance observedTimeDistance)
{
var observations = prevObservedTimeDistances.Count();
int count = 0;
double sum = 0;
// Looping this in reverse allows us to break before going through all elements, as we're only interested in the most recent ones.
for (int i = observations - 1; i >= 0; --i)
{
var prevObservedTimeDistance = prevObservedTimeDistances.ElementAt(i);
// Only consider observations within the last few seconds - this allows the map to build spacing up/down over time, but prevents it from being too sudden.
if (observedTimeDistance.ObservationTime - prevObservedTimeDistance.ObservationTime > observation_lifetime)
break;
// Only consider observations which have a similar time difference - this leniency allows handling of multi-BPM maps which speed up/down slowly.
if (Math.Abs(observedTimeDistance.DeltaTime - prevObservedTimeDistance.DeltaTime) > similar_time_leniency)
break;
count += 1;
sum += prevObservedTimeDistance.Distance / Math.Max(prevObservedTimeDistance.DeltaTime, 1);
}
return sum / Math.Max(count, 1) * observedTimeDistance.DeltaTime;
}
public abstract class IssueTemplateIrregularSpacing : IssueTemplate
{
protected IssueTemplateIrregularSpacing(ICheck check, IssueType issueType)
: base(check, issueType, "Expected {0:0} px spacing like previous objects, currently {1:0}.")
{
}
public Issue Create(double expected, double actual, params HitObject[] hitObjects) => new Issue(hitObjects, this, expected, actual);
}
public class IssueTemplateIrregularSpacingProblem : IssueTemplateIrregularSpacing
{
public IssueTemplateIrregularSpacingProblem(ICheck check)
: base(check, IssueType.Problem)
{
}
}
public class IssueTemplateIrregularSpacingWarning : IssueTemplateIrregularSpacing
{
public IssueTemplateIrregularSpacingWarning(ICheck check)
: base(check, IssueType.Warning)
{
}
}
}
}

View File

@ -13,7 +13,12 @@ namespace osu.Game.Rulesets.Osu.Edit
{
private readonly List<ICheck> checks = new List<ICheck>
{
new CheckOffscreenObjects()
// Compose
new CheckOffscreenObjects(),
// Spread
new CheckTimeDistanceEquality(),
new CheckLowDiffOverlaps()
};
public IEnumerable<Issue> Run(BeatmapVerifierContext context)

View File

@ -7,6 +7,7 @@ using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Bindings;
using osu.Framework.Threading;
using osu.Game.Screens.Play.HUD;
using osu.Game.Skinning;
using osuTK;
namespace osu.Game.Extensions
@ -57,6 +58,9 @@ namespace osu.Game.Extensions
component.Anchor = info.Anchor;
component.Origin = info.Origin;
if (component is ISkinnableDrawable skinnable)
skinnable.UsesFixedAnchor = info.UsesFixedAnchor;
if (component is Container container)
{
foreach (var child in info.Children)

View File

@ -12,6 +12,8 @@ namespace osu.Game.Screens.Play.HUD
[Resolved(canBeNull: true)]
private HUDOverlay hud { get; set; }
public bool UsesFixedAnchor { get; set; }
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{

View File

@ -17,6 +17,8 @@ namespace osu.Game.Screens.Play.HUD
[Resolved(canBeNull: true)]
private HUDOverlay hud { get; set; }
public bool UsesFixedAnchor { get; set; }
public DefaultComboCounter()
{
Current.Value = DisplayedCount = 0;

View File

@ -72,6 +72,8 @@ namespace osu.Game.Screens.Play.HUD
}
}
public bool UsesFixedAnchor { get; set; }
public DefaultHealthDisplay()
{
Size = new Vector2(1, 5);

View File

@ -20,6 +20,8 @@ namespace osu.Game.Screens.Play.HUD
[Resolved(canBeNull: true)]
private HUDOverlay hud { get; set; }
public bool UsesFixedAnchor { get; set; }
[BackgroundDependencyLoader]
private void load(OsuColour colours)
{

View File

@ -22,6 +22,8 @@ namespace osu.Game.Screens.Play.HUD.HitErrorMeters
[Resolved]
private OsuColour colours { get; set; }
public bool UsesFixedAnchor { get; set; }
[BackgroundDependencyLoader(true)]
private void load(DrawableRuleset drawableRuleset)
{

View File

@ -59,6 +59,8 @@ namespace osu.Game.Screens.Play.HUD
set => counterContainer.Alpha = value ? 1 : 0;
}
public bool UsesFixedAnchor { get; set; }
public LegacyComboCounter()
{
AutoSizeAxes = Axes.Both;

View File

@ -32,6 +32,9 @@ namespace osu.Game.Screens.Play.HUD
public Anchor Origin { get; set; }
/// <inheritdoc cref="ISkinnableDrawable.UsesFixedAnchor"/>
public bool UsesFixedAnchor { get; set; }
public List<SkinnableInfo> Children { get; } = new List<SkinnableInfo>();
[JsonConstructor]
@ -53,6 +56,9 @@ namespace osu.Game.Screens.Play.HUD
Anchor = component.Anchor;
Origin = component.Origin;
if (component is ISkinnableDrawable skinnable)
UsesFixedAnchor = skinnable.UsesFixedAnchor;
if (component is Container<Drawable> container)
{
foreach (var child in container.OfType<ISkinnableDrawable>().OfType<Drawable>())

View File

@ -78,6 +78,8 @@ namespace osu.Game.Screens.Play
private IClock referenceClock;
public bool UsesFixedAnchor { get; set; }
public SongProgress()
{
RelativeSizeAxes = Axes.X;

View File

@ -149,13 +149,21 @@ namespace osu.Game.Skinning.Editor
{
foreach (var c in SelectedBlueprints)
{
Drawable drawable = (Drawable)c.Item;
var item = c.Item;
Drawable drawable = (Drawable)item;
drawable.Position += drawable.ScreenSpaceDeltaToParentSpace(moveEvent.ScreenSpaceDelta);
if (item.UsesFixedAnchor) continue;
applyClosestAnchor(drawable);
}
return true;
}
private static void applyClosestAnchor(Drawable drawable) => applyAnchor(drawable, getClosestAnchor(drawable));
protected override void OnSelectionChanged()
{
base.OnSelectionChanged();
@ -171,20 +179,27 @@ namespace osu.Game.Skinning.Editor
protected override IEnumerable<MenuItem> GetContextMenuItemsForSelection(IEnumerable<SelectionBlueprint<ISkinnableDrawable>> selection)
{
var closestItem = new TernaryStateRadioMenuItem("Closest", MenuItemType.Standard, _ => applyClosestAnchors())
{
State = { Value = GetStateFromSelection(selection, c => !c.Item.UsesFixedAnchor) }
};
yield return new OsuMenuItem("Anchor")
{
Items = createAnchorItems(d => d.Anchor, applyAnchor).ToArray()
Items = createAnchorItems((d, a) => d.UsesFixedAnchor && ((Drawable)d).Anchor == a, applyFixedAnchors)
.Prepend(closestItem)
.ToArray()
};
yield return new OsuMenuItem("Origin")
{
Items = createAnchorItems(d => d.Origin, applyOrigin).ToArray()
Items = createAnchorItems((d, o) => ((Drawable)d).Origin == o, applyOrigins).ToArray()
};
foreach (var item in base.GetContextMenuItemsForSelection(selection))
yield return item;
IEnumerable<TernaryStateMenuItem> createAnchorItems(Func<Drawable, Anchor> checkFunction, Action<Anchor> applyFunction)
IEnumerable<TernaryStateMenuItem> createAnchorItems(Func<ISkinnableDrawable, Anchor, bool> checkFunction, Action<Anchor> applyFunction)
{
var displayableAnchors = new[]
{
@ -198,12 +213,11 @@ namespace osu.Game.Skinning.Editor
Anchor.BottomCentre,
Anchor.BottomRight,
};
return displayableAnchors.Select(a =>
{
return new TernaryStateRadioMenuItem(a.ToString(), MenuItemType.Standard, _ => applyFunction(a))
{
State = { Value = GetStateFromSelection(selection, c => checkFunction((Drawable)c.Item) == a) }
State = { Value = GetStateFromSelection(selection, c => checkFunction(c.Item, a)) }
};
});
}
@ -215,15 +229,21 @@ namespace osu.Game.Skinning.Editor
drawable.Parent.ToLocalSpace(screenSpacePosition) - drawable.AnchorPosition;
}
private void applyOrigin(Anchor anchor)
private void applyOrigins(Anchor origin)
{
foreach (var item in SelectedItems)
{
var drawable = (Drawable)item;
if (origin == drawable.Origin) continue;
var previousOrigin = drawable.OriginPosition;
drawable.Origin = anchor;
drawable.Origin = origin;
drawable.Position += drawable.OriginPosition - previousOrigin;
if (item.UsesFixedAnchor) continue;
applyClosestAnchor(drawable);
}
}
@ -234,18 +254,86 @@ namespace osu.Game.Skinning.Editor
private Quad getSelectionQuad() =>
GetSurroundingQuad(SelectedBlueprints.SelectMany(b => b.Item.ScreenSpaceDrawQuad.GetVertices().ToArray()));
private void applyAnchor(Anchor anchor)
private void applyFixedAnchors(Anchor anchor)
{
foreach (var item in SelectedItems)
{
var drawable = (Drawable)item;
var previousAnchor = drawable.AnchorPosition;
drawable.Anchor = anchor;
drawable.Position -= drawable.AnchorPosition - previousAnchor;
item.UsesFixedAnchor = true;
applyAnchor(drawable, anchor);
}
}
private void applyClosestAnchors()
{
foreach (var item in SelectedItems)
{
item.UsesFixedAnchor = false;
applyClosestAnchor((Drawable)item);
}
}
private static Anchor getClosestAnchor(Drawable drawable)
{
var parent = drawable.Parent;
if (parent == null)
return drawable.Anchor;
var screenPosition = getScreenPosition();
var absolutePosition = parent.ToLocalSpace(screenPosition);
var factor = parent.RelativeToAbsoluteFactor;
var result = default(Anchor);
static Anchor getAnchorFromPosition(float xOrY, Anchor anchor0, Anchor anchor1, Anchor anchor2)
{
if (xOrY >= 2 / 3f)
return anchor2;
if (xOrY >= 1 / 3f)
return anchor1;
return anchor0;
}
result |= getAnchorFromPosition(absolutePosition.X / factor.X, Anchor.x0, Anchor.x1, Anchor.x2);
result |= getAnchorFromPosition(absolutePosition.Y / factor.Y, Anchor.y0, Anchor.y1, Anchor.y2);
return result;
Vector2 getScreenPosition()
{
var quad = drawable.ScreenSpaceDrawQuad;
var origin = drawable.Origin;
var pos = quad.TopLeft;
if (origin.HasFlagFast(Anchor.x2))
pos.X += quad.Width;
else if (origin.HasFlagFast(Anchor.x1))
pos.X += quad.Width / 2f;
if (origin.HasFlagFast(Anchor.y2))
pos.Y += quad.Height;
else if (origin.HasFlagFast(Anchor.y1))
pos.Y += quad.Height / 2f;
return pos;
}
}
private static void applyAnchor(Drawable drawable, Anchor anchor)
{
if (anchor == drawable.Anchor) return;
var previousAnchor = drawable.AnchorPosition;
drawable.Anchor = anchor;
drawable.Position -= drawable.AnchorPosition - previousAnchor;
}
private static void adjustScaleFromAnchor(ref Vector2 scale, Anchor reference)
{
// cancel out scale in axes we don't care about (based on which drag handle was used).

View File

@ -14,5 +14,12 @@ namespace osu.Game.Skinning
/// Whether this component should be editable by an end user.
/// </summary>
bool IsEditable => true;
/// <summary>
/// In the context of the skin layout editor, whether this <see cref="ISkinnableDrawable"/> has a permanent anchor defined.
/// If <see langword="false"/>, this <see cref="ISkinnableDrawable"/>'s <see cref="Drawable.Anchor"/> is automatically determined by proximity,
/// If <see langword="true"/>, a fixed anchor point has been defined.
/// </summary>
bool UsesFixedAnchor { get; set; }
}
}

View File

@ -12,6 +12,8 @@ namespace osu.Game.Skinning
{
public class LegacyAccuracyCounter : GameplayAccuracyCounter, ISkinnableDrawable
{
public bool UsesFixedAnchor { get; set; }
public LegacyAccuracyCounter()
{
Anchor = Anchor.TopRight;

View File

@ -27,6 +27,8 @@ namespace osu.Game.Skinning
private bool isNewStyle;
public bool UsesFixedAnchor { get; set; }
[BackgroundDependencyLoader]
private void load(ISkinSource source)
{

View File

@ -13,6 +13,8 @@ namespace osu.Game.Skinning
protected override double RollingDuration => 1000;
protected override Easing RollingEasing => Easing.Out;
public bool UsesFixedAnchor { get; set; }
public LegacyScoreCounter()
: base(6)
{

View File

@ -17,6 +17,8 @@ namespace osu.Game.Skinning
{
public bool IsEditable => false;
public bool UsesFixedAnchor { get; set; }
private readonly Action<Container> applyDefaults;
/// <summary>