// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. #nullable disable using System; using System.Collections.Generic; using System.Linq; using System.Threading; using NUnit.Framework; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input; using osu.Framework.Utils; using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Configuration; using osu.Game.Rulesets; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI.Scrolling; using osuTK; using osuTK.Graphics; using JetBrains.Annotations; namespace osu.Game.Tests.Visual.Gameplay { public partial class TestSceneDrawableScrollingRuleset : OsuTestScene { /// /// The amount of time visible by the "view window" of the playfield. /// All hitobjects added through are spaced apart by this value, such that for a beat length of 1000, /// there will be at most 2 hitobjects visible in the "view window". /// private const double time_range = 1000; private readonly ManualClock testClock = new ManualClock(); private TestDrawableScrollingRuleset drawableRuleset; [SetUp] public void Setup() => Schedule(() => testClock.CurrentTime = 0); [TestCase("pooled")] [TestCase("non-pooled")] public void TestHitObjectLifetime(string pooled) { var beatmap = createBeatmap(_ => pooled == "pooled" ? new TestPooledHitObject() : new TestHitObject()); beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range }); createTest(beatmap); assertPosition(0, 0f); assertDead(3); setTime(3 * time_range); assertPosition(3, 0f); assertDead(0); setTime(0 * time_range); assertPosition(0, 0f); assertDead(3); } [TestCase("pooled")] [TestCase("non-pooled")] public void TestNestedHitObject(string pooled) { var beatmap = createBeatmap(i => { var h = pooled == "pooled" ? new TestPooledParentHitObject() : new TestParentHitObject(); h.Duration = 300; h.ChildTimeOffset = i % 3 * 100; return h; }); beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range }); createTest(beatmap); assertPosition(0, 0f); assertHeight(0); assertChildPosition(0); setTime(5 * time_range); assertPosition(5, 0f); assertHeight(5); assertChildPosition(5); } [TestCase("pooled")] [TestCase("non-pooled")] public void TestLifetimeRecomputedWhenTimeRangeChanges(string pooled) { var beatmap = createBeatmap(_ => pooled == "pooled" ? new TestPooledHitObject() : new TestHitObject()); beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range }); createTest(beatmap); assertDead(3); AddStep("increase time range", () => drawableRuleset.TimeRange.Value = 3 * time_range); assertPosition(3, 1); } [Test] public void TestRelativeBeatLengthScaleSingleTimingPoint() { var beatmap = createBeatmap(); beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range / 2 }); createTest(beatmap, d => d.RelativeScaleBeatLengthsOverride = true); assertPosition(0, 0f); // The single timing point is 1x speed relative to itself, such that the hitobject occurring time_range milliseconds later should appear // at the bottom of the view window regardless of the timing point's beat length assertPosition(1, 1f); } [Test] public void TestRelativeBeatLengthScaleTimingPointBeyondEndDoesNotBecomeDominant() { var beatmap = createBeatmap(); beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range / 2 }); beatmap.ControlPointInfo.Add(12000, new TimingControlPoint { BeatLength = time_range }); beatmap.ControlPointInfo.Add(100000, new TimingControlPoint { BeatLength = time_range }); createTest(beatmap, d => d.RelativeScaleBeatLengthsOverride = true); assertPosition(0, 0f); assertPosition(1, 1f); } [Test] public void TestRelativeBeatLengthScaleFromSecondTimingPoint() { var beatmap = createBeatmap(); beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range }); beatmap.ControlPointInfo.Add(3 * time_range, new TimingControlPoint { BeatLength = time_range / 2 }); createTest(beatmap, d => d.RelativeScaleBeatLengthsOverride = true); // The first timing point should have a relative velocity of 2 assertPosition(0, 0f); assertPosition(1, 0.5f); assertPosition(2, 1f); // Move to the second timing point setTime(3 * time_range); assertPosition(3, 0f); // As above, this is the timing point that is 1x speed relative to itself, so the hitobject occurring time_range milliseconds later should be at the bottom of the view window assertPosition(4, 1f); } [Test] public void TestNonRelativeScale() { var beatmap = createBeatmap(); beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range }); beatmap.ControlPointInfo.Add(3 * time_range, new TimingControlPoint { BeatLength = time_range / 2 }); createTest(beatmap); assertPosition(0, 0f); assertPosition(1, 1); // Move to the second timing point setTime(3 * time_range); assertPosition(3, 0f); // For a beat length of 500, the view window of this timing point is elongated 2x (1000 / 500), such that the second hitobject is two TimeRanges away (offscreen) // To bring it on-screen, half TimeRange is added to the current time, bringing the second half of the view window into view, and the hitobject should appear at the bottom setTime(3 * time_range + time_range / 2); assertPosition(4, 1f); } [Test] public void TestSliderMultiplierDoesNotAffectRelativeBeatLength() { var beatmap = createBeatmap(); beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range }); beatmap.Difficulty.SliderMultiplier = 2; createTest(beatmap, d => d.RelativeScaleBeatLengthsOverride = true); AddStep("adjust time range", () => drawableRuleset.TimeRange.Value = 5000); for (int i = 0; i < 5; i++) assertPosition(i, i / 5f); } [Test] public void TestSliderMultiplierAffectsNonRelativeBeatLength() { var beatmap = createBeatmap(); beatmap.ControlPointInfo.Add(0, new TimingControlPoint { BeatLength = time_range }); beatmap.BeatmapInfo.Difficulty.SliderMultiplier = 2; createTest(beatmap); AddStep("adjust time range", () => drawableRuleset.TimeRange.Value = 2000); assertPosition(0, 0); assertPosition(1, 1); } /// /// Get a corresponding to the 'th . /// When the hit object is not alive, `null` is returned. /// [CanBeNull] private DrawableTestHitObject getDrawableHitObject(int index) { var hitObject = drawableRuleset.Beatmap.HitObjects.ElementAt(index); return (DrawableTestHitObject)drawableRuleset.Playfield.HitObjectContainer.AliveObjects.FirstOrDefault(obj => obj.HitObject == hitObject); } private float yScale => drawableRuleset.Playfield.HitObjectContainer.DrawHeight; private void assertDead(int index) => AddAssert($"hitobject {index} is dead", () => getDrawableHitObject(index) == null); private void assertHeight(int index) => AddAssert($"hitobject {index} height", () => { var d = getDrawableHitObject(index); return d != null && Precision.AlmostEquals(d.DrawHeight, yScale * (float)(d.HitObject.Duration / time_range), 0.1f); }); private void assertChildPosition(int index) => AddAssert($"hitobject {index} child position", () => { var d = getDrawableHitObject(index); return d is DrawableTestParentHitObject && Precision.AlmostEquals( d.NestedHitObjects.First().DrawPosition.Y, yScale * (float)((TestParentHitObject)d.HitObject).ChildTimeOffset / time_range, 0.1f); }); private void assertPosition(int index, float relativeY) => AddAssert($"hitobject {index} at {relativeY}", () => getDrawableHitObject(index)?.DrawPosition.Y / yScale ?? -1, () => Is.EqualTo(relativeY).Within(Precision.FLOAT_EPSILON)); private void setTime(double time) { AddStep($"set time = {time}", () => testClock.CurrentTime = time); } /// /// Creates an , containing 10 hitobjects and user-provided timing points. /// The hitobjects are spaced milliseconds apart. /// /// The . private IBeatmap createBeatmap(Func createAction = null) { var beatmap = new Beatmap { BeatmapInfo = { Difficulty = new BeatmapDifficulty { SliderMultiplier = 1 }, Ruleset = new OsuRuleset().RulesetInfo } }; for (int i = 0; i < 10; i++) { var h = createAction?.Invoke(i) ?? new TestHitObject(); h.StartTime = i * time_range; beatmap.HitObjects.Add(h); } return beatmap; } private void createTest(IBeatmap beatmap, Action overrideAction = null) { AddStep("create test", () => { var ruleset = new TestScrollingRuleset(); drawableRuleset = (TestDrawableScrollingRuleset)ruleset.CreateDrawableRulesetWith(CreateWorkingBeatmap(beatmap).GetPlayableBeatmap(ruleset.RulesetInfo)); drawableRuleset.FrameStablePlayback = false; overrideAction?.Invoke(drawableRuleset); Child = new Container { Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Y, Height = 0.75f, Width = 400, Masking = true, Clock = new FramedClock(testClock), Child = drawableRuleset }; }); } #region Ruleset private class TestScrollingRuleset : Ruleset { public override IEnumerable GetModsFor(ModType type) => throw new NotImplementedException(); public override DrawableRuleset CreateDrawableRulesetWith(IBeatmap beatmap, IReadOnlyList mods = null) => new TestDrawableScrollingRuleset(this, beatmap, mods); public override IBeatmapConverter CreateBeatmapConverter(IBeatmap beatmap) => new TestBeatmapConverter(beatmap, null); public override DifficultyCalculator CreateDifficultyCalculator(IWorkingBeatmap beatmap) => throw new NotImplementedException(); public override string Description { get; } = string.Empty; public override string ShortName { get; } = string.Empty; } private partial class TestDrawableScrollingRuleset : DrawableScrollingRuleset { public bool RelativeScaleBeatLengthsOverride { get; set; } protected override bool RelativeScaleBeatLengths => RelativeScaleBeatLengthsOverride; public new Bindable TimeRange => base.TimeRange; public TestDrawableScrollingRuleset(Ruleset ruleset, IBeatmap beatmap, IReadOnlyList mods = null) : base(ruleset, beatmap, mods) { TimeRange.Value = time_range; VisualisationMethod = ScrollVisualisationMethod.Overlapping; } public override DrawableHitObject CreateDrawableRepresentation(TestHitObject h) { switch (h) { case TestPooledHitObject: case TestPooledParentHitObject: return null; case TestParentHitObject p: return new DrawableTestParentHitObject(p); default: return new DrawableTestHitObject(h); } } protected override PassThroughInputManager CreateInputManager() => new PassThroughInputManager(); protected override Playfield CreatePlayfield() => new TestPlayfield(); } private partial class TestPlayfield : ScrollingPlayfield { public TestPlayfield() { AddInternal(new Container { RelativeSizeAxes = Axes.Both, Children = new Drawable[] { new Box { RelativeSizeAxes = Axes.Both, Alpha = 0.2f, }, new Container { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Top = 150 }, Children = new Drawable[] { new Box { Anchor = Anchor.TopCentre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.X, Height = 2, Colour = Color4.Green }, HitObjectContainer } } } }); RegisterPool(1); RegisterPool(1); } } private class TestBeatmapConverter : BeatmapConverter { public TestBeatmapConverter(IBeatmap beatmap, Ruleset ruleset) : base(beatmap, ruleset) { } public override bool CanConvert() => true; protected override IEnumerable ConvertHitObject(HitObject original, IBeatmap beatmap, CancellationToken cancellationToken) => throw new NotImplementedException(); } #endregion #region HitObject private class TestHitObject : HitObject, IHasDuration { public double EndTime => StartTime + Duration; public double Duration { get; set; } = 100; } private class TestPooledHitObject : TestHitObject { } private class TestParentHitObject : TestHitObject { public double ChildTimeOffset; protected override void CreateNestedHitObjects(CancellationToken cancellationToken) { AddNested(new TestHitObject { StartTime = StartTime + ChildTimeOffset }); } } private class TestPooledParentHitObject : TestParentHitObject { protected override void CreateNestedHitObjects(CancellationToken cancellationToken) { AddNested(new TestPooledHitObject { StartTime = StartTime + ChildTimeOffset }); } } private partial class DrawableTestHitObject : DrawableHitObject { public DrawableTestHitObject([CanBeNull] TestHitObject hitObject) : base(hitObject) { Anchor = Anchor.TopCentre; Origin = Anchor.TopCentre; Size = new Vector2(100, 25); AddRangeInternal(new Drawable[] { new Box { RelativeSizeAxes = Axes.Both, Colour = Color4.LightPink }, new Box { Origin = Anchor.CentreLeft, RelativeSizeAxes = Axes.X, Height = 2, Colour = Color4.Red } }); } protected override void Update() => LifetimeEnd = HitObject.EndTime; } private partial class DrawableTestPooledHitObject : DrawableTestHitObject { public DrawableTestPooledHitObject() : base(null) { InternalChildren[0].Colour = Color4.LightSkyBlue; InternalChildren[1].Colour = Color4.Blue; } } private partial class DrawableTestParentHitObject : DrawableTestHitObject { private readonly Container container; public DrawableTestParentHitObject([CanBeNull] TestHitObject hitObject) : base(hitObject) { InternalChildren[0].Colour = Color4.LightYellow; InternalChildren[1].Colour = Color4.Yellow; AddInternal(container = new Container { RelativeSizeAxes = Axes.Both, }); } protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) => new DrawableTestHitObject((TestHitObject)hitObject); protected override void AddNestedHitObject(DrawableHitObject hitObject) => container.Add(hitObject); protected override void ClearNestedHitObjects() => container.Clear(false); } private partial class DrawableTestPooledParentHitObject : DrawableTestParentHitObject { public DrawableTestPooledParentHitObject() : base(null) { InternalChildren[0].Colour = Color4.LightSeaGreen; InternalChildren[1].Colour = Color4.Green; } } #endregion } }