1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-14 00:42:55 +08:00

Merge pull request #10950 from ekrctb/pool-scrolling

Support hit object pooling in ScrollingPlayfield
This commit is contained in:
Dan Balasescu 2020-11-30 18:29:34 +09:00 committed by GitHub
commit b56e832e83
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 276 additions and 117 deletions

View File

@ -97,7 +97,7 @@ namespace osu.Game.Rulesets.Mania.UI
DrawableManiaHitObject maniaObject = (DrawableManiaHitObject)hitObject; DrawableManiaHitObject maniaObject = (DrawableManiaHitObject)hitObject;
maniaObject.CheckHittable = hitPolicy.IsHittable; maniaObject.CheckHittable = hitPolicy.IsHittable;
HitObjectContainer.Add(hitObject); base.Add(hitObject);
} }
public override bool Remove(DrawableHitObject h) public override bool Remove(DrawableHitObject h)

View File

@ -21,13 +21,13 @@ using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.UI; using osu.Game.Rulesets.UI;
using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Rulesets.UI.Scrolling;
using osuTK; using osuTK;
using osuTK.Graphics; using osuTK.Graphics;
using JetBrains.Annotations;
namespace osu.Game.Tests.Visual.Gameplay namespace osu.Game.Tests.Visual.Gameplay
{ {
@ -46,6 +46,50 @@ namespace osu.Game.Tests.Visual.Gameplay
[SetUp] [SetUp]
public void Setup() => Schedule(() => testClock.CurrentTime = 0); 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);
}
[Test] [Test]
public void TestRelativeBeatLengthScaleSingleTimingPoint() public void TestRelativeBeatLengthScaleSingleTimingPoint()
{ {
@ -147,8 +191,37 @@ namespace osu.Game.Tests.Visual.Gameplay
assertPosition(1, 1); assertPosition(1, 1);
} }
/// <summary>
/// Get a <see cref="DrawableTestHitObject" /> corresponding to the <paramref name="index"/>'th <see cref="TestHitObject"/>.
/// When the hit object is not alive, `null` is returned.
/// </summary>
[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}", private void assertPosition(int index, float relativeY) => AddAssert($"hitobject {index} at {relativeY}",
() => Precision.AlmostEquals(drawableRuleset.Playfield.AllHitObjects.ElementAt(index).DrawPosition.Y, drawableRuleset.Playfield.HitObjectContainer.DrawHeight * relativeY)); () => Precision.AlmostEquals(getDrawableHitObject(index)?.DrawPosition.Y ?? -1, yScale * relativeY));
private void setTime(double time) private void setTime(double time)
{ {
@ -160,12 +233,16 @@ namespace osu.Game.Tests.Visual.Gameplay
/// The hitobjects are spaced <see cref="time_range"/> milliseconds apart. /// The hitobjects are spaced <see cref="time_range"/> milliseconds apart.
/// </summary> /// </summary>
/// <returns>The <see cref="IBeatmap"/>.</returns> /// <returns>The <see cref="IBeatmap"/>.</returns>
private IBeatmap createBeatmap() private IBeatmap createBeatmap(Func<int, TestHitObject> createAction = null)
{ {
var beatmap = new Beatmap<HitObject> { BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo } }; var beatmap = new Beatmap<TestHitObject> { BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo } };
for (int i = 0; i < 10; i++) for (int i = 0; i < 10; i++)
beatmap.HitObjects.Add(new HitObject { StartTime = i * time_range }); {
var h = createAction?.Invoke(i) ?? new TestHitObject();
h.StartTime = i * time_range;
beatmap.HitObjects.Add(h);
}
return beatmap; return beatmap;
} }
@ -225,7 +302,21 @@ namespace osu.Game.Tests.Visual.Gameplay
TimeRange.Value = time_range; TimeRange.Value = time_range;
} }
public override DrawableHitObject<TestHitObject> CreateDrawableRepresentation(TestHitObject h) => new DrawableTestHitObject(h); public override DrawableHitObject<TestHitObject> 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 PassThroughInputManager CreateInputManager() => new PassThroughInputManager();
@ -265,6 +356,9 @@ namespace osu.Game.Tests.Visual.Gameplay
} }
} }
}); });
RegisterPool<TestPooledHitObject, DrawableTestPooledHitObject>(1);
RegisterPool<TestPooledParentHitObject, DrawableTestPooledParentHitObject>(1);
} }
} }
@ -277,30 +371,46 @@ namespace osu.Game.Tests.Visual.Gameplay
public override bool CanConvert() => true; public override bool CanConvert() => true;
protected override IEnumerable<TestHitObject> ConvertHitObject(HitObject original, IBeatmap beatmap, CancellationToken cancellationToken) protected override IEnumerable<TestHitObject> ConvertHitObject(HitObject original, IBeatmap beatmap, CancellationToken cancellationToken) =>
{ throw new NotImplementedException();
yield return new TestHitObject
{
StartTime = original.StartTime,
Duration = (original as IHasDuration)?.Duration ?? 100
};
}
} }
#endregion #endregion
#region HitObject #region HitObject
private class TestHitObject : ConvertHitObject, IHasDuration private class TestHitObject : HitObject, IHasDuration
{ {
public double EndTime => StartTime + Duration; public double EndTime => StartTime + Duration;
public double Duration { get; set; } 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 class DrawableTestHitObject : DrawableHitObject<TestHitObject> private class DrawableTestHitObject : DrawableHitObject<TestHitObject>
{ {
public DrawableTestHitObject(TestHitObject hitObject) public DrawableTestHitObject([CanBeNull] TestHitObject hitObject)
: base(hitObject) : base(hitObject)
{ {
Anchor = Anchor.TopCentre; Anchor = Anchor.TopCentre;
@ -324,6 +434,52 @@ namespace osu.Game.Tests.Visual.Gameplay
} }
}); });
} }
protected override void Update() => LifetimeEnd = HitObject.EndTime;
}
private class DrawableTestPooledHitObject : DrawableTestHitObject
{
public DrawableTestPooledHitObject()
: base(null)
{
InternalChildren[0].Colour = Color4.LightSkyBlue;
InternalChildren[1].Colour = Color4.Blue;
}
}
private class DrawableTestParentHitObject : DrawableTestHitObject
{
private readonly Container<DrawableHitObject> container;
public DrawableTestParentHitObject([CanBeNull] TestHitObject hitObject)
: base(hitObject)
{
InternalChildren[0].Colour = Color4.LightYellow;
InternalChildren[1].Colour = Color4.Yellow;
AddInternal(container = new Container<DrawableHitObject>
{
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 class DrawableTestPooledParentHitObject : DrawableTestParentHitObject
{
public DrawableTestPooledParentHitObject()
: base(null)
{
InternalChildren[0].Colour = Color4.LightSeaGreen;
InternalChildren[1].Colour = Color4.Green;
}
} }
#endregion #endregion

View File

@ -114,6 +114,7 @@ namespace osu.Game.Rulesets.UI
bindStartTime(drawable); bindStartTime(drawable);
AddInternal(drawableMap[entry] = drawable, false); AddInternal(drawableMap[entry] = drawable, false);
OnAdd(drawable);
HitObjectUsageBegan?.Invoke(entry.HitObject); HitObjectUsageBegan?.Invoke(entry.HitObject);
} }
@ -129,6 +130,7 @@ namespace osu.Game.Rulesets.UI
drawableMap.Remove(entry); drawableMap.Remove(entry);
OnRemove(drawable);
unbindStartTime(drawable); unbindStartTime(drawable);
RemoveInternal(drawable); RemoveInternal(drawable);
@ -147,10 +149,12 @@ namespace osu.Game.Rulesets.UI
hitObject.OnRevertResult += onRevertResult; hitObject.OnRevertResult += onRevertResult;
AddInternal(hitObject); AddInternal(hitObject);
OnAdd(hitObject);
} }
public virtual bool Remove(DrawableHitObject hitObject) public virtual bool Remove(DrawableHitObject hitObject)
{ {
OnRemove(hitObject);
if (!RemoveInternal(hitObject)) if (!RemoveInternal(hitObject))
return false; return false;
@ -178,6 +182,26 @@ namespace osu.Game.Rulesets.UI
#endregion #endregion
/// <summary>
/// Invoked when a <see cref="DrawableHitObject"/> is added to this container.
/// </summary>
/// <remarks>
/// This method is not invoked for nested <see cref="DrawableHitObject"/>s.
/// </remarks>
protected virtual void OnAdd(DrawableHitObject drawableHitObject)
{
}
/// <summary>
/// Invoked when a <see cref="DrawableHitObject"/> is removed from this container.
/// </summary>
/// <remarks>
/// This method is not invoked for nested <see cref="DrawableHitObject"/>s.
/// </remarks>
protected virtual void OnRemove(DrawableHitObject drawableHitObject)
{
}
public virtual void Clear(bool disposeChildren = true) public virtual void Clear(bool disposeChildren = true)
{ {
lifetimeManager.ClearEntries(); lifetimeManager.ClearEntries();

View File

@ -135,9 +135,7 @@ namespace osu.Game.Rulesets.UI
/// <param name="h">The DrawableHitObject to add.</param> /// <param name="h">The DrawableHitObject to add.</param>
public virtual void Add(DrawableHitObject h) public virtual void Add(DrawableHitObject h)
{ {
if (h.IsInitialized) if (!h.IsInitialized)
throw new InvalidOperationException($"{nameof(Add)} doesn't support {nameof(DrawableHitObject)} reuse. Use pooling instead.");
onNewDrawableHitObject(h); onNewDrawableHitObject(h);
HitObjectContainer.Add(h); HitObjectContainer.Add(h);

View File

@ -2,13 +2,10 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic; using System.Collections.Generic;
using JetBrains.Annotations;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Caching;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Layout; using osu.Framework.Layout;
using osu.Framework.Threading;
using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
using osuTK; using osuTK;
@ -19,7 +16,16 @@ namespace osu.Game.Rulesets.UI.Scrolling
{ {
private readonly IBindable<double> timeRange = new BindableDouble(); private readonly IBindable<double> timeRange = new BindableDouble();
private readonly IBindable<ScrollingDirection> direction = new Bindable<ScrollingDirection>(); private readonly IBindable<ScrollingDirection> direction = new Bindable<ScrollingDirection>();
private readonly Dictionary<DrawableHitObject, InitialState> hitObjectInitialStateCache = new Dictionary<DrawableHitObject, InitialState>();
/// <summary>
/// Hit objects which require lifetime computation in the next update call.
/// </summary>
private readonly HashSet<DrawableHitObject> toComputeLifetime = new HashSet<DrawableHitObject>();
/// <summary>
/// A set containing all <see cref="HitObjectContainer.AliveObjects"/> which have an up-to-date layout.
/// </summary>
private readonly HashSet<DrawableHitObject> layoutComputed = new HashSet<DrawableHitObject>();
[Resolved] [Resolved]
private IScrollingInfo scrollingInfo { get; set; } private IScrollingInfo scrollingInfo { get; set; }
@ -27,10 +33,6 @@ namespace osu.Game.Rulesets.UI.Scrolling
// Responds to changes in the layout. When the layout changes, all hit object states must be recomputed. // Responds to changes in the layout. When the layout changes, all hit object states must be recomputed.
private readonly LayoutValue layoutCache = new LayoutValue(Invalidation.RequiredParentSizeToFit | Invalidation.DrawInfo); private readonly LayoutValue layoutCache = new LayoutValue(Invalidation.RequiredParentSizeToFit | Invalidation.DrawInfo);
// A combined cache across all hit object states to reduce per-update iterations.
// When invalidated, one or more (but not necessarily all) hitobject states must be re-validated.
private readonly Cached combinedObjCache = new Cached();
public ScrollingHitObjectContainer() public ScrollingHitObjectContainer()
{ {
RelativeSizeAxes = Axes.Both; RelativeSizeAxes = Axes.Both;
@ -48,37 +50,12 @@ namespace osu.Game.Rulesets.UI.Scrolling
timeRange.ValueChanged += _ => layoutCache.Invalidate(); timeRange.ValueChanged += _ => layoutCache.Invalidate();
} }
public override void Add(DrawableHitObject hitObject)
{
combinedObjCache.Invalidate();
hitObject.DefaultsApplied += onDefaultsApplied;
base.Add(hitObject);
}
public override bool Remove(DrawableHitObject hitObject)
{
var result = base.Remove(hitObject);
if (result)
{
combinedObjCache.Invalidate();
hitObjectInitialStateCache.Remove(hitObject);
hitObject.DefaultsApplied -= onDefaultsApplied;
}
return result;
}
public override void Clear(bool disposeChildren = true) public override void Clear(bool disposeChildren = true)
{ {
foreach (var h in Objects)
h.DefaultsApplied -= onDefaultsApplied;
base.Clear(disposeChildren); base.Clear(disposeChildren);
combinedObjCache.Invalidate(); toComputeLifetime.Clear();
hitObjectInitialStateCache.Clear(); layoutComputed.Clear();
} }
/// <summary> /// <summary>
@ -173,15 +150,40 @@ namespace osu.Game.Rulesets.UI.Scrolling
} }
} }
private void onDefaultsApplied(DrawableHitObject drawableObject) protected override void OnAdd(DrawableHitObject drawableHitObject) => onAddRecursive(drawableHitObject);
protected override void OnRemove(DrawableHitObject drawableHitObject) => onRemoveRecursive(drawableHitObject);
private void onAddRecursive(DrawableHitObject hitObject)
{ {
// The cache may not exist if the hitobject state hasn't been computed yet (e.g. if the hitobject was added + defaults applied in the same frame). invalidateHitObject(hitObject);
// In such a case, combinedObjCache will take care of updating the hitobject.
if (hitObjectInitialStateCache.TryGetValue(drawableObject, out var state)) hitObject.DefaultsApplied += invalidateHitObject;
{
combinedObjCache.Invalidate(); foreach (var nested in hitObject.NestedHitObjects)
state.Cache.Invalidate(); onAddRecursive(nested);
} }
private void onRemoveRecursive(DrawableHitObject hitObject)
{
toComputeLifetime.Remove(hitObject);
layoutComputed.Remove(hitObject);
hitObject.DefaultsApplied -= invalidateHitObject;
foreach (var nested in hitObject.NestedHitObjects)
onRemoveRecursive(nested);
}
/// <summary>
/// Make this <see cref="DrawableHitObject"/> lifetime and layout computed in next update.
/// </summary>
private void invalidateHitObject(DrawableHitObject hitObject)
{
// Lifetime computation is delayed until next update because
// when the hit object is not pooled this container is not loaded here and `scrollLength` cannot be computed.
toComputeLifetime.Add(hitObject);
layoutComputed.Remove(hitObject);
} }
private float scrollLength; private float scrollLength;
@ -192,17 +194,18 @@ namespace osu.Game.Rulesets.UI.Scrolling
if (!layoutCache.IsValid) if (!layoutCache.IsValid)
{ {
foreach (var state in hitObjectInitialStateCache.Values) toComputeLifetime.Clear();
state.Cache.Invalidate();
combinedObjCache.Invalidate(); foreach (var hitObject in Objects)
{
if (hitObject.HitObject != null)
toComputeLifetime.Add(hitObject);
}
layoutComputed.Clear();
scrollingInfo.Algorithm.Reset(); scrollingInfo.Algorithm.Reset();
layoutCache.Validate();
}
if (!combinedObjCache.IsValid)
{
switch (direction.Value) switch (direction.Value)
{ {
case ScrollingDirection.Up: case ScrollingDirection.Up:
@ -215,32 +218,24 @@ namespace osu.Game.Rulesets.UI.Scrolling
break; break;
} }
foreach (var obj in Objects) layoutCache.Validate();
{
if (!hitObjectInitialStateCache.TryGetValue(obj, out var state))
state = hitObjectInitialStateCache[obj] = new InitialState(new Cached());
if (state.Cache.IsValid)
continue;
state.ScheduledComputation?.Cancel();
state.ScheduledComputation = computeInitialStateRecursive(obj);
computeLifetimeStartRecursive(obj);
state.Cache.Validate();
} }
combinedObjCache.Validate(); foreach (var hitObject in toComputeLifetime)
}
}
private void computeLifetimeStartRecursive(DrawableHitObject hitObject)
{
hitObject.LifetimeStart = computeOriginAdjustedLifetimeStart(hitObject); hitObject.LifetimeStart = computeOriginAdjustedLifetimeStart(hitObject);
foreach (var obj in hitObject.NestedHitObjects) toComputeLifetime.Clear();
computeLifetimeStartRecursive(obj);
// only AliveObjects need to be considered for layout (reduces overhead in the case of scroll speed changes).
foreach (var obj in AliveObjects)
{
if (layoutComputed.Contains(obj))
continue;
updateLayoutRecursive(obj);
layoutComputed.Add(obj);
}
} }
private double computeOriginAdjustedLifetimeStart(DrawableHitObject hitObject) private double computeOriginAdjustedLifetimeStart(DrawableHitObject hitObject)
@ -271,7 +266,7 @@ namespace osu.Game.Rulesets.UI.Scrolling
return scrollingInfo.Algorithm.GetDisplayStartTime(hitObject.HitObject.StartTime, originAdjustment, timeRange.Value, scrollLength); return scrollingInfo.Algorithm.GetDisplayStartTime(hitObject.HitObject.StartTime, originAdjustment, timeRange.Value, scrollLength);
} }
private ScheduledDelegate computeInitialStateRecursive(DrawableHitObject hitObject) => hitObject.Schedule(() => private void updateLayoutRecursive(DrawableHitObject hitObject)
{ {
if (hitObject.HitObject is IHasDuration e) if (hitObject.HitObject is IHasDuration e)
{ {
@ -291,12 +286,12 @@ namespace osu.Game.Rulesets.UI.Scrolling
foreach (var obj in hitObject.NestedHitObjects) foreach (var obj in hitObject.NestedHitObjects)
{ {
computeInitialStateRecursive(obj); updateLayoutRecursive(obj);
// Nested hitobjects don't need to scroll, but they do need accurate positions // Nested hitobjects don't need to scroll, but they do need accurate positions
updatePosition(obj, hitObject.HitObject.StartTime); updatePosition(obj, hitObject.HitObject.StartTime);
} }
}); }
protected override void UpdateAfterChildrenLife() protected override void UpdateAfterChildrenLife()
{ {
@ -328,19 +323,5 @@ namespace osu.Game.Rulesets.UI.Scrolling
break; break;
} }
} }
private class InitialState
{
[NotNull]
public readonly Cached Cache;
[CanBeNull]
public ScheduledDelegate ScheduledComputation;
public InitialState(Cached cache)
{
Cache = cache;
}
}
} }
} }

View File

@ -15,6 +15,8 @@ namespace osu.Game.Rulesets.UI.Scrolling
{ {
protected readonly IBindable<ScrollingDirection> Direction = new Bindable<ScrollingDirection>(); protected readonly IBindable<ScrollingDirection> Direction = new Bindable<ScrollingDirection>();
public new ScrollingHitObjectContainer HitObjectContainer => (ScrollingHitObjectContainer)base.HitObjectContainer;
[Resolved] [Resolved]
protected IScrollingInfo ScrollingInfo { get; private set; } protected IScrollingInfo ScrollingInfo { get; private set; }
@ -27,14 +29,12 @@ namespace osu.Game.Rulesets.UI.Scrolling
/// <summary> /// <summary>
/// Given a position in screen space, return the time within this column. /// Given a position in screen space, return the time within this column.
/// </summary> /// </summary>
public virtual double TimeAtScreenSpacePosition(Vector2 screenSpacePosition) => public virtual double TimeAtScreenSpacePosition(Vector2 screenSpacePosition) => HitObjectContainer.TimeAtScreenSpacePosition(screenSpacePosition);
((ScrollingHitObjectContainer)HitObjectContainer).TimeAtScreenSpacePosition(screenSpacePosition);
/// <summary> /// <summary>
/// Given a time, return the screen space position within this column. /// Given a time, return the screen space position within this column.
/// </summary> /// </summary>
public virtual Vector2 ScreenSpacePositionAtTime(double time) public virtual Vector2 ScreenSpacePositionAtTime(double time) => HitObjectContainer.ScreenSpacePositionAtTime(time);
=> ((ScrollingHitObjectContainer)HitObjectContainer).ScreenSpacePositionAtTime(time);
protected sealed override HitObjectContainer CreateHitObjectContainer() => new ScrollingHitObjectContainer(); protected sealed override HitObjectContainer CreateHitObjectContainer() => new ScrollingHitObjectContainer();
} }