// 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.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; using osu.Game.Graphics.Containers; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Osu.Skinning; using osu.Game.Rulesets.Osu.Skinning.Default; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Scoring; using osu.Game.Skinning; using osuTK; using osuTK.Graphics; namespace osu.Game.Rulesets.Osu.Objects.Drawables { public partial class DrawableHitCircle : DrawableOsuHitObject, IHasApproachCircle { public OsuAction? HitAction => HitArea.HitAction; protected virtual OsuSkinComponents CirclePieceComponent => OsuSkinComponents.HitCircle; public SkinnableDrawable ApproachCircle { get; private set; } = null!; public HitReceptor HitArea { get; private set; } = null!; public SkinnableDrawable CirclePiece { get; private set; } = null!; protected override IEnumerable DimmablePieces => new[] { CirclePiece }; Drawable IHasApproachCircle.ApproachCircle => ApproachCircle; private Container scaleContainer = null!; private ShakeContainer shakeContainer = null!; public DrawableHitCircle() : this(null) { } public DrawableHitCircle(HitCircle? h = null) : base(h) { } [BackgroundDependencyLoader] private void load() { Origin = Anchor.Centre; AddRangeInternal(new Drawable[] { scaleContainer = new Container { RelativeSizeAxes = Axes.Both, Origin = Anchor.Centre, Anchor = Anchor.Centre, Children = new Drawable[] { HitArea = new HitReceptor { CanBeHit = () => !AllJudged, Hit = () => UpdateResult(true) }, shakeContainer = new ShakeContainer { ShakeDuration = 30, RelativeSizeAxes = Axes.Both, Children = new Drawable[] { CirclePiece = new SkinnableDrawable(new OsuSkinComponentLookup(CirclePieceComponent), _ => new MainCirclePiece()) { Anchor = Anchor.Centre, Origin = Anchor.Centre, }, ApproachCircle = new ProxyableSkinnableDrawable(new OsuSkinComponentLookup(OsuSkinComponents.ApproachCircle), _ => new DefaultApproachCircle()) { Anchor = Anchor.Centre, Origin = Anchor.Centre, RelativeSizeAxes = Axes.Both, Alpha = 0, Scale = new Vector2(4), } } } } }, }); Size = HitArea.DrawSize; PositionBindable.BindValueChanged(_ => UpdatePosition()); StackHeightBindable.BindValueChanged(_ => UpdatePosition()); ScaleBindable.BindValueChanged(scale => scaleContainer.Scale = new Vector2(scale.NewValue)); } public override double LifetimeStart { get => base.LifetimeStart; set { base.LifetimeStart = value; ApproachCircle.LifetimeStart = value; } } public override double LifetimeEnd { get => base.LifetimeEnd; set { base.LifetimeEnd = value; ApproachCircle.LifetimeEnd = value; } } protected virtual void UpdatePosition() { Position = HitObject.StackedPosition; } public override void Shake() => shakeContainer.Shake(); protected override void CheckForResult(bool userTriggered, double timeOffset) { Debug.Assert(HitObject.HitWindows != null); if (!userTriggered) { if (!HitObject.HitWindows.CanBeHit(timeOffset)) { ApplyResult((r, position) => { var circleResult = (OsuHitCircleJudgementResult)r; circleResult.Type = r.Judgement.MinResult; circleResult.CursorPositionAtHit = position; }, computeHitPosition()); } return; } var result = ResultFor(timeOffset); var clickAction = CheckHittable?.Invoke(this, Time.Current, result); if (clickAction == ClickAction.Shake) Shake(); if (result == HitResult.None || clickAction != ClickAction.Hit) return; ApplyResult<(HitResult result, Vector2? position)>((r, state) => { var circleResult = (OsuHitCircleJudgementResult)r; circleResult.Type = state.result; circleResult.CursorPositionAtHit = state.position; }, (result, computeHitPosition())); } private Vector2? computeHitPosition() { if (HitArea.ClosestPressPosition is Vector2 screenSpaceHitPosition) return HitObject.StackedPosition + (ToLocalSpace(screenSpaceHitPosition) - DrawSize / 2); return null; } /// /// Retrieves the for a time offset. /// /// The time offset. /// The hit result, or if doesn't result in a judgement. protected virtual HitResult ResultFor(double timeOffset) => HitObject.HitWindows.ResultFor(timeOffset); protected override void UpdateInitialTransforms() { base.UpdateInitialTransforms(); CirclePiece.FadeInFromZero(HitObject.TimeFadeIn); ApproachCircle.FadeTo(0.9f, Math.Min(HitObject.TimeFadeIn * 2, HitObject.TimePreempt)); ApproachCircle.ScaleTo(1f, HitObject.TimePreempt); ApproachCircle.Expire(true); } protected override void UpdateStartTimeStateTransforms() { base.UpdateStartTimeStateTransforms(); // always fade out at the circle's start time (to match user expectations). ApproachCircle.FadeOut(50); } protected override void UpdateHitStateTransforms(ArmedState state) { Debug.Assert(HitObject.HitWindows != null); // todo: temporary / arbitrary, used for lifetime optimisation. this.Delay(800).FadeOut(); switch (state) { default: ApproachCircle.FadeOut(); break; case ArmedState.Idle: HitArea.Reset(); break; case ArmedState.Miss: this.FadeOut(100); break; } Expire(); } public Drawable ProxiedLayer => ApproachCircle; protected override JudgementResult CreateResult(Judgement judgement) => new OsuHitCircleJudgementResult(HitObject, judgement); public partial class HitReceptor : CompositeDrawable, IKeyBindingHandler { // IsHovered is used public override bool HandlePositionalInput => true; /// /// Whether the hitobject can still be hit at the current point in time. /// public required Func CanBeHit { get; set; } /// /// An action that's invoked to perform the hit. /// public required Action Hit { get; set; } /// /// The with which the hit was attempted. /// public OsuAction? HitAction { get; private set; } /// /// The closest position to the hit receptor at the point where the hit was attempted. /// public Vector2? ClosestPressPosition { get; private set; } public HitReceptor() { Size = OsuHitObject.OBJECT_DIMENSIONS; Anchor = Anchor.Centre; Origin = Anchor.Centre; CornerRadius = OsuHitObject.OBJECT_RADIUS; CornerExponent = 2; } public bool OnPressed(KeyBindingPressEvent e) { if (!CanBeHit()) return false; switch (e.Action) { case OsuAction.LeftButton: case OsuAction.RightButton: if (ClosestPressPosition is Vector2 curClosest) { float oldDist = Vector2.DistanceSquared(curClosest, ScreenSpaceDrawQuad.Centre); float newDist = Vector2.DistanceSquared(e.ScreenSpaceMousePosition, ScreenSpaceDrawQuad.Centre); if (newDist < oldDist) ClosestPressPosition = e.ScreenSpaceMousePosition; } else ClosestPressPosition = e.ScreenSpaceMousePosition; if (IsHovered) { Hit(); HitAction ??= e.Action; return true; } break; } return false; } public void OnReleased(KeyBindingReleaseEvent e) { } /// /// Resets to a fresh state. /// public void Reset() { HitAction = null; ClosestPressPosition = null; } } private partial class ProxyableSkinnableDrawable : SkinnableDrawable { public override bool RemoveWhenNotAlive => false; public ProxyableSkinnableDrawable(ISkinComponentLookup lookup, Func? defaultImplementation = null, ConfineMode confineMode = ConfineMode.NoScaling) : base(lookup, defaultImplementation, confineMode) { } } #region FOR EDITOR USE ONLY, DO NOT USE FOR ANY OTHER PURPOSE internal void SuppressHitAnimations() { UpdateState(ArmedState.Idle, true); UpdateComboColour(); // This method is called every frame. If we need to, the following can likely be converted // to code which doesn't use transforms at all. // Matches stable (see https://github.com/peppy/osu-stable-reference/blob/bb57924c1552adbed11ee3d96cdcde47cf96f2b6/osu!/GameplayElements/HitObjects/Osu/HitCircleOsu.cs#L336-L338) using (BeginAbsoluteSequence(StateUpdateTime - 5)) this.TransformBindableTo(AccentColour, Color4.White, Math.Max(0, HitStateUpdateTime - StateUpdateTime)); using (BeginAbsoluteSequence(HitStateUpdateTime)) this.FadeOut(700).Expire(); } internal void RestoreHitAnimations() { UpdateState(ArmedState.Hit, force: true); UpdateComboColour(); } #endregion } }