using System; using System.Threading; using Cysharp.Threading.Tasks; using DanroJump.Audio; using UnityEngine; namespace DanroJump.Gameplay { /// /// Пружинная площадка, которая усиливает прыжок игрока и проигрывает squash/stretch-анимацию. /// [DisallowMultipleComponent] [RequireComponent(typeof(Collider2D))] public sealed class JumpPad : PlatformEquipmentBase, ILandingEquipment { [SerializeField] [Min(1f)] private float jumpHeightMultiplier = 3f; [SerializeField] private float landingVelocityThreshold = 0.05f; [SerializeField] private float landingTopTolerance = 0.2f; [SerializeField] private float activationCooldown = 0.08f; [SerializeField] private Transform visualRoot; [SerializeField] private string visualRootName = "Visual"; [SerializeField] private Vector2 stretchScale = new(0.9f, 1.35f); [SerializeField] [Min(0f)] private float stretchOutDuration = 0.06f; [SerializeField] [Min(0f)] private float recoverDuration = 0.14f; private Collider2D jumpPadCollider; private IGameAudioService audioService; private CancellationTokenSource stretchCancellation; private Vector3 visualBaseScale = Vector3.one; private float lastActivationTime = -1f; private bool hasVisualBaseScale; /// /// Во сколько раз пружина усиливает стандартный прыжок игрока. /// public float JumpHeightMultiplier => jumpHeightMultiplier; [Zenject.Inject] public void Construct([Zenject.InjectOptional] IGameAudioService injectedAudioService = null) { audioService = injectedAudioService; } /// /// Подготавливает trigger-коллайдер и визуальную часть пружины. /// private void Awake() { EnsureTriggerCollider(); ResolveVisualRoot(); CacheVisualBaseScale(); } /// /// Проверяет контакт при первом входе игрока в trigger. /// private void OnTriggerEnter2D(Collider2D other) { TryHandlePlayerContact(other); } /// /// Повторно проверяет контакт, если игрок уже находится внутри trigger. /// private void OnTriggerStay2D(Collider2D other) { TryHandlePlayerContact(other); } /// /// Преобразует Collider2D в игрока и передает контакт в общий handler. /// private void TryHandlePlayerContact(Collider2D other) { if (other.TryGetComponent(out JumpPlayerController player) && !player.IgnoresEnvironmentInteractions) { HandlePlayerContact(player, this); } } /// /// Сбрасывает cooldown, коллайдер и визуальный scale при переиспользовании. /// public override void ResetEquipment() { lastActivationTime = -1f; EnsureTriggerCollider(); ResolveVisualRoot(); CacheVisualBaseScale(); ResetVisualScale(); } /// /// Пробует активировать пружину от контакта игрока. /// public override void HandlePlayerContact(JumpPlayerController player, Component source) { TryActivate(player); } /// /// Активирует усиленный прыжок, если игрок действительно приземляется сверху. /// /// True, если пружина сработала. public bool TryActivate(JumpPlayerController player) { if (!CanActivate(player)) { return false; } NotifyPlatformLandingHandlers(player); audioService?.Play(GameAudioEvent.JumpPadActivated, transform.position); player.BounceWithEnemyPassThrough(CalculateBoostVelocity(player)); lastActivationTime = Time.time; PlayStretchAnimation(); return true; } public bool TryActivateFromLanding(JumpPlayerController player) { return TryActivate(player); } /// /// Проверяет направление движения, cooldown и высоту GroundCheck относительно верха пружины. /// private bool CanActivate(JumpPlayerController player) { if (player == null || player.IgnoresEnvironmentInteractions || Time.time - lastActivationTime < activationCooldown) { return false; } var isLanding = player.VerticalVelocity <= landingVelocityThreshold || player.PreviousVerticalVelocity <= landingVelocityThreshold; if (!isLanding) { return false; } if (jumpPadCollider == null) { EnsureTriggerCollider(); } if (jumpPadCollider == null) { return false; } var landingLineY = jumpPadCollider.bounds.max.y - landingTopTolerance; return player.GroundCheckY >= landingLineY || player.PreviousGroundCheckY >= landingLineY; } /// /// Переводит множитель высоты в скорость прыжка через квадратный корень. /// private float CalculateBoostVelocity(JumpPlayerController player) { return player.AutoJumpVelocity * Mathf.Sqrt(jumpHeightMultiplier); } /// /// Гарантирует, что коллайдер пружины работает как trigger. /// private void EnsureTriggerCollider() { jumpPadCollider = GetComponent(); if (jumpPadCollider != null) { jumpPadCollider.isTrigger = true; } } /// /// Находит визуальный корень пружины для stretch-анимации. /// private void ResolveVisualRoot() { if (visualRoot != null) { return; } visualRoot = transform.Find(visualRootName); if (visualRoot == null && TryGetComponent(out SpriteRenderer spriteRenderer)) { visualRoot = spriteRenderer.transform; } } /// /// Запоминает исходный scale визуала, чтобы корректно восстановить его после анимации. /// private void CacheVisualBaseScale() { if (visualRoot == null || hasVisualBaseScale) { return; } visualBaseScale = visualRoot.localScale; hasVisualBaseScale = true; } /// /// Запускает короткую анимацию растяжения пружины. /// private void PlayStretchAnimation() { ResolveVisualRoot(); CacheVisualBaseScale(); if (visualRoot == null) { return; } CancelStretchAnimation(); stretchCancellation = new CancellationTokenSource(); StretchVisualAsync(stretchCancellation).Forget(Debug.LogException); } /// /// Останавливает активную stretch-анимацию и возвращает базовый scale. /// private void ResetVisualScale() { CancelStretchAnimation(); if (visualRoot != null && hasVisualBaseScale) { visualRoot.localScale = visualBaseScale; } } private void OnDestroy() { CancelStretchAnimation(); } private void CancelStretchAnimation() { if (stretchCancellation == null) { return; } try { stretchCancellation.Cancel(); } catch { // ignored } stretchCancellation.Dispose(); stretchCancellation = null; } /// /// Последовательно растягивает визуал и возвращает его к исходному размеру. /// private async UniTask StretchVisualAsync(CancellationTokenSource cancellationSource) { var stretchedScale = new Vector3( visualBaseScale.x * stretchScale.x, visualBaseScale.y * stretchScale.y, visualBaseScale.z); var token = cancellationSource.Token; try { await AnimateVisualScaleAsync(visualBaseScale, stretchedScale, stretchOutDuration, token); await AnimateVisualScaleAsync(stretchedScale, visualBaseScale, recoverDuration, token); if (!token.IsCancellationRequested && visualRoot != null) { visualRoot.localScale = visualBaseScale; } } catch (OperationCanceledException) when (token.IsCancellationRequested) { // ignored } finally { if (stretchCancellation == cancellationSource) { stretchCancellation = null; cancellationSource.Dispose(); } } } /// /// Интерполирует scale визуального корня за указанное время. /// private async UniTask AnimateVisualScaleAsync(Vector3 from, Vector3 to, float duration, CancellationToken cancellationToken) { if (duration <= 0f) { if (visualRoot != null) { visualRoot.localScale = to; } return; } var elapsed = 0f; while (elapsed < duration) { cancellationToken.ThrowIfCancellationRequested(); if (visualRoot == null) { return; } elapsed += Time.deltaTime; var t = Mathf.Clamp01(elapsed / duration); var easedT = 1f - (1f - t) * (1f - t); visualRoot.localScale = Vector3.LerpUnclamped(from, to, easedT); await UniTask.Yield(PlayerLoopTiming.Update, cancellationToken); } if (visualRoot != null) { visualRoot.localScale = to; } } } }