using UnityEngine; using RR.Common.Interfaces; using RR.Game2D.Core; using RR.Game2D.Behaviour; using RR.Game2D.Core.Interfaces; namespace RR.Game2D.Entities.Projectiles { /// /// Подбираемый снаряд с возможностью отскока, возврата к владельцу и взаимодействия с игровыми сущностями. /// [DisallowMultipleComponent] [RequireComponent(typeof(Rigidbody2D), typeof(CapsuleCollider2D))] public class PickableProjectile : PhysicsProjectile, IPickable, IBouncy, IRecallable { public static PickableProjectile Instance { get; private set; } public enum State { Held, Airborne, Grounded, Returning } public State CurrentState { get; private set; } = State.Grounded; [Header("Подбор и прикрепление")] [SerializeField] private Vector3 heldLocalOffset = new(0.25f, 0.1f, 0f); [SerializeField] private float heldScale = 1.5f; [SerializeField] private float ignoreOwnerTimeOnThrow = 0.1f; [Header("Приземление")] [SerializeField] private float groundVelocity = 1f; [SerializeField] private float maxGroundSlideTime = 1f; [SerializeField] private bool lockWhenLanded = true; [Header("Отскоки")] [SerializeField] private int maxBounces = 3; [SerializeField] private float minBounceSpeed = 0.8f; [SerializeField] private float stickDelayAfterImpact = 0.03f; [Header("Возврат")] [SerializeField, Range(0, 100)] int recallScreenMarginPercent = 5; [SerializeField] private float recallArriveDistance = 1f; [SerializeField] private float recallSpeed = 16f; [SerializeField] private float recallStartDistance = 8f; [Header("Gizmos")] [SerializeField] private bool drawGizmos; [SerializeField] private Color gizmoColor = new (0.2f, 0.8f, 1f, 0.35f); private Transform holderAttachPoint; private int bounceCounter; private float lastImpactTime = -999f; private readonly Vector3 baseLocalScale = Vector3.one; private bool hadBeenHeld; private GameObject lastKnownHolder; private float lastGroundTouchTime; private GameObject jumpPad; // ------------------------------------------- // Методы Unity // #region Unity Methods //--------------------------- // Singleton //--------------------------- /// /// Инициализация компонентов и создание вспомогательной прыжковой платформы (jumpPad). /// Реализуется как Singleton: если экземпляр уже существует — уничтожаем дубликат. /// protected override void Awake() { // Получение ссылок на компоненты Rigidbody2D и Collider rb = GetComponent(); col = GetComponent(); // Пока в Awake оставляем коллайдер в режиме триггера — это переставим в Start/другие состояния по необходимости col.isTrigger = true; // Простая реализация singleton-а — предотвращаем дублирование снаряда, если это ожидаемое поведение проекта if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; // Создаём вспомогательный объект "jumpPad" программно. Он используется для реакции игрока на то, что снаряд лежит на земле. // Создание через код даёт гибкость при отсутствии заранее подготовленного префаба. jumpPad = new GameObject("Projectile Jump Pad"); jumpPad.AddComponent(); jumpPad.AddComponent(); jumpPad.GetComponent().isTrigger = true; Vector3 jumpPadScale = jumpPad.transform.localScale; // Уменьшаем по высоте — платформа тонкая jumpPadScale.y = 0.1f; jumpPadScale.x = 2f; jumpPad.transform.localScale = jumpPadScale; jumpPad.SetActive(false); // Подписываемся на событие прыжка с платформы jumpPad.GetComponent().PlayerJumped += OnPlayerJumped; } /// /// Очистка при уничтожении — отписка и уничтожение вспомогательного объекта. /// private void OnDestroy() { if (Instance == this) Instance = null; Destroy(jumpPad); } //--------------------------- // //--------------------------- /// /// Устанавливаем параметры физики и первоначальное состояние снаряда. /// private void Start() { rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous; col.isTrigger = false; if (CurrentState != State.Held) BecomeGrounded(); } /// /// Фиксированный апдейт: обработка смены состояний, поворот в направлении полёта и инициирование возврата. /// protected override void FixedUpdate() { base.FixedUpdate(); // Если не нужно блокировать при приземлении — проверяем скорость и переводим в состояние 'Grounded' if (!lockWhenLanded && CurrentState == State.Airborne) { if (rb.linearVelocity.sqrMagnitude < (groundVelocity * groundVelocity)) BecomeGrounded(); } // В полёте и при возврате снаряд ориентируется по вектору скорости if ((CurrentState == State.Airborne || CurrentState == State.Returning) && rb.linearVelocity.sqrMagnitude > 0.0001f) transform.up = rb.linearVelocity.normalized; // Дополнительная страховка перехода в grounded по скорости if (CurrentState == State.Airborne && rb.linearVelocity.magnitude < groundVelocity) BecomeGrounded(); // Если снаряд когда-то был в руках — он может начать возвращаться автоматически if (hadBeenHeld && CurrentState != State.Held) Recall(); } /// /// Обработка столкновения. Пытаемся подобрать при касании игрока, наносим урон врагам, обрабатываем отскоки и приземление. /// /// Данные столкновения protected override void OnCollisionEnter2D(Collision2D collision) { // Нахождение игрового объекта, с которым мы столкнулись. Учтём разные варианты: rigidbody другого коллайдера или сам объект. var otherRb = collision.rigidbody ? collision.rigidbody : collision.otherRigidbody; var otherGO = (collision.collider ? collision.collider.attachedRigidbody?.gameObject : null) ?? otherRb?.gameObject ?? collision.gameObject; if (otherGO != null) { if (otherGO.CompareTag("Player") && CurrentState != State.Held) { // Попытка подобрать снаряд игроком if (TryPickup(otherGO)) return; } else if (otherGO.CompareTag("Enemy")) { // Наносим урон врагу через его контроллер, если он есть EnemyController enemyController = otherGO.GetComponent(); if (enemyController != null) { enemyController.TakeDamage(projectileDamage); } } } // Если слой не предназначен для нанесения урона/эффектов — выходим if (!IsInHitLayer(collision.collider.gameObject.layer)) return; // Точка контакта для эффектов Vector2 point = collision.GetContact(0).point; OnHit(collision, point, collision.relativeVelocity); // Логика отскока и приземления if (CurrentState == State.Airborne) { if (TryBounce(collision)) { lastImpactTime = Time.time; return; } BecomeGrounded(); } if (collision.collider.CompareTag("Ground")) lastGroundTouchTime = Time.time; } /// /// Контролирует длительность скольжения по земле — если прошло слишком много времени, переводим в grounded. /// /// Данные столкновения private void OnCollisionStay2D(Collision2D collision) { if (collision.collider.CompareTag("Ground")) { if (Time.time - lastGroundTouchTime > maxGroundSlideTime && CurrentState != State.Returning) BecomeGrounded(); } } /// /// Обработка триггерного входа: подбор игроком, нанесение урона врагам или делегирование базовому методу. /// /// Коллайдер, вошедший в триггер protected override void OnTriggerEnter2D(Collider2D other) { if (other.CompareTag("Player")) { if (TryPickup(other.gameObject)) return; } else if (other.CompareTag("Enemy")) { other.TryGetComponent(out var controller); controller.TakeDamage(projectileDamage); } else { base.OnTriggerEnter2D(other); } } // // // ------------------------------------------- #endregion // ------------------------------------------- // Переопределенные методы родителей // #region Parent Methods /// /// Обработка попадания при столкновении (вызывается из OnCollisionEnter2D). /// По-умолчанию проигрывает эффекты и вызывает финальную обработку удара. /// protected override void OnHit(Collision2D collision, Vector2 point, Vector2 relativeVelocity) { //TryDealDamage(collision.collider, point, relativeVelocity); PlayFX(point); OnImpactFinal(collision); } /// /// Финальная обработка удара при наличии Collision2D. /// Пытаемся отскочить; если отскок невозможен — считаем, что снаряд приземлился. /// /// Данные столкнов��ния protected override void OnImpactFinal(Collision2D collision) { if (!TryBounce(collision) && CurrentState == State.Airborne) BecomeGrounded(); } /// /// Финальная обработка удара при наличии Collider2D (например при триггерах). /// Если прошло достаточно времени с последнего удара — приземляемся. /// /// Коллайдер protected override void OnImpactFinal(Collider2D other) { if (Time.time - lastImpactTime > stickDelayAfterImpact && CurrentState == State.Airborne) BecomeGrounded(); } #endregion // // // ------------------------------------------- // ------------------------------------------- // Подбор // #region Pickup /// /// Попытка подобрать снаряд указанным владельцем. /// Если снаряд уже держат — возвращает true. /// /// Игровой объект, пытающийся подобрать снаряд /// Опциональная точка привязки /// True — если успешно подобран или уже в руках, иначе false public bool TryPickup(GameObject newOwner, Transform optionalAttachPoint = null) { if (CurrentState == State.Held) return true; owner = newOwner; lastKnownHolder = newOwner; holderAttachPoint = optionalAttachPoint ?? FindAttachPoint(newOwner); var fireScript = newOwner.GetComponent(); if (!holderAttachPoint && fireScript != null) holderAttachPoint = fireScript.attachPoint; if (!holderAttachPoint) holderAttachPoint = newOwner.transform; // Отключаем физическое поведение и делаем снаряд кинематическим, чтобы он "встал" в руки игрока rb.linearVelocity = Vector2.zero; rb.angularVelocity = 0f; rb.bodyType = RigidbodyType2D.Kinematic; col.enabled = false; transform.SetParent(holderAttachPoint); // Устанавливаем позицию и вращение относительно точки привязки. transform.SetPositionAndRotation(holderAttachPoint.TransformPoint(heldLocalOffset), new Quaternion(0, 90f, 0, 0)); transform.localScale = baseLocalScale * heldScale; if (fireScript != null) fireScript.BindProjectile(this); //StartCoroutine(IgnoreOwnerTemporarily(owner, ignoreOwnerTimeOnThrow)); CurrentState = State.Held; launched = false; hadBeenHeld = true; // При подборе убираем jumpPad, чтобы игрок не случайно "прыгнул" с него jumpPad.SetActive(false); return true; } /// /// Поиск точки привязки на holder (игроке). /// Возвращает preferred если задано, иначе ищет в PlayerFireScript, иначе возвращает трансформ самого объекта. /// /// Игрок / владелец /// Предпочтительная точка привязки /// Transform точки привязки private Transform FindAttachPoint(GameObject holder, Transform preferred = null) { if (preferred) return preferred; PlayerFireScript fireScript = holder.GetComponent(); if (fireScript && fireScript.attachPoint) return fireScript.attachPoint; return holder.transform; } #endregion // // // ------------------------------------------- // ------------------------------------------- // Отскок // #region Bounce /// /// Попытка отскока при столкновении. Возвращает true, если отскок выполнен. /// Здесь используется отражение вектора скорости относительно нормали контакта и коэффициент упругости из материала ко��лайдера. /// /// Данные столкновения /// True — если был отскок public bool TryBounce(Collision2D collision) { // Короткий таймаут между прыжками, чтобы избежать слишком частых "залипаний" на отскоках if (Time.time - lastImpactTime < stickDelayAfterImpact) return true; if (bounceCounter > maxBounces) return false; Vector2 velocityIn = rb.linearVelocity; float speed = velocityIn.magnitude; if (speed < minBounceSpeed) return false; // Нормаль первого контакта Vector2 direction = Vector2.Reflect(velocityIn, collision.contacts[0].normal); // Здесь используется bounciness физического материала коллайдера rb.linearVelocity = col.sharedMaterial.bounciness * direction; if (collision.collider.CompareTag("Ground") || collision.collider.CompareTag("Trap")) bounceCounter++; return true; } #endregion // // // ------------------------------------------- // ------------------------------------------- // Запуск // #region Launch /// /// Запуск снаряда: делаем его динамическим, добавляем импульс, настраиваем параметры состояния. /// /// Направление запуска (в мировых координатах) /// Скорость — если значение меньше либо равно нуля, используется initialSpeed /// Игрок, который бросает снаряд (владелец) public override void Launch(Vector2 direction, float speed = -1, GameObject thrower = null) { if (CurrentState == State.Held) transform.SetParent(null); owner = thrower; lastKnownHolder = thrower; holderAttachPoint = null; rb.bodyType = RigidbodyType2D.Dynamic; col.enabled = true; col.isTrigger = false; launched = true; Vector2 launchDirection = direction.sqrMagnitude > 0.0001f ? direction.normalized : Vector2.right; float finalSpeed = (speed > 0f) ? speed : initialSpeed; rb.linearVelocity = Vector2.zero; rb.angularVelocity = 0f; rb.AddForce(finalSpeed * rb.mass * launchDirection, ForceMode2D.Impulse); // Ориентируем снаряд по направлению скорости transform.up = launchDirection; transform.localScale = baseLocalScale; if (thrower != null && ignoreOwnerTimeOnThrow > 0f) StartCoroutine(IgnoreOwnerTemporarily(thrower, ignoreOwnerTimeOnThrow)); CurrentState = State.Airborne; bounceCounter = 0; } #endregion // // // ------------------------------------------- // ------------------------------------------- // Возвращение // #region Recall /// /// Логика возврата снаряда к последнему известному владельцу. Если снаряд найден вне экрана — переключается в режим Returning и летит к владельцу. /// public void Recall() { GameObject playerGo = lastKnownHolder; // Если нет последнего известного владельца — пытаемся получить игрока из GameManager или найти по тегу Player if (playerGo == null || !playerGo.activeInHierarchy) playerGo = (GameManager.Instance && GameManager.Instance.PlayerObj) ? GameManager.Instance.PlayerObj : GameObject.FindWithTag("Player"); if (!playerGo) return; var attachPoint = FindAttachPoint(playerGo); bool isVisible; var mainCamera = Camera.main; float margin = recallScreenMarginPercent / 100f; if (mainCamera) { // Если камера есть — проверяем, видим ли снаряд в пределах экрана (+margin) Vector3 viewPoint = mainCamera.WorldToViewportPoint(transform.position); isVisible = viewPoint.x >= -margin && viewPoint.x <= 1f + margin && viewPoint.y >= -margin && viewPoint.y <= 1f + margin; } else { // Если камеры нет — проверяем дистанцию до владельца как запасной критерий видимости Vector2 toOwnerTest = (Vector2)attachPoint.position - rb.position; isVisible = (toOwnerTest.magnitude <= recallStartDistance); } Vector2 toOwner = (Vector2)attachPoint.position - rb.position; float dist = toOwner.magnitude; if (CurrentState != State.Returning) { if (!isVisible) { // Переходим в режим возврата: включаем физику и делаем триггер, чтобы не мешать столкновениям CurrentState = State.Returning; rb.bodyType = RigidbodyType2D.Dynamic; col.isTrigger = true; } else { // Если видим снаряд — не начинаем возврат return; } } Vector2 dir = (dist > 0.001f) ? (toOwner / dist) : rb.linearVelocity.normalized; rb.linearVelocity = dir * recallSpeed; if (rb.linearVelocity.sqrMagnitude > 0.01f) transform.up = rb.linearVelocity.normalized; // Если достаточно близко — автоматически подбираем if (dist <= recallArriveDistance) TryPickup(playerGo); } #endregion // // // ------------------------------------------- // ------------------------------------------- // Собственные незаисимые методы // #region Self methods /// /// Переводит снаряд в состояние "на земле" (Grounded): блокирует или оставляет динамическим, /// включает/выключает триггер и активирует jumpPad — платформу, с которой игрок может прыгнуть. /// private void BecomeGrounded() { launched = false; CurrentState = State.Grounded; if (lockWhenLanded) { // Полностью останавливаем и делаем кинематическим rb.linearVelocity = Vector2.zero; rb.angularVelocity = 0f; rb.bodyType = RigidbodyType2D.Kinematic; } else { rb.bodyType = RigidbodyType2D.Dynamic; } col.enabled = true; col.isTrigger = true; // Отвязываем от родителя transform.SetParent(null, true); bounceCounter = 0; MoveJumpPad(); jumpPad.SetActive(true); } /// /// Расчёт позиции и перемещение вспомогательной прыжковой платформы относительно снаряда. /// Здесь используется приближённая геометрия, чтобы корректно позиционировать платформу даже при повороте снаряда. /// private void MoveJumpPad() { // Размеры коллайдера используются для определения смещения платформы float sizeX = col.bounds.size.y; float sizeY = col.bounds.size.x; Vector3 jumpPadPos = transform.position; // Прыжковая платформа размещается выше снаряда с учётом его поворота. // Формула — приближённая (без учёта всех трансформаций), но даёт корректный результат в большинстве случаев. float jumpPadY = Mathf.Sqrt(sizeX * sizeX * Mathf.Pow(Mathf.Cos(transform.localEulerAngles.z), 2) + sizeY * sizeY * Mathf.Pow(Mathf.Sin(transform.localEulerAngles.z), 2)); jumpPadPos.y = jumpPadPos.y + 0.5f * jumpPadY; jumpPad.transform.position = jumpPadPos; } /// /// Обработчик события прыжка с jumpPad — пытаемся подобрать снаряд игроком, которы�� прыгнул. /// /// Игрок, совершивший прыжок private void OnPlayerJumped(GameObject playerObj) { TryPickup(playerObj); } #endregion // // // ------------------------------------------- } }