// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Primitives; using osu.Framework.Logging; using osu.Framework.Threading; using osu.Game.Audio; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; using osu.Game.Configuration; using osuTK.Graphics; namespace osu.Game.Rulesets.Objects.Drawables { [Cached(typeof(DrawableHitObject))] public abstract class DrawableHitObject : SkinReloadableDrawable { public event Action DefaultsApplied; public readonly HitObject HitObject; /// /// 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 Lazy> nestedHitObjects = new Lazy>(); public IReadOnlyList NestedHitObjects => nestedHitObjects.IsValueCreated ? nestedHitObjects.Value : (IReadOnlyList)Array.Empty(); /// /// 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. /// public event Action OnRevertResult; /// /// Whether a visual indicator should be displayed when a scoring result occurs. /// public virtual bool DisplayResult => true; /// /// Whether this and all of its nested s have been judged. /// public bool AllJudged => Judged && NestedHitObjects.All(h => h.AllJudged); /// /// 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 => Result?.HasResult ?? true; /// /// The scoring result of this . /// public JudgementResult Result { get; private set; } /// /// 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; private BindableList samplesBindable; private Bindable startTimeBindable; private Bindable userPositionalHitSounds; private Bindable comboIndexBindable; public override bool RemoveWhenNotAlive => false; public override bool RemoveCompletedTransforms => false; protected override bool RequiresChildrenUpdate => true; public override bool IsPresent => base.IsPresent || (State.Value == ArmedState.Idle && Clock?.CurrentTime >= LifetimeStart); private readonly Bindable state = new Bindable(); public IBindable State => state; protected DrawableHitObject([NotNull] HitObject hitObject) { HitObject = hitObject ?? throw new ArgumentNullException(nameof(hitObject)); } [BackgroundDependencyLoader] private void load(OsuConfigManager config) { userPositionalHitSounds = config.GetBindable(OsuSetting.PositionalHitSounds); var judgement = HitObject.CreateJudgement(); Result = CreateResult(judgement); if (Result == null) throw new InvalidOperationException($"{GetType().ReadableName()} must provide a {nameof(JudgementResult)} through {nameof(CreateResult)}."); LoadSamples(); } protected override void LoadAsyncComplete() { base.LoadAsyncComplete(); HitObject.DefaultsApplied += onDefaultsApplied; startTimeBindable = HitObject.StartTimeBindable.GetBoundCopy(); startTimeBindable.BindValueChanged(_ => updateState(State.Value, true)); if (HitObject is IHasComboInformation combo) { comboIndexBindable = combo.ComboIndexBindable.GetBoundCopy(); comboIndexBindable.BindValueChanged(_ => updateComboColour(), true); } samplesBindable = HitObject.SamplesBindable.GetBoundCopy(); samplesBindable.CollectionChanged += (_, __) => LoadSamples(); apply(HitObject); } protected override void LoadComplete() { base.LoadComplete(); updateState(ArmedState.Idle, true); } /// /// 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() { if (Samples != null) { RemoveInternal(Samples); Samples = null; } var samples = GetSamples().ToArray(); if (samples.Length <= 0) return; if (HitObject.SampleControlPoint == null) { throw new InvalidOperationException($"{nameof(HitObject)}s must always have an attached {nameof(HitObject.SampleControlPoint)}." + $" This is an indication that {nameof(HitObject.ApplyDefaults)} has not been invoked on {this}."); } Samples = new PausableSkinnableSound(samples.Select(s => HitObject.SampleControlPoint.ApplyTo(s))); AddInternal(Samples); } private void onDefaultsApplied(HitObject hitObject) { apply(hitObject); updateState(state.Value, true); DefaultsApplied?.Invoke(this); } private void apply(HitObject hitObject) { if (nestedHitObjects.IsValueCreated) { nestedHitObjects.Value.Clear(); ClearNestedHitObjects(); } foreach (var h in hitObject.NestedHitObjects) { var drawableNested = CreateNestedHitObject(h) ?? throw new InvalidOperationException($"{nameof(CreateNestedHitObject)} returned null for {h.GetType().ReadableName()}."); drawableNested.OnNewResult += (d, r) => OnNewResult?.Invoke(d, r); drawableNested.OnRevertResult += (d, r) => OnRevertResult?.Invoke(d, r); drawableNested.ApplyCustomUpdateState += (d, j) => ApplyCustomUpdateState?.Invoke(d, j); nestedHitObjects.Value.Add(drawableNested); AddNestedHitObject(drawableNested); } } /// /// 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) => throw new InvalidOperationException($"Should never clear a {nameof(DrawableHitObject)}"); private void updateState(ArmedState newState, bool force = false) { if (State.Value == newState && !force) return; LifetimeEnd = double.MaxValue; double transformTime = HitObject.StartTime - InitialLifetimeOffset; base.ApplyTransformsAt(double.MinValue, true); base.ClearTransformsAfter(double.MinValue, true); using (BeginAbsoluteSequence(transformTime, true)) { UpdateInitialTransforms(); var judgementOffset = Result?.TimeOffset ?? 0; using (BeginDelayedSequence(InitialLifetimeOffset + judgementOffset, true)) { UpdateStateTransforms(newState); state.Value = newState; } } if (LifetimeEnd == double.MaxValue && (state.Value != ArmedState.Idle || HitObject.HitWindows == null)) Expire(); // apply any custom state overrides ApplyCustomUpdateState?.Invoke(this, newState); if (newState == ArmedState.Hit) PlaySamples(); } /// /// Apply (generally fade-in) transforms leading into the start time. /// The local drawable hierarchy is recursively delayed to for convenience. /// /// 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 transforms based on the current . Previous states are automatically cleared. /// In the case of a non-idle , and if was not set during this call, will be invoked. /// /// The new armed state. protected virtual void UpdateStateTransforms(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 protected sealed override void SkinChanged(ISkinSource skin, bool allowFallback) { base.SkinChanged(skin, allowFallback); updateComboColour(); ApplySkin(skin, allowFallback); if (IsLoaded) updateState(State.Value, true); } private void updateComboColour() { if (!(HitObject is IHasComboInformation)) return; var comboColours = CurrentSkin.GetConfig>(GlobalSkinColours.ComboColours)?.Value; AccentColour.Value = GetComboColour(comboColours); } /// /// Called to retrieve the combo colour. Automatically assigned to . /// Defaults to using to decide on a colour. /// /// /// This will only be called if the implements . /// /// A list of combo colours provided by the beatmap or skin. Can be null if not available. protected virtual Color4 GetComboColour(IReadOnlyList comboColours) { if (!(HitObject is IHasComboInformation combo)) throw new InvalidOperationException($"{nameof(HitObject)} must implement {nameof(IHasComboInformation)}"); return comboColours?.Count > 0 ? comboColours[combo.ComboIndex % comboColours.Count] : Color4.White; } /// /// 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) { const float balance_adjust_amount = 0.4f; return balance_adjust_amount * (userPositionalHitSounds.Value ? position - 0.5f : 0); } /// /// 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 samples. Automatically called when 's lifetime has been exceeded. /// public virtual void StopAllSamples() => Samples?.Stop(); protected override void Update() { base.Update(); if (Result != null && Result.HasResult) { var endTime = HitObject.GetEndTime(); if (Result.TimeOffset + endTime > Time.Current) { OnRevertResult?.Invoke(this, Result); Result.TimeOffset = 0; Result.Type = HitResult.None; updateState(ArmedState.Idle); } } } 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); private double? lifetimeStart; public override double LifetimeStart { get => lifetimeStart ?? (HitObject.StartTime - InitialLifetimeOffset); set { lifetimeStart = value; base.LifetimeStart = value; } } /// /// A safe 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 . /// /// /// This is only used as an optimisation to delay the initial update of this and may be tuned more aggressively if required. /// It is indirectly used to decide the automatic transform offset provided to . /// A more accurate should be set for further optimisation (in , for example). /// protected virtual double InitialLifetimeOffset => 10000; /// /// Will be called at least once after this has become not alive. /// public virtual void OnKilled() { foreach (var nested in NestedHitObjects) nested.OnKilled(); 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)}."); // Some (especially older) rulesets use scorable judgements instead of the newer ignorehit/ignoremiss judgements. // Can be removed 20210328 if (Result.Judgement.MaxResult == HitResult.IgnoreHit) { HitResult originalType = Result.Type; if (Result.Type == HitResult.Miss) Result.Type = HitResult.IgnoreMiss; else if (Result.Type >= HitResult.Meh && Result.Type <= HitResult.Perfect) Result.Type = HitResult.IgnoreHit; if (Result.Type != originalType) { Logger.Log($"{GetType().ReadableName()} applied an invalid hit result ({originalType}) when {nameof(HitResult.IgnoreMiss)} or {nameof(HitResult.IgnoreHit)} is expected.\n" + $"This has been automatically adjusted to {Result.Type}, and support will be removed from 2020-03-28 onwards.", level: LogLevel.Important); } } 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}])."); } // Ensure that the judgement is given a valid time offset, because this may not get set by the caller var endTime = HitObject.GetEndTime(); Result.TimeOffset = Math.Min(HitObject.HitWindows.WindowFor(HitResult.Miss), Time.Current - endTime); switch (Result.Type) { case HitResult.None: break; case HitResult.Miss: updateState(ArmedState.Miss); break; default: updateState(ArmedState.Hit); break; } 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 (Time.Elapsed < 0) return false; if (Judged) return false; var endTime = HitObject.GetEndTime(); CheckForResult(userTriggered, Time.Current - endTime); 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); protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); HitObject.DefaultsApplied -= onDefaultsApplied; } } public abstract class DrawableHitObject : DrawableHitObject where TObject : HitObject { public new readonly TObject HitObject; protected DrawableHitObject(TObject hitObject) : base(hitObject) { HitObject = hitObject; } } }