mirror of
https://github.com/ppy/osu.git
synced 2025-01-31 12:13:00 +08:00
414 lines
15 KiB
C#
414 lines
15 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;
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using JetBrains.Annotations;
|
||
using osu.Framework.Allocation;
|
||
using osu.Framework.Bindables;
|
||
using osu.Framework.Graphics;
|
||
using osu.Framework.Graphics.Containers;
|
||
using osu.Framework.Layout;
|
||
using osu.Game.Audio;
|
||
using osu.Game.Graphics.Containers;
|
||
using osu.Game.Rulesets.Judgements;
|
||
using osu.Game.Rulesets.Objects;
|
||
using osu.Game.Rulesets.Objects.Drawables;
|
||
using osu.Game.Rulesets.Osu.Judgements;
|
||
using osu.Game.Rulesets.Osu.Skinning.Default;
|
||
using osu.Game.Rulesets.Scoring;
|
||
using osu.Game.Skinning;
|
||
using osuTK;
|
||
|
||
namespace osu.Game.Rulesets.Osu.Objects.Drawables
|
||
{
|
||
public partial class DrawableSlider : DrawableOsuHitObject
|
||
{
|
||
public new Slider HitObject => (Slider)base.HitObject;
|
||
|
||
public new OsuSliderJudgementResult Result => (OsuSliderJudgementResult)base.Result;
|
||
|
||
public DrawableSliderHead HeadCircle => headContainer.Child;
|
||
public DrawableSliderTail TailCircle => tailContainer.Child;
|
||
|
||
[Cached]
|
||
public DrawableSliderBall Ball { get; private set; }
|
||
|
||
public SkinnableDrawable Body { get; private set; }
|
||
|
||
private ShakeContainer shakeContainer;
|
||
|
||
protected override IEnumerable<Drawable> DimmablePieces => new Drawable[]
|
||
{
|
||
// HeadCircle should not be added to this list, as it handles dimming itself
|
||
TailCircle,
|
||
repeatContainer,
|
||
Body,
|
||
};
|
||
|
||
/// <summary>
|
||
/// A target container which can be used to add top level elements to the slider's display.
|
||
/// Intended to be used for proxy purposes only.
|
||
/// </summary>
|
||
public Container OverlayElementContainer { get; private set; }
|
||
|
||
public override bool DisplayResult => HitObject.ClassicSliderBehaviour;
|
||
|
||
[CanBeNull]
|
||
public PlaySliderBody SliderBody => Body.Drawable as PlaySliderBody;
|
||
|
||
public IBindable<int> PathVersion => pathVersion;
|
||
private readonly Bindable<int> pathVersion = new Bindable<int>();
|
||
|
||
public readonly SliderInputManager SliderInputManager;
|
||
|
||
private Container<DrawableSliderHead> headContainer;
|
||
private Container<DrawableSliderTail> tailContainer;
|
||
private Container<DrawableSliderTick> tickContainer;
|
||
private Container<DrawableSliderRepeat> repeatContainer;
|
||
private PausableSkinnableSound slidingSample;
|
||
|
||
private readonly LayoutValue relativeAnchorPositionLayout;
|
||
|
||
public DrawableSlider()
|
||
: this(null)
|
||
{
|
||
}
|
||
|
||
public DrawableSlider([CanBeNull] Slider s = null)
|
||
: base(s)
|
||
{
|
||
SliderInputManager = new SliderInputManager(this);
|
||
|
||
Ball = new DrawableSliderBall
|
||
{
|
||
BypassAutoSizeAxes = Axes.Both,
|
||
AlwaysPresent = true,
|
||
Alpha = 0
|
||
};
|
||
AddLayout(relativeAnchorPositionLayout = new LayoutValue(Invalidation.DrawSize | Invalidation.MiscGeometry));
|
||
}
|
||
|
||
[BackgroundDependencyLoader]
|
||
private void load()
|
||
{
|
||
tailContainer = new Container<DrawableSliderTail> { RelativeSizeAxes = Axes.Both };
|
||
|
||
AddRangeInternal(new Drawable[]
|
||
{
|
||
SliderInputManager,
|
||
shakeContainer = new ShakeContainer
|
||
{
|
||
ShakeDuration = 30,
|
||
RelativeSizeAxes = Axes.Both,
|
||
Children = new[]
|
||
{
|
||
Body = new SkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.SliderBody), _ => new DefaultSliderBody(), confineMode: ConfineMode.NoScaling),
|
||
// proxied here so that the tail is drawn under repeats/ticks - legacy skins rely on this
|
||
tailContainer.CreateProxy(),
|
||
tickContainer = new Container<DrawableSliderTick> { RelativeSizeAxes = Axes.Both },
|
||
repeatContainer = new Container<DrawableSliderRepeat> { RelativeSizeAxes = Axes.Both },
|
||
// actual tail container is placed here to ensure that tail hitobjects are processed after ticks/repeats.
|
||
// this is required for the correct operation of Score V2.
|
||
tailContainer,
|
||
}
|
||
},
|
||
// slider head is not included in shake as it handles hit detection, and handles its own shaking.
|
||
headContainer = new Container<DrawableSliderHead> { RelativeSizeAxes = Axes.Both },
|
||
OverlayElementContainer = new Container { RelativeSizeAxes = Axes.Both, },
|
||
Ball,
|
||
slidingSample = new PausableSkinnableSound
|
||
{
|
||
Looping = true,
|
||
MinimumSampleVolume = MINIMUM_SAMPLE_VOLUME,
|
||
}
|
||
});
|
||
|
||
PositionBindable.BindValueChanged(_ => Position = HitObject.StackedPosition);
|
||
StackHeightBindable.BindValueChanged(_ => Position = HitObject.StackedPosition);
|
||
ScaleBindable.BindValueChanged(scale => Ball.Scale = new Vector2(scale.NewValue));
|
||
|
||
AccentColour.BindValueChanged(colour =>
|
||
{
|
||
foreach (var drawableHitObject in NestedHitObjects)
|
||
drawableHitObject.AccentColour.Value = colour.NewValue;
|
||
}, true);
|
||
}
|
||
|
||
protected override JudgementResult CreateResult(Judgement judgement) => new OsuSliderJudgementResult(HitObject, judgement);
|
||
|
||
protected override void OnApply()
|
||
{
|
||
base.OnApply();
|
||
|
||
// Ensure that the version will change after the upcoming BindTo().
|
||
pathVersion.Value = int.MaxValue;
|
||
PathVersion.BindTo(HitObject.Path.Version);
|
||
}
|
||
|
||
public override void Shake() => shakeContainer.Shake();
|
||
|
||
protected override void OnFree()
|
||
{
|
||
base.OnFree();
|
||
|
||
PathVersion.UnbindFrom(HitObject.Path.Version);
|
||
|
||
slidingSample?.ClearSamples();
|
||
}
|
||
|
||
protected override void LoadSamples()
|
||
{
|
||
// Note: base.LoadSamples() isn't called since the slider plays the tail's hitsounds for the time being.
|
||
|
||
Samples.Samples = HitObject.TailSamples.Cast<ISampleInfo>().ToArray();
|
||
slidingSample.Samples = HitObject.CreateSlidingSamples().Cast<ISampleInfo>().ToArray();
|
||
}
|
||
|
||
public override void StopAllSamples()
|
||
{
|
||
base.StopAllSamples();
|
||
slidingSample?.Stop();
|
||
}
|
||
|
||
protected override void AddNestedHitObject(DrawableHitObject hitObject)
|
||
{
|
||
base.AddNestedHitObject(hitObject);
|
||
|
||
switch (hitObject)
|
||
{
|
||
case DrawableSliderHead head:
|
||
headContainer.Child = head;
|
||
break;
|
||
|
||
case DrawableSliderTail tail:
|
||
tailContainer.Child = tail;
|
||
break;
|
||
|
||
case DrawableSliderTick tick:
|
||
tickContainer.Add(tick);
|
||
break;
|
||
|
||
case DrawableSliderRepeat repeat:
|
||
repeatContainer.Add(repeat);
|
||
break;
|
||
}
|
||
|
||
relativeAnchorPositionLayout.Invalidate();
|
||
}
|
||
|
||
protected override void ClearNestedHitObjects()
|
||
{
|
||
base.ClearNestedHitObjects();
|
||
|
||
headContainer.Clear(false);
|
||
tailContainer.Clear(false);
|
||
repeatContainer.Clear(false);
|
||
tickContainer.Clear(false);
|
||
|
||
OverlayElementContainer.Clear(false);
|
||
}
|
||
|
||
protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject)
|
||
{
|
||
switch (hitObject)
|
||
{
|
||
case SliderTailCircle tail:
|
||
return new DrawableSliderTail(tail);
|
||
|
||
case SliderHeadCircle head:
|
||
return new DrawableSliderHead(head);
|
||
|
||
case SliderTick tick:
|
||
return new DrawableSliderTick(tick);
|
||
|
||
case SliderRepeat repeat:
|
||
return new DrawableSliderRepeat(repeat);
|
||
}
|
||
|
||
return base.CreateNestedHitObject(hitObject);
|
||
}
|
||
|
||
public readonly Bindable<bool> Tracking = new Bindable<bool>();
|
||
|
||
protected override void Update()
|
||
{
|
||
base.Update();
|
||
|
||
Tracking.Value = SliderInputManager.Tracking;
|
||
|
||
if (slidingSample != null)
|
||
{
|
||
if (Tracking.Value && Time.Current >= HitObject.StartTime)
|
||
{
|
||
// keep the sliding sample playing at the current tracking position
|
||
if (!slidingSample.RequestedPlaying)
|
||
slidingSample.Play();
|
||
slidingSample.Balance.Value = CalculateSamplePlaybackBalance(CalculateDrawableRelativePosition(Ball));
|
||
}
|
||
else if (slidingSample.IsPlaying || slidingSample.RequestedPlaying)
|
||
slidingSample.Stop();
|
||
}
|
||
}
|
||
|
||
protected override void UpdateAfterChildren()
|
||
{
|
||
base.UpdateAfterChildren();
|
||
|
||
// During slider path editing, the PlaySliderBody is scheduled to refresh once on Update.
|
||
// It is crucial to perform the code below in UpdateAfterChildren. This ensures that the SliderBody has the opportunity
|
||
// to update its Size and PathOffset beforehand, ensuring correct placement.
|
||
|
||
double completionProgress = Math.Clamp((Time.Current - HitObject.StartTime) / HitObject.Duration, 0, 1);
|
||
|
||
Ball.UpdateProgress(completionProgress);
|
||
SliderBody?.UpdateProgress(HeadCircle.IsHit ? completionProgress : 0);
|
||
|
||
foreach (DrawableSliderRepeat repeat in repeatContainer)
|
||
repeat.UpdateSnakingPosition(HitObject.Path.PositionAt(SliderBody?.SnakedStart ?? 0), HitObject.Path.PositionAt(SliderBody?.SnakedEnd ?? 0));
|
||
|
||
Size = SliderBody?.Size ?? Vector2.Zero;
|
||
OriginPosition = SliderBody?.PathOffset ?? Vector2.Zero;
|
||
|
||
if (!relativeAnchorPositionLayout.IsValid)
|
||
{
|
||
Vector2 pos = Vector2.Divide(OriginPosition, DrawSize);
|
||
foreach (var obj in NestedHitObjects)
|
||
obj.RelativeAnchorPosition = pos;
|
||
Ball.RelativeAnchorPosition = pos;
|
||
|
||
relativeAnchorPositionLayout.Validate();
|
||
}
|
||
}
|
||
|
||
public override void OnKilled()
|
||
{
|
||
base.OnKilled();
|
||
SliderBody?.RecyclePath();
|
||
}
|
||
|
||
protected override void CheckForResult(bool userTriggered, double timeOffset)
|
||
{
|
||
if (userTriggered || !TailCircle.Judged || Time.Current < HitObject.EndTime)
|
||
return;
|
||
|
||
if (HitObject.ClassicSliderBehaviour)
|
||
{
|
||
// Classic behaviour means a slider is judged proportionally to the number of nested hitobjects hit. This is the classic osu!stable scoring.
|
||
ApplyResult(static (r, hitObject) =>
|
||
{
|
||
int totalTicks = hitObject.NestedHitObjects.Count;
|
||
int hitTicks = hitObject.NestedHitObjects.Count(h => h.IsHit);
|
||
|
||
if (hitTicks == totalTicks)
|
||
r.Type = HitResult.Great;
|
||
else if (hitTicks == 0)
|
||
r.Type = HitResult.Miss;
|
||
else
|
||
{
|
||
double hitFraction = (double)hitTicks / totalTicks;
|
||
r.Type = hitFraction >= 0.5 ? HitResult.Ok : HitResult.Meh;
|
||
}
|
||
});
|
||
}
|
||
else
|
||
{
|
||
// If only the nested hitobjects are judged, then the slider's own judgement is ignored for scoring purposes.
|
||
// But the slider needs to still be judged with a reasonable hit/miss result for visual purposes (hit/miss transforms, etc).
|
||
ApplyResult(static (r, hitObject) =>
|
||
{
|
||
r.Type = hitObject.NestedHitObjects.Any(h => h.Result.IsHit) ? r.Judgement.MaxResult : r.Judgement.MinResult;
|
||
});
|
||
}
|
||
}
|
||
|
||
public override void PlaySamples()
|
||
{
|
||
// rather than doing it this way, we should probably attach the sample to the tail circle.
|
||
// this can only be done if we stop using LastTick.
|
||
if (!TailCircle.SamplePlaysOnlyOnHit || TailCircle.IsHit)
|
||
base.PlaySamples();
|
||
}
|
||
|
||
protected override void UpdateInitialTransforms()
|
||
{
|
||
base.UpdateInitialTransforms();
|
||
|
||
Body.FadeInFromZero(HitObject.TimeFadeIn);
|
||
}
|
||
|
||
protected override void UpdateStartTimeStateTransforms()
|
||
{
|
||
base.UpdateStartTimeStateTransforms();
|
||
|
||
Ball.FadeIn();
|
||
Ball.ScaleTo(HitObject.Scale);
|
||
}
|
||
|
||
protected override void UpdateHitStateTransforms(ArmedState state)
|
||
{
|
||
base.UpdateHitStateTransforms(state);
|
||
|
||
const float fade_out_time = 240;
|
||
|
||
switch (state)
|
||
{
|
||
case ArmedState.Hit:
|
||
if (HeadCircle.IsHit && SliderBody?.SnakingOut.Value == true)
|
||
Body.FadeOut(40); // short fade to allow for any body colour to smoothly disappear.
|
||
break;
|
||
}
|
||
|
||
this.FadeOut(fade_out_time).Expire();
|
||
}
|
||
|
||
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => SliderBody?.ReceivePositionalInputAt(screenSpacePos) ?? base.ReceivePositionalInputAt(screenSpacePos);
|
||
|
||
private partial class DefaultSliderBody : PlaySliderBody
|
||
{
|
||
}
|
||
|
||
#region FOR EDITOR USE ONLY, DO NOT USE FOR ANY OTHER PURPOSE
|
||
|
||
internal void SuppressHitAnimations()
|
||
{
|
||
UpdateState(ArmedState.Idle);
|
||
HeadCircle.SuppressHitAnimations();
|
||
|
||
foreach (var repeat in repeatContainer)
|
||
repeat.SuppressHitAnimations();
|
||
|
||
TailCircle.SuppressHitAnimations();
|
||
|
||
// This method is called every frame in editor contexts, thus the lack of need for transforms.
|
||
|
||
if (Time.Current >= HitStateUpdateTime)
|
||
{
|
||
// Apply the slider's alpha to *only* the body.
|
||
// This allows start and – more importantly – end circles to fade slower than the overall slider.
|
||
if (Alpha < 1)
|
||
Body.Alpha = Alpha;
|
||
Alpha = 1;
|
||
}
|
||
|
||
LifetimeEnd = HitStateUpdateTime + 700;
|
||
}
|
||
|
||
internal void RestoreHitAnimations()
|
||
{
|
||
UpdateState(ArmedState.Hit);
|
||
HeadCircle.RestoreHitAnimations();
|
||
|
||
foreach (var repeat in repeatContainer)
|
||
repeat.RestoreHitAnimations();
|
||
|
||
TailCircle.RestoreHitAnimations();
|
||
}
|
||
|
||
#endregion
|
||
}
|
||
}
|