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; } } }