Danro/Assets/Scripts/Behaviour/Projectiles/PickableProjectile.cs
Danil Iarosh 5bafc385da feat(audio): increase SFX pool size and preload audio data
- Increased SFX pool size from 8 to 16 in AudioManager
- Enabled preloadAudioData for multiple SFX files
- Removed unused VisualScripting import from Checkpoint script
- Reordered using statements in several scripts for consistency
- Added CoinCollectible component to Coin prefab with pickup effects
- Updated sorting layers and mesh LOD settings in Coin prefabs
- Fixed typo in Interfaces.cs: optionaAttachPoint to optionalAttachPoint
- Modified AddCoin method to accept count parameter and handle coin overflow
- Moved projectile scripts (BasicProjectile, EnemyBasicProjectile, PhysicsProjectile, PickableProjectile)
- Cleaned up unused namespaces and imports across various scripts
2025-10-30 16:39:15 +03:00

614 lines
27 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using UnityEngine;
using RR.Common.Interfaces;
using RR.Game2D.Core.Managers;
using RR.Game2D.Behaviour;
using RR.Game2D.Core.Managers.Interfaces;
namespace RR.Game2D.Entities.Projectiles
{
/// <summary>
/// Подбираемый снаряд с возможностью отскока, возврата к владельцу и взаимодействия с игровыми сущностями.
/// </summary>
[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
//---------------------------
/// <summary>
/// Инициализация компонентов и создание вспомогательной прыжковой платформы (jumpPad).
/// Реализуется как Singleton: если экземпляр уже существует — уничтожаем дубликат.
/// </summary>
protected override void Awake()
{
// Получение ссылок на компоненты Rigidbody2D и Collider
_rb = GetComponent<Rigidbody2D>();
_col = GetComponent<CapsuleCollider2D>();
// Пока в 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<BoxCollider2D>();
jumpPad.AddComponent<JumpPad2D>();
jumpPad.GetComponent<BoxCollider2D>().isTrigger = true;
Vector3 jumpPadScale = jumpPad.transform.localScale;
// Уменьшаем по высоте — платформа тонкая
jumpPadScale.y = 0.1f;
jumpPadScale.x = 2f;
jumpPad.transform.localScale = jumpPadScale;
jumpPad.SetActive(false);
// Подписываемся на событие прыжка с платформы
jumpPad.GetComponent<JumpPad2D>().PlayerJumped += OnPlayerJumped;
}
/// <summary>
/// Очистка при уничтожении — отписка и уничтожение вспомогательного объекта.
/// </summary>
private void OnDestroy()
{
if (Instance == this) Instance = null;
Destroy(jumpPad);
}
//---------------------------
//
//---------------------------
/// <summary>
/// Устанавливаем параметры физики и первоначальное состояние снаряда.
/// </summary>
private void Start()
{
_rb.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
_col.isTrigger = false;
if (CurrentState != State.Held)
BecomeGrounded();
}
/// <summary>
/// Фиксированный апдейт: обработка смены состояний, поворот в направлении полёта и инициирование возврата.
/// </summary>
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();
}
/// <summary>
/// Обработка столкновения. Пытаемся подобрать при касании игрока, наносим урон врагам, обрабатываем отскоки и приземление.
/// </summary>
/// <param name="collision">Данные столкновения</param>
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<EnemyController>();
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;
}
/// <summary>
/// Контролирует длительность скольжения по земле — если прошло слишком много времени, переводим в grounded.
/// </summary>
/// <param name="collision">Данные столкновения</param>
private void OnCollisionStay2D(Collision2D collision)
{
if (collision.collider.CompareTag("Ground"))
{
if (Time.time - lastGroundTouchTime > maxGroundSlideTime && CurrentState != State.Returning)
BecomeGrounded();
}
}
/// <summary>
/// Обработка триггерного входа: подбор игроком, нанесение урона врагам или делегирование базовому методу.
/// </summary>
/// <param name="other">Коллайдер, вошедший в триггер</param>
protected override void OnTriggerEnter2D(Collider2D other)
{
if (other.CompareTag("Player"))
{
if (TryPickup(other.gameObject)) return;
}
else if (other.CompareTag("Enemy"))
{
other.TryGetComponent<EnemyController>(out var controller);
controller.TakeDamage(projectileDamage);
}
else
{
base.OnTriggerEnter2D(other);
}
}
//
//
// -------------------------------------------
#endregion
// -------------------------------------------
// Переопределенные методы родителей
//
#region Parent Methods
/// <summary>
/// Обработка попадания при столкновении (вызывается из OnCollisionEnter2D).
/// По-умолчанию проигрывает эффекты и вызывает финальную обработку удара.
/// </summary>
protected override void OnHit(Collision2D collision, Vector2 point, Vector2 relativeVelocity)
{
//TryDealDamage(collision.collider, point, relativeVelocity);
PlayFX(point);
OnImpactFinal(collision);
}
/// <summary>
/// Финальная обработка удара при наличии Collision2D.
/// Пытаемся отскочить; если отскок невозможен — считаем, что снаряд приземлился.
/// </summary>
/// <param name="collision">Данные столкнов<D0BE><D0B2>ния</param>
protected override void OnImpactFinal(Collision2D collision)
{
if (!TryBounce(collision) && CurrentState == State.Airborne)
BecomeGrounded();
}
/// <summary>
/// Финальная обработка удара при наличии Collider2D (например при триггерах).
/// Если прошло достаточно времени с последнего удара — приземляемся.
/// </summary>
/// <param name="other">Коллайдер</param>
protected override void OnImpactFinal(Collider2D other)
{
if (Time.time - lastImpactTime > stickDelayAfterImpact && CurrentState == State.Airborne)
BecomeGrounded();
}
#endregion
//
//
// -------------------------------------------
// -------------------------------------------
// Подбор
//
#region Pickup
/// <summary>
/// Попытка подобрать снаряд указанным владельцем.
/// Если снаряд уже держат — возвращает true.
/// </summary>
/// <param name="newOwner">Игровой объект, пытающийся подобрать снаряд</param>
/// <param name="optionalAttachPoint">Опциональная точка привязки</param>
/// <returns>True — если успешно подобран или уже в руках, иначе false</returns>
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<PlayerFireScript>();
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;
}
/// <summary>
/// Поиск точки привязки на holder (игроке).
/// Возвращает preferred если задано, иначе ищет в PlayerFireScript, иначе возвращает трансформ самого объекта.
/// </summary>
/// <param name="holder">Игрок / владелец</param>
/// <param name="preferred">Предпочтительная точка привязки</param>
/// <returns>Transform точки привязки</returns>
private Transform FindAttachPoint(GameObject holder, Transform preferred = null)
{
if (preferred) return preferred;
PlayerFireScript fireScript = holder.GetComponent<PlayerFireScript>();
if (fireScript && fireScript.attachPoint) return fireScript.attachPoint;
return holder.transform;
}
#endregion
//
//
// -------------------------------------------
// -------------------------------------------
// Отскок
//
#region Bounce
/// <summary>
/// Попытка отскока при столкновении. Возвращает true, если отскок выполнен.
/// Здесь используется отражение вектора скорости относительно нормали контакта и коэффициент упругости из материала ко<D0BA><D0BE>лайдера.
/// </summary>
/// <param name="collision">Данные столкновения</param>
/// <returns>True — если был отскок</returns>
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
/// <summary>
/// Запуск снаряда: делаем его динамическим, добавляем импульс, настраиваем параметры состояния.
/// </summary>
/// <param name="direction">Направление запуска (в мировых координатах)</param>
/// <param name="speed">Скорость — если значение меньше либо равно нуля, используется initialSpeed</param>
/// <param name="thrower">Игрок, который бросает снаряд (владелец)</param>
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
/// <summary>
/// Логика возврата снаряда к последнему известному владельцу. Если снаряд найден вне экрана — переключается в режим Returning и летит к владельцу.
/// </summary>
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
/// <summary>
/// Переводит снаряд в состояние "на земле" (Grounded): блокирует или оставляет динамическим,
/// включает/выключает триггер и активирует jumpPad — платформу, с которой игрок может прыгнуть.
/// </summary>
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);
}
/// <summary>
/// Расчёт позиции и перемещение вспомогательной прыжковой платформы относительно снаряда.
/// Здесь используется приближённая геометрия, чтобы корректно позиционировать платформу даже при повороте снаряда.
/// </summary>
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;
}
/// <summary>
/// Обработчик события прыжка с jumpPad — пытаемся подобрать снаряд игроком, которы<D180><D18B> прыгнул.
/// </summary>
/// <param name="playerObj">Игрок, совершивший прыжок</param>
private void OnPlayerJumped(GameObject playerObj)
{
TryPickup(playerObj);
}
#endregion
//
//
// -------------------------------------------
}
}