mirror of
https://github.com/ppy/osu.git
synced 2026-05-18 04:19:53 +08:00
d4a4acd3f4
Fixes https://github.com/ppy/osu/issues/29223 This fixes several issues around hold note dimming. Notice in the following video: - The tail piece of the first note not getting dimmed. - The body of the second note not responding to dimming at all. https://github.com/user-attachments/assets/8e51053d-8d88-4e48-909b-79218d65917b Then, notice in the following video: - The body piece of the second note is dimmed from the very beginning. https://github.com/user-attachments/assets/a514c630-5c72-4ba5-96d5-472bae1058b3 This requires a specific setup whereby the hold note and its components must be reused from the pool. In particular: 1. The hold note must be long. So long that by the time the tail becomes on screen, the body will already have dimmed. 2. The hold note must be re-used from the pool. We can induce this by setting the pool sizes to 1 in [`Column.cs`](https://github.com/ppy/osu/blob/780ce2666099c22d1e0673cafab544418b5d14b0/osu.Game.Rulesets.Mania/UI/Column.cs#L121-L123). 3. The second hold note should be placed far enough in the timeline that the first hold note dies by the time it becomes visible. 4. Scroll speed should be adjusted to fit the above constraints. I haven't done a full deep dive into exactly _why_ this is happening, so the fix here is hand-wavy. That said, just by looking at the old code in `LegacyBodyPiece` you'll get a feeling that something's bound to go wrong; - It never resets the `missingFadeTime` state. - It never resets the colours back to `Color4.White`. - It applies transforms onto external components. - It jumps through hoops to figure out how to set `missingFadeTime`. My hope is that these changes first bring some sanity in the process, and if it breaks again I'll consider doing a more proper root cause (I've had this issue in the back of my mind for about 1 year). With this PR, they now behave as expected: https://github.com/user-attachments/assets/140b37c5-cf84-44ba-b797-86ac6d8130c8 https://github.com/user-attachments/assets/6f2342a4-6a9a-4941-a55a-24a357f27c25
391 lines
14 KiB
C#
391 lines
14 KiB
C#
// 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.
|
|
|
|
#nullable disable
|
|
|
|
using System.Linq;
|
|
using osu.Framework.Allocation;
|
|
using osu.Framework.Bindables;
|
|
using osu.Framework.Graphics;
|
|
using osu.Framework.Graphics.Containers;
|
|
using osu.Framework.Input.Bindings;
|
|
using osu.Framework.Input.Events;
|
|
using osu.Game.Audio;
|
|
using osu.Game.Rulesets.Judgements;
|
|
using osu.Game.Rulesets.Mania.Judgements;
|
|
using osu.Game.Rulesets.Mania.Skinning.Default;
|
|
using osu.Game.Rulesets.Objects;
|
|
using osu.Game.Rulesets.Objects.Drawables;
|
|
using osu.Game.Rulesets.Scoring;
|
|
using osu.Game.Rulesets.UI.Scrolling;
|
|
using osu.Game.Screens.Play;
|
|
using osu.Game.Skinning;
|
|
using osuTK;
|
|
|
|
namespace osu.Game.Rulesets.Mania.Objects.Drawables
|
|
{
|
|
/// <summary>
|
|
/// Visualises a <see cref="HoldNote"/> hit object.
|
|
/// </summary>
|
|
public partial class DrawableHoldNote : DrawableManiaHitObject<HoldNote>, IKeyBindingHandler<ManiaAction>
|
|
{
|
|
public override bool DisplayResult => false;
|
|
|
|
/// <summary>
|
|
/// Whether the user is currently pressing the hold note.
|
|
/// </summary>
|
|
public IBindable<bool> IsHolding => isHolding;
|
|
|
|
private readonly Bindable<bool> isHolding = new Bindable<bool>();
|
|
|
|
/// <summary>
|
|
/// The time at which the user starting missing the hold note.
|
|
/// This could be the time at which they missed the head, broke on the body, or missed the tail.
|
|
/// </summary>
|
|
public IBindable<double?> MissingStartTime => missingStartTime;
|
|
|
|
private readonly Bindable<double?> missingStartTime = new Bindable<double?>();
|
|
|
|
public DrawableHoldNoteHead Head => headContainer.Child;
|
|
public DrawableHoldNoteTail Tail => tailContainer.Child;
|
|
public DrawableHoldNoteBody Body => bodyContainer.Child;
|
|
|
|
private Container<DrawableHoldNoteHead> headContainer;
|
|
private Container<DrawableHoldNoteTail> tailContainer;
|
|
private Container<DrawableHoldNoteBody> bodyContainer;
|
|
|
|
private PausableSkinnableSound slidingSample;
|
|
|
|
/// <summary>
|
|
/// Contains the size of the hold note covering the whole head/tail bounds. The size of this container changes as the hold note is being pressed.
|
|
/// </summary>
|
|
private Container sizingContainer;
|
|
|
|
/// <summary>
|
|
/// Contains the contents of the hold note that should be masked as the hold note is being pressed. Follows changes in the size of <see cref="sizingContainer"/>.
|
|
/// </summary>
|
|
private Container maskingContainer;
|
|
|
|
private SkinnableDrawable bodyPiece;
|
|
|
|
public DrawableHoldNote()
|
|
: this(null)
|
|
{
|
|
}
|
|
|
|
public DrawableHoldNote(HoldNote hitObject)
|
|
: base(hitObject)
|
|
{
|
|
}
|
|
|
|
[BackgroundDependencyLoader]
|
|
private void load()
|
|
{
|
|
Container maskedContents;
|
|
|
|
AddRangeInternal(new Drawable[]
|
|
{
|
|
sizingContainer = new Container
|
|
{
|
|
RelativeSizeAxes = Axes.Both,
|
|
Children = new Drawable[]
|
|
{
|
|
maskingContainer = new Container
|
|
{
|
|
RelativeSizeAxes = Axes.Both,
|
|
Child = maskedContents = new Container
|
|
{
|
|
RelativeSizeAxes = Axes.Both,
|
|
Masking = true,
|
|
}
|
|
},
|
|
headContainer = new Container<DrawableHoldNoteHead> { RelativeSizeAxes = Axes.Both }
|
|
}
|
|
},
|
|
bodyContainer = new Container<DrawableHoldNoteBody> { RelativeSizeAxes = Axes.Both },
|
|
bodyPiece = new SkinnableDrawable(new ManiaSkinComponentLookup(ManiaSkinComponents.HoldNoteBody), _ => new DefaultBodyPiece
|
|
{
|
|
RelativeSizeAxes = Axes.Both,
|
|
})
|
|
{
|
|
RelativeSizeAxes = Axes.X
|
|
},
|
|
tailContainer = new Container<DrawableHoldNoteTail> { RelativeSizeAxes = Axes.Both },
|
|
slidingSample = new PausableSkinnableSound
|
|
{
|
|
Looping = true,
|
|
MinimumSampleVolume = MINIMUM_SAMPLE_VOLUME,
|
|
}
|
|
});
|
|
|
|
maskedContents.AddRange(new[]
|
|
{
|
|
bodyPiece.CreateProxy(),
|
|
tailContainer.CreateProxy(),
|
|
});
|
|
}
|
|
|
|
protected override void LoadComplete()
|
|
{
|
|
base.LoadComplete();
|
|
|
|
isHolding.BindValueChanged(updateSlidingSample, true);
|
|
}
|
|
|
|
protected override void OnApply()
|
|
{
|
|
base.OnApply();
|
|
|
|
sizingContainer.Size = Vector2.One;
|
|
}
|
|
|
|
protected override void AddNestedHitObject(DrawableHitObject hitObject)
|
|
{
|
|
base.AddNestedHitObject(hitObject);
|
|
|
|
switch (hitObject)
|
|
{
|
|
case DrawableHoldNoteHead head:
|
|
headContainer.Child = head;
|
|
break;
|
|
|
|
case DrawableHoldNoteTail tail:
|
|
tailContainer.Child = tail;
|
|
break;
|
|
|
|
case DrawableHoldNoteBody body:
|
|
bodyContainer.Child = body;
|
|
break;
|
|
}
|
|
}
|
|
|
|
protected override void ClearNestedHitObjects()
|
|
{
|
|
base.ClearNestedHitObjects();
|
|
headContainer.Clear(false);
|
|
tailContainer.Clear(false);
|
|
bodyContainer.Clear(false);
|
|
}
|
|
|
|
protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject)
|
|
{
|
|
switch (hitObject)
|
|
{
|
|
case TailNote tail:
|
|
return new DrawableHoldNoteTail(tail);
|
|
|
|
case HeadNote head:
|
|
return new DrawableHoldNoteHead(head);
|
|
|
|
case HoldNoteBody body:
|
|
return new DrawableHoldNoteBody(body);
|
|
}
|
|
|
|
return base.CreateNestedHitObject(hitObject);
|
|
}
|
|
|
|
protected override void OnDirectionChanged(ValueChangedEvent<ScrollingDirection> e)
|
|
{
|
|
base.OnDirectionChanged(e);
|
|
|
|
if (e.NewValue == ScrollingDirection.Up)
|
|
{
|
|
bodyPiece.Anchor = bodyPiece.Origin = Anchor.TopLeft;
|
|
sizingContainer.Anchor = sizingContainer.Origin = Anchor.BottomLeft;
|
|
}
|
|
else
|
|
{
|
|
bodyPiece.Anchor = bodyPiece.Origin = Anchor.BottomLeft;
|
|
sizingContainer.Anchor = sizingContainer.Origin = Anchor.TopLeft;
|
|
}
|
|
}
|
|
|
|
public override void PlaySamples()
|
|
{
|
|
// Samples are played by the head/tail notes.
|
|
}
|
|
|
|
public override void OnKilled()
|
|
{
|
|
base.OnKilled();
|
|
|
|
// flush the final state of holding on kill.
|
|
// this matters because some skin implementations like legacy skin
|
|
// insert drawables in the hierarchy that are not a child of this DHO
|
|
// (see `LegacyBodyPiece` and related machinations with `lightContainer` being added at column level)
|
|
isHolding.Value = Result.IsHolding(Time.Current);
|
|
missingStartTime.Value = null;
|
|
(bodyPiece.Drawable as IHoldNoteBody)?.Recycle();
|
|
}
|
|
|
|
protected override void Update()
|
|
{
|
|
base.Update();
|
|
|
|
if (Head.Judged && !Head.IsHit)
|
|
missingStartTime.Value ??= Head.Result.TimeAbsolute;
|
|
if (Body.HasHoldBreak)
|
|
missingStartTime.Value ??= Body.Result.TimeAbsolute;
|
|
if (Tail.Judged && !Tail.IsHit)
|
|
missingStartTime.Value ??= Tail.Result.TimeAbsolute;
|
|
|
|
isHolding.Value = Result.IsHolding(Time.Current);
|
|
|
|
// Pad the full size container so its contents (i.e. the masking container) reach under the tail.
|
|
// This is required for the tail to not be masked away, since it lies outside the bounds of the hold note.
|
|
sizingContainer.Padding = new MarginPadding
|
|
{
|
|
Top = Direction.Value == ScrollingDirection.Down ? -Tail.Height : 0,
|
|
Bottom = Direction.Value == ScrollingDirection.Up ? -Tail.Height : 0,
|
|
};
|
|
|
|
// Pad the masking container to the starting position of the body piece (half-way under the head).
|
|
// This is required to make the body start getting masked immediately as soon as the note is held.
|
|
maskingContainer.Padding = new MarginPadding
|
|
{
|
|
Top = Direction.Value == ScrollingDirection.Up ? Head.Height / 2 : 0,
|
|
Bottom = Direction.Value == ScrollingDirection.Down ? Head.Height / 2 : 0,
|
|
};
|
|
|
|
// Position and resize the body to lie half-way under the head and the tail notes.
|
|
// The rationale for this is account for heads/tails with corner radius.
|
|
bodyPiece.Y = (Direction.Value == ScrollingDirection.Up ? 1 : -1) * Head.Height / 2;
|
|
bodyPiece.Height = DrawHeight - Head.Height / 2 + Tail.Height / 2;
|
|
|
|
if (Time.Current >= HitObject.StartTime)
|
|
{
|
|
// As the note is being held, adjust the size of the sizing container. This has two effects:
|
|
// 1. The contained masking container will mask the body and ticks.
|
|
// 2. The head note will move along with the new "head position" in the container.
|
|
//
|
|
// As per stable, this should not apply for early hits, waiting until the object starts to touch the
|
|
// judgement area first.
|
|
if (Head.IsHit && !Result.DroppedHoldAfter(HitObject.StartTime) && DrawHeight > 0)
|
|
{
|
|
// How far past the hit target this hold note is.
|
|
float yOffset = Direction.Value == ScrollingDirection.Up ? -Y : Y;
|
|
sizingContainer.Height = 1 - yOffset / DrawHeight;
|
|
}
|
|
}
|
|
else
|
|
sizingContainer.Height = 1;
|
|
}
|
|
|
|
protected override JudgementResult CreateResult(Judgement judgement) => new HoldNoteJudgementResult(HitObject, judgement);
|
|
|
|
public new HoldNoteJudgementResult Result => (HoldNoteJudgementResult)base.Result;
|
|
|
|
protected override void CheckForResult(bool userTriggered, double timeOffset)
|
|
{
|
|
if (Tail.AllJudged)
|
|
{
|
|
if (Tail.IsHit)
|
|
ApplyMaxResult();
|
|
else
|
|
MissForcefully();
|
|
|
|
// Make sure that the hold note is fully judged by giving the body a judgement.
|
|
if (!Body.AllJudged)
|
|
Body.TriggerResult(Tail.IsHit);
|
|
|
|
// Important that this is always called when a result is applied.
|
|
Result.ReportHoldState(Time.Current, false);
|
|
}
|
|
}
|
|
|
|
public override void MissForcefully()
|
|
{
|
|
base.MissForcefully();
|
|
|
|
// Important that this is always called when a result is applied.
|
|
Result.ReportHoldState(Time.Current, false);
|
|
}
|
|
|
|
public bool OnPressed(KeyBindingPressEvent<ManiaAction> e)
|
|
{
|
|
if (AllJudged)
|
|
return false;
|
|
|
|
if (e.Action != Action.Value)
|
|
return false;
|
|
|
|
// do not run any of this logic when rewinding, as it inverts order of presses/releases.
|
|
if ((Clock as IGameplayClock)?.IsRewinding == true)
|
|
return false;
|
|
|
|
if (CheckHittable?.Invoke(this, Time.Current) == false)
|
|
return false;
|
|
|
|
// The tail has a lenience applied to it which is factored into the miss window (i.e. the miss judgement will be delayed).
|
|
// But the hold cannot ever be started within the late-lenience window, so we should skip trying to begin the hold during that time.
|
|
// Note: Unlike below, we use the tail's start time to determine the time offset.
|
|
if (Time.Current > Tail.HitObject.StartTime && !Tail.HitObject.HitWindows.CanBeHit(Time.Current - Tail.HitObject.StartTime))
|
|
return false;
|
|
|
|
beginHoldAt(Time.Current - Head.HitObject.StartTime);
|
|
|
|
return Head.UpdateResult();
|
|
}
|
|
|
|
private void beginHoldAt(double timeOffset)
|
|
{
|
|
if (timeOffset < -Head.HitObject.HitWindows.WindowFor(HitResult.Miss))
|
|
return;
|
|
|
|
Result.ReportHoldState(Time.Current, true);
|
|
}
|
|
|
|
public void OnReleased(KeyBindingReleaseEvent<ManiaAction> e)
|
|
{
|
|
if (AllJudged)
|
|
return;
|
|
|
|
if (e.Action != Action.Value)
|
|
return;
|
|
|
|
// do not run any of this logic when rewinding, as it inverts order of presses/releases.
|
|
if ((Clock as IGameplayClock)?.IsRewinding == true)
|
|
return;
|
|
|
|
// When our action is released and we are in the middle of a hold, there's a chance that
|
|
// the user has released too early (before the tail).
|
|
//
|
|
// In such a case, we want to record this against the DrawableHoldNoteBody.
|
|
if (isHolding.Value)
|
|
{
|
|
Tail.UpdateResult();
|
|
Body.TriggerResult(Tail.IsHit);
|
|
|
|
Result.ReportHoldState(Time.Current, false);
|
|
}
|
|
}
|
|
|
|
protected override void LoadSamples()
|
|
{
|
|
// Note: base.LoadSamples() isn't called since the slider plays the tail's hitsounds for the time being.
|
|
|
|
slidingSample.Samples = HitObject.CreateSlidingSamples().Cast<ISampleInfo>().ToArray();
|
|
}
|
|
|
|
public override void StopAllSamples()
|
|
{
|
|
base.StopAllSamples();
|
|
slidingSample?.Stop();
|
|
}
|
|
|
|
private void updateSlidingSample(ValueChangedEvent<bool> tracking)
|
|
{
|
|
if (tracking.NewValue && HitObject.PlaySlidingSamples)
|
|
slidingSample?.Play();
|
|
else
|
|
slidingSample?.Stop();
|
|
}
|
|
|
|
protected override void OnFree()
|
|
{
|
|
slidingSample.ClearSamples();
|
|
base.OnFree();
|
|
}
|
|
}
|
|
}
|