// 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.Collections.Specialized; using System.Diagnostics; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Pooling; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.UI; using osu.Game.Screens.Play; using osu.Game.Skinning; using osuTK.Graphics; namespace osu.Game.Rulesets.Objects.Drawables { [Cached(typeof(DrawableHitObject))] public abstract partial class DrawableHitObject : PoolableDrawableWithLifetime, IAnimationTimeReference { /// /// Invoked after this 's applied has had its defaults applied. /// public event Action DefaultsApplied; /// /// Invoked after a has been applied to this . /// public event Action HitObjectApplied; /// /// The currently represented by this . /// public HitObject HitObject => Entry?.HitObject; /// /// The parenting , if any. /// [CanBeNull] protected internal DrawableHitObject ParentHitObject { get; internal set; } /// /// The colour used for various elements of this DrawableHitObject. /// public readonly Bindable AccentColour = new Bindable(Color4.Gray); protected PausableSkinnableSound Samples { get; private set; } public virtual IEnumerable GetSamples() => HitObject.Samples; private readonly List nestedHitObjects = new List(); public IReadOnlyList NestedHitObjects => nestedHitObjects; /// /// Whether this object should handle any user input events. /// public bool HandleUserInput { get; set; } = true; public override bool PropagatePositionalInputSubTree => HandleUserInput; public override bool PropagateNonPositionalInputSubTree => HandleUserInput; /// /// Invoked by this or a nested after a has been applied. /// public event Action OnNewResult; /// /// Invoked by this or a nested prior to a being reverted. /// /// /// This is only invoked if this is alive when the result is reverted. /// public event Action OnRevertResult; /// /// Invoked when a new nested hit object is created by . /// internal event Action OnNestedDrawableCreated; /// /// Whether a visual indicator should be displayed when a scoring result occurs. /// public virtual bool DisplayResult => true; /// /// The scoring result of this . /// public JudgementResult Result => Entry?.Result; /// /// Whether this has been hit. This occurs if is hit. /// Note: This does NOT include nested hitobjects. /// public bool IsHit => Result?.IsHit ?? false; /// /// Whether this has been judged. /// Note: This does NOT include nested hitobjects. /// public bool Judged => Entry?.Judged ?? false; /// /// Whether this and all of its nested s have been judged. /// public bool AllJudged => Entry?.AllJudged ?? false; /// /// The relative X position of this hit object for sample playback balance adjustment. /// /// /// This is a range of 0..1 (0 for far-left, 0.5 for centre, 1 for far-right). /// Dampening is post-applied to ensure the effect is not too intense. /// protected virtual float SamplePlaybackPosition => 0.5f; public readonly Bindable StartTimeBindable = new Bindable(); private readonly BindableList samplesBindable = new BindableList(); private readonly Bindable comboIndexBindable = new Bindable(); private readonly IBindable positionalHitsoundsLevel = new Bindable(); private readonly IBindable comboColourBrightness = new Bindable(); private readonly Bindable comboIndexWithOffsetsBindable = new Bindable(); protected override bool RequiresChildrenUpdate => true; public override bool IsPresent => base.IsPresent || (State.Value == ArmedState.Idle && Clock.IsNotNull() && Clock.CurrentTime >= LifetimeStart); private readonly Bindable state = new Bindable(); /// /// The state of this . /// /// /// For pooled hitobjects, is recommended to be used instead for better editor/rewinding support. /// public IBindable State => state; [Resolved(CanBeNull = true)] private IPooledHitObjectProvider pooledObjectProvider { get; set; } /// /// Whether the initialization logic in has applied. /// internal bool IsInitialized; /// /// The minimum allowable volume for sample playback. /// quieter than that will be forcibly played at this volume instead. /// /// /// /// Drawable hitobjects adding their own custom samples, or other sample playback sources /// (i.e. ) must enforce this themselves. /// /// /// This sample volume floor is present in stable, although it is set at 8% rather than 5%. /// See: https://github.com/peppy/osu-stable-reference/blob/3ea48705eb67172c430371dcfc8a16a002ed0d3d/osu!/Audio/AudioEngine.cs#L1070, /// https://github.com/peppy/osu-stable-reference/blob/3ea48705eb67172c430371dcfc8a16a002ed0d3d/osu!/Audio/AudioEngine.cs#L1404-L1405. /// The reason why it is 5% here is that the 8% cap was enforced in a silent manner /// (i.e. the minimum selectable volume in the editor was 5%, but it would be played at 8% anyways), /// which is confusing and arbitrary, so we're just doing 5% here at the cost of sacrificing strict parity. /// /// public const int MINIMUM_SAMPLE_VOLUME = 5; /// /// Creates a new . /// /// /// The to be initially applied to this . /// If null, a hitobject is expected to be later applied via (or automatically via pooling). /// protected DrawableHitObject([CanBeNull] HitObject initialHitObject = null) { if (initialHitObject == null) return; Entry = new SyntheticHitObjectEntry(initialHitObject); ensureEntryHasResult(); } [BackgroundDependencyLoader] private void load(IGameplaySettings gameplaySettings, ISkinSource skinSource) { positionalHitsoundsLevel.BindTo(gameplaySettings.PositionalHitsoundsLevel); comboColourBrightness.BindTo(gameplaySettings.ComboColourNormalisationAmount); // Explicit non-virtual function call in case a DrawableHitObject overrides AddInternal. base.AddInternal(Samples = new PausableSkinnableSound { MinimumSampleVolume = MINIMUM_SAMPLE_VOLUME }); CurrentSkin = skinSource; CurrentSkin.SourceChanged += skinSourceChanged; } protected override void LoadAsyncComplete() { base.LoadAsyncComplete(); skinChanged(); } protected override void LoadComplete() { base.LoadComplete(); comboIndexBindable.BindValueChanged(_ => UpdateComboColour()); comboIndexWithOffsetsBindable.BindValueChanged(_ => UpdateComboColour(), true); comboColourBrightness.BindValueChanged(_ => UpdateComboColour()); // Apply transforms updateStateFromResult(); } /// /// Applies a new to be represented by this . /// A new is automatically created and applied to this . /// public void Apply([NotNull] HitObject hitObject) { ArgumentNullException.ThrowIfNull(hitObject); Apply(new SyntheticHitObjectEntry(hitObject)); } protected sealed override void OnApply(HitObjectLifetimeEntry entry) { Debug.Assert(Entry != null); // LifetimeStart is already computed using HitObjectLifetimeEntry's InitialLifetimeOffset. // We override this with DHO's InitialLifetimeOffset for a non-pooled DHO. if (entry is SyntheticHitObjectEntry) LifetimeStart = HitObject.StartTime - InitialLifetimeOffset; ensureEntryHasResult(); entry.RevertResult += onRevertResult; foreach (var h in HitObject.NestedHitObjects) { var pooledDrawableNested = pooledObjectProvider?.GetPooledDrawableRepresentation(h, this); var drawableNested = pooledDrawableNested ?? CreateNestedHitObject(h) ?? throw new InvalidOperationException($"{nameof(CreateNestedHitObject)} returned null for {h.GetType().ReadableName()}."); // Only invoke the event for non-pooled DHOs, otherwise the event will be fired by the playfield. if (pooledDrawableNested == null) OnNestedDrawableCreated?.Invoke(drawableNested); drawableNested.OnNewResult += onNewResult; drawableNested.OnRevertResult += onNestedRevertResult; drawableNested.ApplyCustomUpdateState += onApplyCustomUpdateState; // This is only necessary for non-pooled DHOs. For pooled DHOs, this is handled inside GetPooledDrawableRepresentation(). // Must be done before the nested DHO is added to occur before the nested Apply()! drawableNested.ParentHitObject = this; nestedHitObjects.Add(drawableNested); // assume that synthetic entries are not pooled and therefore need to be managed from within the DHO. // this is important for the correctness of value of flags such as `AllJudged`. if (drawableNested.Entry is SyntheticHitObjectEntry syntheticNestedEntry) Entry.NestedEntries.Add(syntheticNestedEntry); AddNestedHitObject(drawableNested); } StartTimeBindable.BindTo(HitObject.StartTimeBindable); if (HitObject is IHasComboInformation combo) { comboIndexBindable.BindTo(combo.ComboIndexBindable); comboIndexWithOffsetsBindable.BindTo(combo.ComboIndexWithOffsetsBindable); } samplesBindable.BindTo(HitObject.SamplesBindable); samplesBindable.BindCollectionChanged(onSamplesChanged, true); HitObject.DefaultsApplied += onDefaultsApplied; OnApply(); HitObjectApplied?.Invoke(this); // If not loaded, the state update happens in LoadComplete(). if (IsLoaded) { updateStateFromResult(); // Combo colour may have been applied via a bindable flow while no object entry was attached. // Update here to ensure we're in a good state. UpdateComboColour(); } } private void updateStateFromResult() { if (Result.IsHit) updateState(ArmedState.Hit, true); else if (Result.HasResult) updateState(ArmedState.Miss, true); else updateState(ArmedState.Idle, true); } protected sealed override void OnFree(HitObjectLifetimeEntry entry) { Debug.Assert(Entry != null); StartTimeBindable.UnbindFrom(HitObject.StartTimeBindable); if (HitObject is IHasComboInformation combo) { comboIndexBindable.UnbindFrom(combo.ComboIndexBindable); comboIndexWithOffsetsBindable.UnbindFrom(combo.ComboIndexWithOffsetsBindable); } samplesBindable.UnbindFrom(HitObject.SamplesBindable); // When a new hitobject is applied, the samples will be cleared before re-populating. // In order to stop this needless update, the event is unbound and re-bound as late as possible in Apply(). samplesBindable.CollectionChanged -= onSamplesChanged; // Release the samples for other hitobjects to use. Samples?.ClearSamples(); foreach (var obj in nestedHitObjects) { obj.OnNewResult -= onNewResult; obj.OnRevertResult -= onNestedRevertResult; obj.ApplyCustomUpdateState -= onApplyCustomUpdateState; } nestedHitObjects.Clear(); // clean up synthetic entries manually added in `Apply()`. Entry.NestedEntries.RemoveAll(nestedEntry => nestedEntry is SyntheticHitObjectEntry); ClearNestedHitObjects(); // Changes to `HitObject` properties trigger default application, which triggers `State` updates. // When a new hitobject is applied, `OnApply()` automatically performs a state update. HitObject.DefaultsApplied -= onDefaultsApplied; entry.RevertResult -= onRevertResult; OnFree(); ParentHitObject = null; clearExistingStateTransforms(); } /// /// Invoked for this to take on any values from a newly-applied . /// This is also fired after any changes which occurred via an call. /// protected virtual void OnApply() { } /// /// Invoked for this to revert any values previously taken on from the currently-applied . /// This is also fired after any changes which occurred via an call. /// protected virtual void OnFree() { } /// /// Invoked by the base to populate samples, once on initial load and potentially again on any change to the samples collection. /// protected virtual void LoadSamples() { var samples = GetSamples().ToArray(); if (samples.Length <= 0) return; Samples.Samples = samples.Cast().ToArray(); } private void onSamplesChanged(object sender, NotifyCollectionChangedEventArgs e) => LoadSamples(); private void onNewResult(DrawableHitObject drawableHitObject, JudgementResult result) => OnNewResult?.Invoke(drawableHitObject, result); private void onRevertResult() { updateState(ArmedState.Idle); OnRevertResult?.Invoke(this, Result); } private void onNestedRevertResult(DrawableHitObject drawableHitObject, JudgementResult result) => OnRevertResult?.Invoke(drawableHitObject, result); private void onApplyCustomUpdateState(DrawableHitObject drawableHitObject, ArmedState state) => ApplyCustomUpdateState?.Invoke(drawableHitObject, state); private void onDefaultsApplied(HitObject hitObject) { Debug.Assert(Entry != null); Apply(Entry); // Applied defaults indicate a change in hit object state. // We need to update the judgement result time to the new end time // and update state to ensure the hit object fades out at the correct time. if (Result is not null) { Result.TimeOffset = 0; updateState(State.Value, true); } DefaultsApplied?.Invoke(this); } /// /// Invoked by the base to add nested s to the hierarchy. /// /// The to be added. protected virtual void AddNestedHitObject(DrawableHitObject hitObject) { } /// /// Invoked by the base to remove all previously-added nested s. /// protected virtual void ClearNestedHitObjects() { } /// /// Creates the drawable representation for a nested . /// /// The . /// The drawable representation for . protected virtual DrawableHitObject CreateNestedHitObject(HitObject hitObject) => null; #region State / Transform Management /// /// Invoked by this or a nested to apply a custom state that can override the default implementation. /// public event Action ApplyCustomUpdateState; protected override void ClearInternal(bool disposeChildren = true) => // See sample addition in load method. throw new InvalidOperationException( $"Should never clear a {nameof(DrawableHitObject)} as the base implementation adds components. If attempting to use {nameof(InternalChild)} or {nameof(InternalChildren)}, using {nameof(AddInternal)} or {nameof(AddRangeInternal)} instead."); private void updateState(ArmedState newState, bool force = false) { if (State.Value == newState && !force) return; LifetimeEnd = double.MaxValue; clearExistingStateTransforms(); double initialTransformsTime = HitObject.StartTime - InitialLifetimeOffset; AnimationStartTime.Value = initialTransformsTime; using (BeginAbsoluteSequence(initialTransformsTime)) UpdateInitialTransforms(); using (BeginAbsoluteSequence(StateUpdateTime)) UpdateStartTimeStateTransforms(); using (BeginAbsoluteSequence(HitStateUpdateTime)) UpdateHitStateTransforms(newState); state.Value = newState; if (LifetimeEnd == double.MaxValue && (state.Value != ArmedState.Idle || HitObject.HitWindows == null)) LifetimeEnd = Math.Max(LatestTransformEndTime, HitStateUpdateTime + (Samples?.Length ?? 0)); // apply any custom state overrides ApplyCustomUpdateState?.Invoke(this, newState); if (!force && newState == ArmedState.Hit) PlaySamples(); } private void clearExistingStateTransforms() { base.ApplyTransformsAt(double.MinValue, true); // has to call this method directly (not ClearTransforms) to bypass the local ClearTransformsAfter override. base.ClearTransformsAfter(double.MinValue, true); } /// /// Reapplies the current . /// public void RefreshStateTransforms() => updateState(State.Value, true); /// /// Apply (generally fade-in) transforms leading into the start time. /// By default, this will fade in the object from zero with no duration. /// /// /// This is called once before every . This is to ensure a good state in the case /// the was negative and potentially altered the pre-hit transforms. /// protected virtual void UpdateInitialTransforms() { this.FadeInFromZero(); } /// /// Apply passive transforms at the 's StartTime. /// This is called each time changes. /// Previous states are automatically cleared. /// protected virtual void UpdateStartTimeStateTransforms() { } /// /// Apply transforms based on the current . This call is offset by (HitObject.EndTime + Result.Offset), equivalent to when the user hit the object. /// If was not set during this call, will be invoked. /// Previous states are automatically cleared. /// /// The new armed state. protected virtual void UpdateHitStateTransforms(ArmedState state) { } public override void ClearTransformsAfter(double time, bool propagateChildren = false, string targetMember = null) { // Parent calls to this should be blocked for safety, as we are manually handling this in updateState. } public override void ApplyTransformsAt(double time, bool propagateChildren = false) { // Parent calls to this should be blocked for safety, as we are manually handling this in updateState. } #endregion #region Skinning protected ISkinSource CurrentSkin { get; private set; } private void skinSourceChanged() => Scheduler.AddOnce(skinChanged); private void skinChanged() { UpdateComboColour(); ApplySkin(CurrentSkin, true); if (IsLoaded) updateState(State.Value, true); } protected void UpdateComboColour() { if (!(HitObject is IHasComboInformation combo)) return; Color4 colour = combo.GetComboColour(CurrentSkin); // Normalise the combo colour to the given brightness level. if (comboColourBrightness.Value != 0) { colour = Interpolation.ValueAt(Math.Abs(comboColourBrightness.Value), colour, new HSPAColour(colour) { P = 0.6f }.ToColor4(), 0, 1); } AccentColour.Value = colour; } /// /// Called when a change is made to the skin. /// /// The new skin. /// Whether fallback to default skin should be allowed if the custom skin is missing this resource. protected virtual void ApplySkin(ISkinSource skin, bool allowFallback) { } /// /// Calculate the position to be used for sample playback at a specified X position (0..1). /// /// The lookup X position. Generally should be . protected double CalculateSamplePlaybackBalance(double position) { float balanceAdjustAmount = positionalHitsoundsLevel.Value * 2; double returnedValue = balanceAdjustAmount * (position - 0.5f); // Rounded to reduce the overhead of audio adjustments (which are currently bindable heavy). // Balance is very hard to perceive in small increments anyways. return Math.Round(returnedValue, 2); } /// /// Plays all the hit sounds for this . /// This is invoked automatically when this is hit. /// public virtual void PlaySamples() { if (Samples != null) { Samples.Balance.Value = CalculateSamplePlaybackBalance(SamplePlaybackPosition); Samples.Play(); } } /// /// Stops playback of all relevant samples. Generally only looping samples should be stopped by this, and the rest let to play out. /// Automatically called when 's lifetime has been exceeded. /// public virtual void StopAllSamples() { if (Samples?.Looping == true) Samples.Stop(); } #endregion public override bool UpdateSubTreeMasking(Drawable source, RectangleF maskingBounds) => false; protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); UpdateResult(false); } /// /// Schedules an to this . /// /// /// Only provided temporarily until hitobject pooling is implemented. /// protected internal new ScheduledDelegate Schedule(Action action) => base.Schedule(action); /// /// An offset prior to the start time of at which this may begin displaying contents. /// By default, s are assumed to display their contents within 10 seconds prior to the start time of . /// /// /// The initial transformation () starts at this offset before the start time of . /// protected virtual double InitialLifetimeOffset => 10000; /// /// The time at which state transforms should be applied that line up to 's StartTime. /// This is used to offset calls to . /// public double StateUpdateTime => HitObject.StartTime; /// /// The time at which judgement dependent state transforms should be applied. This is equivalent of the (end) time of the object, in addition to any judgement offset. /// This is used to offset calls to . /// public double HitStateUpdateTime => Result?.TimeAbsolute ?? HitObject.GetEndTime(); /// /// Will be called at least once after this has become not alive. /// public virtual void OnKilled() { foreach (var nested in NestedHitObjects) nested.OnKilled(); // failsafe to ensure looping samples don't get stuck in a playing state. // this could occur in a non-frame-stable context where DrawableHitObjects get killed before a SkinnableSound has the chance to be stopped. StopAllSamples(); UpdateResult(false); } /// /// Applies the of this , notifying responders such as /// the of the . /// /// The callback that applies changes to the . protected void ApplyResult(Action application) { if (Result.HasResult) throw new InvalidOperationException("Cannot apply result on a hitobject that already has a result."); application?.Invoke(Result); if (!Result.HasResult) throw new InvalidOperationException($"{GetType().ReadableName()} applied a {nameof(JudgementResult)} but did not update {nameof(JudgementResult.Type)}."); HitResultExtensions.ValidateHitResultPair(Result.Judgement.MaxResult, Result.Judgement.MinResult); if (!Result.Type.IsValidHitResult(Result.Judgement.MinResult, Result.Judgement.MaxResult)) { throw new InvalidOperationException( $"{GetType().ReadableName()} applied an invalid hit result (was: {Result.Type}, expected: [{Result.Judgement.MinResult} ... {Result.Judgement.MaxResult}])."); } Result.RawTime = Time.Current; Result.GameplayRate = (Clock as IGameplayClock)?.GetTrueGameplayRate() ?? Clock.Rate; if (Result.HasResult) updateState(Result.IsHit ? ArmedState.Hit : ArmedState.Miss); OnNewResult?.Invoke(this, Result); } /// /// Processes this , checking if a scoring result has occurred. /// /// Whether the user triggered this process. /// Whether a scoring result has occurred from this or any nested . protected bool UpdateResult(bool userTriggered) { // It's possible for input to get into a bad state when rewinding gameplay, so results should not be processed if ((Clock as IGameplayClock)?.IsRewinding == true) return false; if (Judged) return false; CheckForResult(userTriggered, Time.Current - HitObject.GetEndTime()); return Judged; } /// /// Checks if a scoring result has occurred for this . /// /// /// If a scoring result has occurred, this method must invoke to update the result and notify responders. /// /// Whether the user triggered this check. /// The offset from the end time of the at which this check occurred. /// A > 0 implies that this check occurred after the end time of the . protected virtual void CheckForResult(bool userTriggered, double timeOffset) { } /// /// Creates the that represents the scoring result for this . /// /// The that provides the scoring information. protected virtual JudgementResult CreateResult(Judgement judgement) => new JudgementResult(HitObject, judgement); private void ensureEntryHasResult() { Debug.Assert(Entry != null); Entry.Result ??= CreateResult(HitObject.CreateJudgement()) ?? throw new InvalidOperationException($"{GetType().ReadableName()} must provide a {nameof(JudgementResult)} through {nameof(CreateResult)}."); } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); if (HitObject != null) HitObject.DefaultsApplied -= onDefaultsApplied; if (CurrentSkin != null) CurrentSkin.SourceChanged -= skinSourceChanged; // Safeties against shooting in foot in cases where these are bound by external entities (like playfield) that don't clean up. OnNestedDrawableCreated = null; OnNewResult = null; OnRevertResult = null; DefaultsApplied = null; HitObjectApplied = null; } public Bindable AnimationStartTime { get; } = new BindableDouble(); } public abstract partial class DrawableHitObject : DrawableHitObject where TObject : HitObject { public new TObject HitObject => (TObject)base.HitObject; protected DrawableHitObject([CanBeNull] TObject hitObject) : base(hitObject) { } } }