375 lines
12 KiB
C#
375 lines
12 KiB
C#
using UnityEngine;
|
|
|
|
namespace Streamingle
|
|
{
|
|
/// <summary>
|
|
/// Component attached to throwable objects.
|
|
/// Uses parabolic interpolation for smooth arc trajectory.
|
|
/// On reaching target, enables collision detection - bounces off colliders or falls.
|
|
/// </summary>
|
|
public class ThrowableObject : MonoBehaviour
|
|
{
|
|
[HideInInspector]
|
|
public ThrowableObjectLauncher launcher;
|
|
|
|
[Header("Settings")]
|
|
[Tooltip("Play sound on hit")]
|
|
public AudioClip[] hitSounds;
|
|
|
|
[Tooltip("Spawn particle effect on hit")]
|
|
public GameObject hitEffectPrefab;
|
|
|
|
[Tooltip("Destroy hit effect after this time")]
|
|
public float hitEffectLifetime = 2f;
|
|
|
|
[Header("Trajectory Settings")]
|
|
[Tooltip("Time to reach target in seconds")]
|
|
public float flightDuration = 1.5f;
|
|
|
|
[Tooltip("Arc height (higher = more curved trajectory)")]
|
|
public float arcHeight = 2f;
|
|
|
|
[Tooltip("Rotation speed while flying")]
|
|
public float rotationSpeed = 180f;
|
|
|
|
[Header("Collision Settings")]
|
|
[Tooltip("Bounce force multiplier when hitting collider")]
|
|
public float bounceForce = 5f;
|
|
|
|
[Tooltip("Additional upward force on bounce")]
|
|
public float bounceUpForce = 2f;
|
|
|
|
[Tooltip("Time to stay after reaching target before deactivating")]
|
|
public float postArrivalLifetime = 3f;
|
|
|
|
[Tooltip("Time spent shrinking out before disappearing (0 = pop instantly)")]
|
|
public float shrinkDuration = 0.4f;
|
|
|
|
// Trajectory state
|
|
private Vector3 startPosition;
|
|
private Vector3 targetPosition;
|
|
private Transform targetTransform;
|
|
private float flightTime;
|
|
private float currentTime;
|
|
private bool isFlying = false;
|
|
private bool hasArrived = false;
|
|
private bool hasCollided = false;
|
|
private bool hasPlayedHitFeedback = false;
|
|
private Vector3 rotationAxis;
|
|
private Vector3 arrivalVelocity;
|
|
|
|
// Components
|
|
private Rigidbody rb;
|
|
private AudioSource audioSource;
|
|
private Collider[] colliders;
|
|
|
|
// Lifetime
|
|
private float lifetime;
|
|
private float spawnTime;
|
|
|
|
// Despawn (shrink-out)
|
|
private Vector3 originalScale;
|
|
private bool isDespawning = false;
|
|
|
|
void Awake()
|
|
{
|
|
rb = GetComponent<Rigidbody>();
|
|
audioSource = GetComponent<AudioSource>();
|
|
colliders = GetComponents<Collider>();
|
|
originalScale = transform.localScale;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initialize the throwable object with target position
|
|
/// </summary>
|
|
public void Initialize(Transform target, float lifetime)
|
|
{
|
|
Initialize(target, lifetime, Vector3.zero);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Initialize with offset for target position
|
|
/// </summary>
|
|
public void Initialize(Transform target, float lifetime, Vector3 targetOffset)
|
|
{
|
|
this.lifetime = lifetime;
|
|
this.spawnTime = Time.time;
|
|
this.hasArrived = false;
|
|
this.hasCollided = false;
|
|
this.hasPlayedHitFeedback = false;
|
|
this.isDespawning = false;
|
|
this.isFlying = true;
|
|
|
|
// Restore scale in case this pooled object was shrunk out last time
|
|
transform.localScale = originalScale;
|
|
this.currentTime = 0f;
|
|
this.flightTime = flightDuration;
|
|
this.startPosition = transform.position;
|
|
this.targetTransform = target;
|
|
|
|
// Calculate target position with offset
|
|
if (target != null)
|
|
{
|
|
this.targetPosition = target.position + targetOffset;
|
|
}
|
|
else
|
|
{
|
|
this.targetPosition = transform.position + Vector3.forward * 5f;
|
|
}
|
|
|
|
// Random rotation axis for spinning effect
|
|
rotationAxis = Random.insideUnitSphere.normalized;
|
|
|
|
// Disable physics during flight
|
|
if (rb != null)
|
|
{
|
|
rb.isKinematic = true;
|
|
rb.useGravity = false;
|
|
}
|
|
|
|
// Disable colliders during flight
|
|
SetCollidersEnabled(false);
|
|
}
|
|
|
|
void Update()
|
|
{
|
|
// Check lifetime
|
|
if (lifetime > 0 && Time.time - spawnTime > lifetime)
|
|
{
|
|
BeginDespawn();
|
|
return;
|
|
}
|
|
|
|
if (!isFlying) return;
|
|
|
|
// Store previous position for velocity calculation
|
|
Vector3 prevPos = transform.position;
|
|
|
|
currentTime += Time.deltaTime;
|
|
float t = Mathf.Clamp01(currentTime / flightTime);
|
|
|
|
// Calculate parabolic position
|
|
Vector3 currentPos = CalculateParabolicPosition(t);
|
|
transform.position = currentPos;
|
|
|
|
// Calculate velocity for when we switch to physics
|
|
if (Time.deltaTime > 0)
|
|
{
|
|
arrivalVelocity = (currentPos - prevPos) / Time.deltaTime;
|
|
}
|
|
|
|
// Rotate while flying
|
|
transform.Rotate(rotationAxis, rotationSpeed * Time.deltaTime, Space.World);
|
|
|
|
// Check if reached target
|
|
if (t >= 1f)
|
|
{
|
|
OnReachedTarget();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculate position along parabolic arc
|
|
/// </summary>
|
|
private Vector3 CalculateParabolicPosition(float t)
|
|
{
|
|
// Linear interpolation for base position
|
|
Vector3 linearPos = Vector3.Lerp(startPosition, targetPosition, t);
|
|
|
|
// Parabolic arc (peaks at t=0.5)
|
|
float parabola = 4f * arcHeight * t * (1f - t);
|
|
|
|
// Add arc height to Y position
|
|
linearPos.y += parabola;
|
|
|
|
return linearPos;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called when object reaches the target position
|
|
/// </summary>
|
|
private void OnReachedTarget()
|
|
{
|
|
isFlying = false;
|
|
hasArrived = true;
|
|
|
|
// Re-enable colliders for collision detection
|
|
SetCollidersEnabled(true);
|
|
|
|
// Enable physics - object will either hit a collider and bounce or fall
|
|
if (rb != null)
|
|
{
|
|
rb.isKinematic = false;
|
|
rb.useGravity = true;
|
|
|
|
// Continue with the arrival velocity (maintains momentum)
|
|
rb.linearVelocity = arrivalVelocity;
|
|
rb.angularVelocity = rotationAxis * rotationSpeed * Mathf.Deg2Rad;
|
|
}
|
|
|
|
// Schedule despawn (object falls if no collision, then shrinks out)
|
|
Invoke(nameof(BeginDespawn), postArrivalLifetime);
|
|
}
|
|
|
|
void OnCollisionEnter(Collision collision)
|
|
{
|
|
if (!hasArrived) return;
|
|
|
|
// Check if collision is with the target (or a child of the target)
|
|
bool isTargetHit = false;
|
|
if (targetTransform != null)
|
|
{
|
|
Transform hitTransform = collision.collider.transform;
|
|
isTargetHit = hitTransform == targetTransform || hitTransform.IsChildOf(targetTransform);
|
|
}
|
|
|
|
// Play hit feedback the first time we actually touch the target.
|
|
// This is gated separately from the bounce so a glancing contact with a
|
|
// non-target collider (clothing/body) right after arrival doesn't swallow
|
|
// the hit sound — the previous single-flag logic was the main cause of
|
|
// intermittent "no sound".
|
|
if (isTargetHit && !hasPlayedHitFeedback)
|
|
{
|
|
hasPlayedHitFeedback = true;
|
|
|
|
if (launcher != null)
|
|
{
|
|
launcher.OnObjectHitTarget(gameObject, collision.collider, collision);
|
|
}
|
|
|
|
PlayHitSound();
|
|
SpawnHitEffect(collision);
|
|
}
|
|
|
|
// Apply bounce force once, regardless of what was hit
|
|
if (!hasCollided && rb != null && collision.contacts.Length > 0)
|
|
{
|
|
hasCollided = true;
|
|
|
|
Vector3 normal = collision.contacts[0].normal;
|
|
Vector3 reflectedVel = Vector3.Reflect(rb.linearVelocity, normal);
|
|
|
|
rb.linearVelocity = reflectedVel.normalized * bounceForce + Vector3.up * bounceUpForce;
|
|
rb.angularVelocity = Random.insideUnitSphere * 10f;
|
|
}
|
|
}
|
|
|
|
private void SetCollidersEnabled(bool enabled)
|
|
{
|
|
if (colliders == null) return;
|
|
foreach (var col in colliders)
|
|
{
|
|
if (col != null) col.enabled = enabled;
|
|
}
|
|
}
|
|
|
|
private void PlayHitSound()
|
|
{
|
|
if (hitSounds == null || hitSounds.Length == 0) return;
|
|
|
|
AudioClip clip = hitSounds[Random.Range(0, hitSounds.Length)];
|
|
if (clip == null) return;
|
|
|
|
// Play on a detached temporary AudioSource (PlayClipAtPoint) rather than this
|
|
// object's AudioSource. When pooling, this object can be deactivated/recycled
|
|
// before a short clip finishes, which cuts PlayOneShot off — the detached
|
|
// source lives independently and always plays to completion.
|
|
float volume = audioSource != null ? audioSource.volume : 1f;
|
|
AudioSource.PlayClipAtPoint(clip, transform.position, volume);
|
|
}
|
|
|
|
private void SpawnHitEffect(Collision collision = null)
|
|
{
|
|
if (hitEffectPrefab == null) return;
|
|
|
|
Vector3 pos = transform.position;
|
|
Quaternion rot = Quaternion.identity;
|
|
|
|
if (collision != null && collision.contacts.Length > 0)
|
|
{
|
|
pos = collision.contacts[0].point;
|
|
rot = Quaternion.LookRotation(collision.contacts[0].normal);
|
|
}
|
|
|
|
GameObject effect = Instantiate(hitEffectPrefab, pos, rot);
|
|
if (hitEffectLifetime > 0)
|
|
{
|
|
Destroy(effect, hitEffectLifetime);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Begin the despawn sequence. Shrinks the object out over shrinkDuration
|
|
/// before actually deactivating, so it doesn't pop out abruptly.
|
|
/// </summary>
|
|
private void BeginDespawn()
|
|
{
|
|
if (isDespawning) return;
|
|
isDespawning = true;
|
|
isFlying = false;
|
|
|
|
// Cancel the scheduled BeginDespawn so it can't fire again mid-shrink.
|
|
CancelInvoke();
|
|
|
|
if (shrinkDuration > 0f && gameObject.activeInHierarchy)
|
|
{
|
|
StartCoroutine(ShrinkAndDeactivate());
|
|
}
|
|
else
|
|
{
|
|
Deactivate();
|
|
}
|
|
}
|
|
|
|
private System.Collections.IEnumerator ShrinkAndDeactivate()
|
|
{
|
|
Vector3 from = transform.localScale;
|
|
float t = 0f;
|
|
while (t < shrinkDuration)
|
|
{
|
|
t += Time.deltaTime;
|
|
float k = Mathf.Clamp01(t / shrinkDuration);
|
|
transform.localScale = Vector3.Lerp(from, Vector3.zero, k);
|
|
yield return null;
|
|
}
|
|
transform.localScale = Vector3.zero;
|
|
Deactivate();
|
|
}
|
|
|
|
private void Deactivate()
|
|
{
|
|
CancelInvoke();
|
|
StopAllCoroutines();
|
|
isFlying = false;
|
|
hasArrived = false;
|
|
hasCollided = false;
|
|
hasPlayedHitFeedback = false;
|
|
isDespawning = false;
|
|
|
|
// Restore scale so the pooled/recycled object starts at full size next time.
|
|
transform.localScale = originalScale;
|
|
|
|
if (launcher != null && launcher.usePooling)
|
|
{
|
|
// Return to pool
|
|
gameObject.SetActive(false);
|
|
}
|
|
else
|
|
{
|
|
// Destroy
|
|
Destroy(gameObject);
|
|
}
|
|
}
|
|
|
|
void OnDisable()
|
|
{
|
|
CancelInvoke();
|
|
isFlying = false;
|
|
hasArrived = false;
|
|
hasCollided = false;
|
|
hasPlayedHitFeedback = false;
|
|
isDespawning = false;
|
|
}
|
|
}
|
|
}
|