1
0
mirror of https://github.com/ppy/osu.git synced 2025-03-13 01:27:25 +08:00
osu-lazer/osu.Game/Rulesets/UI/Scrolling/ScrollingHitObjectContainer.cs

292 lines
12 KiB
C#
Raw Normal View History

// 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.
2018-04-13 18:19:50 +09:00
2022-06-17 16:37:17 +09:00
#nullable disable
using System;
using System.Collections.Generic;
using osu.Framework.Allocation;
2019-02-21 19:04:31 +09:00
using osu.Framework.Bindables;
2018-04-13 18:19:50 +09:00
using osu.Framework.Graphics;
using osu.Framework.Graphics.Primitives;
2020-02-24 20:52:15 +09:00
using osu.Framework.Layout;
using osu.Game.Rulesets.Objects;
2018-04-13 18:19:50 +09:00
using osu.Game.Rulesets.Objects.Drawables;
using osu.Game.Rulesets.Objects.Types;
2023-08-15 20:38:17 +09:00
using osu.Game.Rulesets.UI.Scrolling.Algorithms;
using osuTK;
2018-04-13 18:19:50 +09:00
namespace osu.Game.Rulesets.UI.Scrolling
{
2022-11-24 14:32:20 +09:00
public partial class ScrollingHitObjectContainer : HitObjectContainer
2018-04-13 18:19:50 +09:00
{
private readonly IBindable<double> timeRange = new BindableDouble();
2018-11-06 15:46:36 +09:00
private readonly IBindable<ScrollingDirection> direction = new Bindable<ScrollingDirection>();
2023-08-15 20:38:17 +09:00
private readonly IBindable<IScrollAlgorithm> algorithm = new Bindable<IScrollAlgorithm>();
/// <summary>
/// Whether the scrolling direction is horizontal or vertical.
/// </summary>
private Direction scrollingAxis => direction.Value == ScrollingDirection.Left || direction.Value == ScrollingDirection.Right ? Direction.Horizontal : Direction.Vertical;
/// <summary>
2021-06-14 21:51:32 +02:00
/// The scrolling axis is inverted if objects temporally farther in the future have a smaller position value across the scrolling axis.
/// </summary>
2021-06-14 21:51:32 +02:00
/// <example>
/// <see cref="ScrollingDirection.Down"/> is inverted, because given two objects, one of which is at the current time and one of which is 1000ms in the future,
/// in the current time instant the future object is spatially above the current object, and therefore has a smaller value of the Y coordinate of its position.
/// </example>
private bool axisInverted => direction.Value == ScrollingDirection.Down || direction.Value == ScrollingDirection.Right;
2020-11-30 15:54:20 +09:00
/// <summary>
/// A set of top-level <see cref="DrawableHitObject"/>s which have an up-to-date layout.
2020-11-30 15:54:20 +09:00
/// </summary>
private readonly HashSet<DrawableHitObject> layoutComputed = new HashSet<DrawableHitObject>();
2018-11-06 15:46:36 +09:00
[Resolved]
private IScrollingInfo scrollingInfo { get; set; }
// 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);
public ScrollingHitObjectContainer()
2018-04-13 18:19:50 +09:00
{
RelativeSizeAxes = Axes.Both;
2020-02-24 20:52:15 +09:00
AddLayout(layoutCache);
2018-11-06 15:46:36 +09:00
}
[BackgroundDependencyLoader]
private void load()
{
direction.BindTo(scrollingInfo.Direction);
timeRange.BindTo(scrollingInfo.TimeRange);
2023-08-15 20:38:17 +09:00
algorithm.BindTo(scrollingInfo.Algorithm);
direction.ValueChanged += _ => layoutCache.Invalidate();
timeRange.ValueChanged += _ => layoutCache.Invalidate();
2023-08-15 20:38:17 +09:00
algorithm.ValueChanged += _ => layoutCache.Invalidate();
2018-04-13 18:19:50 +09:00
}
2020-05-25 22:09:09 +09:00
/// <summary>
2021-06-14 21:51:32 +02:00
/// Given a position at <paramref name="currentTime"/>, return the time of the object corresponding to the position.
2020-05-25 22:09:09 +09:00
/// </summary>
2021-06-14 12:41:44 +09:00
/// <remarks>
/// If there are multiple valid time values, one arbitrary time is returned.
/// </remarks>
public double TimeAtPosition(float localPosition, double currentTime)
{
float scrollPosition = axisInverted ? -localPosition : localPosition;
2023-08-15 20:38:17 +09:00
return algorithm.Value.TimeAt(scrollPosition, currentTime, timeRange.Value, scrollLength);
}
2020-05-25 22:09:09 +09:00
/// <summary>
2021-06-14 12:41:44 +09:00
/// Given a position at the current time in screen space, return the time of the object corresponding the position.
2020-05-25 22:09:09 +09:00
/// </summary>
2021-06-14 12:41:44 +09:00
/// <remarks>
/// If there are multiple valid time values, one arbitrary time is returned.
/// </remarks>
public double TimeAtScreenSpacePosition(Vector2 screenSpacePosition)
{
Vector2 pos = ToLocalSpace(screenSpacePosition);
float localPosition = scrollingAxis == Direction.Horizontal ? pos.X : pos.Y;
localPosition -= axisInverted ? scrollLength : 0;
return TimeAtPosition(localPosition, Time.Current);
}
/// <summary>
/// Given a time, return the position along the scrolling axis within this <see cref="HitObjectContainer"/> at time <paramref name="currentTime"/>.
/// </summary>
public float PositionAtTime(double time, double currentTime, double? originTime = null)
{
2023-08-15 20:38:17 +09:00
float scrollPosition = algorithm.Value.PositionAt(time, currentTime, timeRange.Value, scrollLength, originTime);
return axisInverted ? -scrollPosition : scrollPosition;
}
/// <summary>
/// Given a time, return the position along the scrolling axis within this <see cref="HitObjectContainer"/> at the current time.
/// </summary>
public float PositionAtTime(double time) => PositionAtTime(time, Time.Current);
/// <summary>
/// Given a time, return the screen space position within this <see cref="HitObjectContainer"/>.
/// In the non-scrolling axis, the center of this <see cref="HitObjectContainer"/> is returned.
/// </summary>
public Vector2 ScreenSpacePositionAtTime(double time)
{
float localPosition = PositionAtTime(time, Time.Current);
localPosition += axisInverted ? scrollLength : 0;
return scrollingAxis == Direction.Horizontal
? ToScreenSpace(new Vector2(localPosition, DrawHeight / 2))
: ToScreenSpace(new Vector2(DrawWidth / 2, localPosition));
}
/// <summary>
/// Given a start time and end time of a scrolling object, return the length of the object along the scrolling axis.
/// </summary>
public float LengthAtTime(double startTime, double endTime)
{
2023-08-15 20:38:17 +09:00
return algorithm.Value.GetLength(startTime, endTime, timeRange.Value, scrollLength);
}
private float scrollLength => scrollingAxis == Direction.Horizontal ? DrawWidth : DrawHeight;
public override void Add(HitObjectLifetimeEntry entry)
{
// Scroll info is not available until loaded.
// The lifetime of all entries will be updated in the first Update.
if (IsLoaded)
setComputedLifetime(entry);
base.Add(entry);
}
protected override void AddDrawable(HitObjectLifetimeEntry entry, DrawableHitObject drawable)
{
base.AddDrawable(entry, drawable);
invalidateHitObject(drawable);
drawable.DefaultsApplied += invalidateHitObject;
}
protected override void RemoveDrawable(HitObjectLifetimeEntry entry, DrawableHitObject drawable)
{
base.RemoveDrawable(entry, drawable);
drawable.DefaultsApplied -= invalidateHitObject;
layoutComputed.Remove(drawable);
}
private void invalidateHitObject(DrawableHitObject hitObject)
{
layoutComputed.Remove(hitObject);
}
2018-04-13 18:19:50 +09:00
protected override void Update()
{
base.Update();
if (layoutCache.IsValid) return;
layoutComputed.Clear();
foreach (var entry in Entries)
setComputedLifetime(entry);
2023-08-15 20:38:17 +09:00
algorithm.Value.Reset();
layoutCache.Validate();
}
protected override void UpdateAfterChildrenLife()
{
base.UpdateAfterChildrenLife();
// We need to calculate hit object positions (including nested hit objects) as soon as possible after lifetimes
// to prevent hit objects displayed in a wrong position for one frame.
// Only AliveEntries need to be considered for layout (reduces overhead in the case of scroll speed changes).
// We are not using AliveObjects directly to avoid selection/sorting overhead since we don't care about the order at which positions will be updated.
2024-01-31 22:52:57 +09:00
foreach (var entry in AliveEntries)
{
var obj = entry.Value;
updatePosition(obj, Time.Current);
if (layoutComputed.Contains(obj))
continue;
updateLayoutRecursive(obj);
layoutComputed.Add(obj);
}
}
/// <summary>
/// Get a conservative maximum bounding box of a <see cref="DrawableHitObject"/> corresponding to <paramref name="entry"/>.
/// It is used to calculate when the hit object appears.
/// </summary>
protected virtual RectangleF GetConservativeBoundingBox(HitObjectLifetimeEntry entry) => new RectangleF().Inflate(100);
private double computeDisplayStartTime(HitObjectLifetimeEntry entry)
{
RectangleF boundingBox = GetConservativeBoundingBox(entry);
float startOffset = 0;
switch (direction.Value)
{
case ScrollingDirection.Right:
startOffset = boundingBox.Right;
break;
case ScrollingDirection.Down:
startOffset = boundingBox.Bottom;
break;
case ScrollingDirection.Left:
startOffset = -boundingBox.Left;
break;
case ScrollingDirection.Up:
startOffset = -boundingBox.Top;
break;
}
2023-08-15 20:38:17 +09:00
return algorithm.Value.GetDisplayStartTime(entry.HitObject.StartTime, startOffset, timeRange.Value, scrollLength);
}
private void setComputedLifetime(HitObjectLifetimeEntry entry)
{
double computedStartTime = computeDisplayStartTime(entry);
// always load the hitobject before its first judgement offset
entry.LifetimeStart = Math.Min(entry.HitObject.StartTime - entry.HitObject.MaximumJudgementOffset, computedStartTime);
// This is likely not entirely correct, but sets a sane expectation of the ending lifetime.
// A more correct lifetime will be overwritten after a DrawableHitObject is assigned via DrawableHitObject.updateState.
//
// It is required that we set a lifetime end here to ensure that in scenarios like loading a Player instance to a seeked
// location in a beatmap doesn't churn every hit object into a DrawableHitObject. Even in a pooled scenario, the overhead
// of this can be quite crippling.
Fix taiko swell ending samples playing at results sometimes Closes https://github.com/ppy/osu/issues/32052. Sooooo... this is going to be a rant... To understand why this is going to require a rant, dear reader, please do the following: 1. Read the issue thread and follow the reproduction scenario (download map linked, fire up autoplay, seek near end, wait for results, hear the sample spam). 2. Now exit out to song select, *hide the toolbar*, and attempt reproducing the issue again. 3. Depending on ambient mood, laugh or cry. Now, *why on earth* would the *TOOLBAR* have any bearing on anything? Well, the chain of failure is something like this: - The toolbar hides for the duration of gameplay, naturally. - When progressing to results, the toolbar gets automatically unhidden. - This triggers invalidations on `ScrollingHitObjectContainer`. I'm not precisely sure which property it is that triggers the invalidations, but one clearly does. It may be position or size or whichever. - When the invalidation is triggered on `layoutCache`, the next `Update()` call is going to recompute lifetimes for ALL hitobject entries. - In case of swells, it happens that the calculated lifetime end of the swell is larger than what it actually ended up being determined as at the instant of judging the swell, and thus, the swell is *resurrected*, reassigned a DHO, and the DHO calls `UpdateState()` and plays the sample again despite the `samplePlayed` flag in `LegacySwell`, because all of that is ephemeral state that does not survive a hitobject getting resurrected. Now I *could* just fix this locally to the swell, maybe, by having some time lenience check, but the fact that hitobjects can be resurrected by the *toolbar* appearing, of all possible causes in the world, feels just completely wrong. So I'm adding a local check in SHOC to not overwrite lifetime ends of judged object entries. The reason why I'm making that check specific to end time is that I can see valid reasons why you would want to recompute lifetime *start* even on a judged object (such as playfield geometry changing in a significant way). I can't think of a valid reason to do that to lifetime *end*.
2025-02-24 14:30:55 +01:00
//
// However, additionally do not attempt to alter lifetime of judged entries.
// This is to prevent freak accidents like objects suddenly becoming alive because of this estimate assigning a later lifetime
// than the object itself decided it should have when it underwent judgement.
if (!entry.Judged)
entry.LifetimeEnd = entry.HitObject.GetEndTime() + timeRange.Value;
}
private void updateLayoutRecursive(DrawableHitObject hitObject, double? parentHitObjectStartTime = null)
{
parentHitObjectStartTime ??= hitObject.HitObject.StartTime;
2020-05-27 12:38:39 +09:00
if (hitObject.HitObject is IHasDuration e)
{
float length = LengthAtTime(hitObject.HitObject.StartTime, e.EndTime);
if (scrollingAxis == Direction.Horizontal)
hitObject.Width = length;
else
hitObject.Height = length;
}
2019-08-26 19:06:23 +09:00
foreach (var obj in hitObject.NestedHitObjects)
{
updateLayoutRecursive(obj, parentHitObjectStartTime);
// Nested hitobjects don't need to scroll, but they do need accurate positions and start lifetime
updatePosition(obj, hitObject.HitObject.StartTime, parentHitObjectStartTime);
setComputedLifetime(obj.Entry);
2018-04-13 18:19:50 +09:00
}
}
2018-04-13 18:19:50 +09:00
private void updatePosition(DrawableHitObject hitObject, double currentTime, double? parentHitObjectStartTime = null)
{
float position = PositionAtTime(hitObject.HitObject.StartTime, currentTime, parentHitObjectStartTime);
2019-04-01 12:44:46 +09:00
if (scrollingAxis == Direction.Horizontal)
hitObject.X = position;
else
hitObject.Y = position;
2018-04-13 18:19:50 +09:00
}
}
}