using UnityEngine;
namespace Streamingle
{
///
/// Component attached to throwable objects.
/// Uses parabolic interpolation for smooth arc trajectory.
/// On reaching target, enables collision detection - bounces off colliders or falls.
///
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();
audioSource = GetComponent();
colliders = GetComponents();
originalScale = transform.localScale;
}
///
/// Initialize the throwable object with target position
///
public void Initialize(Transform target, float lifetime)
{
Initialize(target, lifetime, Vector3.zero);
}
///
/// Initialize with offset for target position
///
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();
}
}
///
/// Calculate position along parabolic arc
///
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;
}
///
/// Called when object reaches the target position
///
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);
}
}
///
/// Begin the despawn sequence. Shrinks the object out over shrinkDuration
/// before actually deactivating, so it doesn't pop out abruptly.
///
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();
}
///
/// Immediately despawn this object, skipping the flight, the post-arrival
/// wait and the shrink-out animation. Called by the launcher's ClearAll to
/// reset everything at once (e.g. to clear a donation-burst backlog).
/// Pooled objects are deactivated; non-pooled ones are destroyed.
///
public void ForceReset()
{
// Deactivate() already cancels invokes/coroutines and restores scale,
// and routes to pool-deactivate vs destroy based on the launcher.
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;
}
}
}