using UnityEngine; using UnityEngine.Events; namespace Streamingle { /// /// Launches throwable objects at a target (avatar's head). /// Connect to WefLab donation events to throw objects on donations. /// Uses parabolic interpolation for smooth arc trajectories. /// public class ThrowableObjectLauncher : MonoBehaviour { [Header("Target")] [Tooltip("The target transform to throw objects at (usually avatar's head)")] public Transform target; [Tooltip("Fixed offset from target position")] public Vector3 targetOffset = Vector3.zero; [Tooltip("Random offset range for hit position")] public Vector3 targetRandomOffset = new Vector3(0.1f, 0.1f, 0.1f); [Header("Target Collider")] [Tooltip("Automatically create a collider on target for bounce detection")] public bool autoCreateTargetCollider = true; [Tooltip("Radius of the auto-created sphere collider")] public float targetColliderRadius = 0.15f; [Header("Spawn Settings")] [Tooltip("Prefabs to throw (randomly selected)")] public GameObject[] throwablePrefabs; [Tooltip("Spawn position offset from this transform")] public Vector3 spawnOffset = new Vector3(0, 1f, 0); [Tooltip("Randomize spawn position within this range")] public Vector3 spawnRandomRange = new Vector3(5f, 1f, 5f); [Header("Throw Settings")] [Tooltip("Delay between throws when throwing multiple")] public float throwInterval = 0.15f; [Header("Object Lifetime")] [Tooltip("Destroy thrown objects after this time (0 = never)")] public float objectLifetime = 5f; [Tooltip("Use object pooling instead of instantiate/destroy")] public bool usePooling = true; [Tooltip("Pool size per prefab")] public int poolSizePerPrefab = 10; [Header("Events")] public UnityEvent onObjectThrown; public UnityEvent onObjectHit; // Object pool private GameObject[][] objectPool; private int[] poolIndices; // Objects spawned directly (when pooling is off) so ClearAll can find them. private readonly System.Collections.Generic.List activeNonPooled = new System.Collections.Generic.List(); // Auto-created collider private SphereCollider createdTargetCollider; private Rigidbody createdTargetRigidbody; void Start() { if (usePooling) { InitializePool(); } if (autoCreateTargetCollider && target != null) { SetupTargetCollider(); } } private void SetupTargetCollider() { // Check if target already has a collider var existingCollider = target.GetComponent(); if (existingCollider != null) { UnityEngine.Debug.Log($"[ThrowableObjectLauncher] Target already has collider: {existingCollider.GetType().Name}"); return; } // Create sphere collider on target with offset applied createdTargetCollider = target.gameObject.AddComponent(); createdTargetCollider.radius = targetColliderRadius; // Convert the world-space hit point (target.position + targetOffset) into the // target's local space so the collider matches the gizmo even when the target // (e.g. a head bone) is rotated or scaled. createdTargetCollider.center = target.InverseTransformPoint(target.position + targetOffset); // Ensure target has rigidbody (kinematic) for collision detection var rb = target.GetComponent(); if (rb == null) { createdTargetRigidbody = target.gameObject.AddComponent(); createdTargetRigidbody.isKinematic = true; createdTargetRigidbody.useGravity = false; } UnityEngine.Debug.Log($"[ThrowableObjectLauncher] Created SphereCollider on target: {target.name}, radius: {targetColliderRadius}, center: {createdTargetCollider.center}"); } void OnDestroy() { // Clean up components we added to the target so we don't leave an // orphaned Rigidbody/Collider on the avatar's bone hierarchy. if (createdTargetCollider != null) { Destroy(createdTargetCollider); } if (createdTargetRigidbody != null) { Destroy(createdTargetRigidbody); } } private void InitializePool() { if (throwablePrefabs == null || throwablePrefabs.Length == 0) return; objectPool = new GameObject[throwablePrefabs.Length][]; poolIndices = new int[throwablePrefabs.Length]; for (int i = 0; i < throwablePrefabs.Length; i++) { if (throwablePrefabs[i] == null) continue; objectPool[i] = new GameObject[poolSizePerPrefab]; for (int j = 0; j < poolSizePerPrefab; j++) { var obj = Instantiate(throwablePrefabs[i], transform); obj.SetActive(false); // Add throwable component if not present var throwable = obj.GetComponent(); if (throwable == null) { throwable = obj.AddComponent(); } throwable.launcher = this; objectPool[i][j] = obj; } } } /// /// Throw a random object at the target /// public void ThrowObject() { if (throwablePrefabs == null || throwablePrefabs.Length == 0) { UnityEngine.Debug.LogWarning("[ThrowableObjectLauncher] No throwable prefabs assigned"); return; } ThrowObject(Random.Range(0, throwablePrefabs.Length)); } /// /// Throw a specific object at the target /// public void ThrowObject(int prefabIndex) { if (throwablePrefabs == null || throwablePrefabs.Length == 0) { UnityEngine.Debug.LogWarning("[ThrowableObjectLauncher] No throwable prefabs assigned"); return; } if (target == null) { UnityEngine.Debug.LogWarning("[ThrowableObjectLauncher] No target assigned"); return; } prefabIndex = Mathf.Clamp(prefabIndex, 0, throwablePrefabs.Length - 1); if (throwablePrefabs[prefabIndex] == null) return; // Get or create object GameObject obj = GetObject(prefabIndex); if (obj == null) return; // Calculate spawn position Vector3 spawnPos = transform.position + transform.TransformDirection(spawnOffset); spawnPos += new Vector3( Random.Range(-spawnRandomRange.x, spawnRandomRange.x), Random.Range(-spawnRandomRange.y, spawnRandomRange.y), Random.Range(-spawnRandomRange.z, spawnRandomRange.z) ); // Calculate random offset for target position Vector3 randomOffset = new Vector3( Random.Range(-targetRandomOffset.x, targetRandomOffset.x), Random.Range(-targetRandomOffset.y, targetRandomOffset.y), Random.Range(-targetRandomOffset.z, targetRandomOffset.z) ); // Combine fixed offset + random offset Vector3 combinedOffset = targetOffset + randomOffset; // Setup object position obj.transform.position = spawnPos; obj.transform.rotation = Random.rotation; obj.SetActive(true); // Setup throwable component with combined offset var throwable = obj.GetComponent(); if (throwable != null) { throwable.Initialize(target, objectLifetime, combinedOffset); } onObjectThrown?.Invoke(obj); } /// /// Throw multiple objects /// public void ThrowMultiple(int count) { for (int i = 0; i < count; i++) { // Delay each throw slightly float delay = i * throwInterval; StartCoroutine(ThrowDelayed(delay)); } } private System.Collections.IEnumerator ThrowDelayed(float delay) { yield return new WaitForSeconds(delay); ThrowObject(); } /// /// Immediately reset only the currently-spawned (active) objects. /// Pooled instances are deactivated back into the pool (the pre-allocated /// reserve is left untouched); non-pooled instances are destroyed. /// Also cancels any pending ThrowMultiple delays so the backlog doesn't /// re-spawn right after clearing. Use this to relieve a donation-burst /// pile-up that causes frame drops. Safe to call at any time. /// [ContextMenu("Reset: Clear All Thrown Objects")] public void ClearAll() { // Cancel any queued ThrowMultiple delays still waiting to fire, // otherwise the pending throws would immediately repopulate the scene. StopAllCoroutines(); ClearActiveObjects(); } /// /// Reset only the objects currently out in the scene, leaving any pending /// ThrowMultiple delays running. Useful when you want to clear what's /// visible but let already-queued throws continue. /// public void ClearActiveObjects() { int cleared = 0; // Pooled objects: deactivate every active instance back into the pool. // Inactive reserve objects are skipped, so the pool stays intact. if (objectPool != null) { foreach (var pool in objectPool) { if (pool == null) continue; foreach (var obj in pool) { if (obj == null || !obj.activeInHierarchy) continue; var throwable = obj.GetComponent(); if (throwable != null) throwable.ForceReset(); else obj.SetActive(false); cleared++; } } } // Non-pooled objects we instantiated directly (ForceReset destroys them). for (int i = activeNonPooled.Count - 1; i >= 0; i--) { var obj = activeNonPooled[i]; if (obj == null) continue; var throwable = obj.GetComponent(); if (throwable != null) throwable.ForceReset(); else Destroy(obj); cleared++; } activeNonPooled.Clear(); UnityEngine.Debug.Log($"[ThrowableObjectLauncher] Cleared {cleared} active object(s)"); } private GameObject GetObject(int prefabIndex) { GameObject obj; if (usePooling && objectPool != null && objectPool[prefabIndex] != null) { // Find inactive object in pool for (int i = 0; i < poolSizePerPrefab; i++) { int idx = (poolIndices[prefabIndex] + i) % poolSizePerPrefab; if (!objectPool[prefabIndex][idx].activeInHierarchy) { poolIndices[prefabIndex] = (idx + 1) % poolSizePerPrefab; obj = objectPool[prefabIndex][idx]; // Ensure launcher reference is set var throwable = obj.GetComponent(); if (throwable != null) throwable.launcher = this; return obj; } } // All in use, reuse oldest poolIndices[prefabIndex] = (poolIndices[prefabIndex] + 1) % poolSizePerPrefab; obj = objectPool[prefabIndex][poolIndices[prefabIndex]]; obj.SetActive(false); // Ensure launcher reference is set var throwableOldest = obj.GetComponent(); if (throwableOldest != null) throwableOldest.launcher = this; return obj; } else { // Instantiate new obj = Instantiate(throwablePrefabs[prefabIndex]); var throwable = obj.GetComponent(); if (throwable == null) { throwable = obj.AddComponent(); } throwable.launcher = this; // Track so ClearAll can reset directly-instantiated objects. activeNonPooled.Add(obj); return obj; } } /// /// Called by ThrowableObject when it reaches the target /// public void OnObjectHitTarget(GameObject obj, Collider hitCollider, Collision collision) { onObjectHit?.Invoke(obj, collision); } #region Context Menu Test Methods [ContextMenu("Test: Throw 1")] private void TestThrowOne() { ThrowObject(); } [ContextMenu("Test: Throw 5")] private void TestThrowFive() { ThrowMultiple(5); } #endregion void OnDrawGizmosSelected() { // Draw spawn area Gizmos.color = Color.green; Vector3 spawnPos = transform.position + transform.TransformDirection(spawnOffset); Gizmos.DrawWireCube(spawnPos, spawnRandomRange * 2); // Draw target if (target != null) { Gizmos.color = Color.red; Vector3 targetPos = target.position + targetOffset; Gizmos.DrawWireSphere(targetPos, 0.2f); Gizmos.DrawWireCube(targetPos, targetRandomOffset * 2); // Draw target collider radius (at offset position) if (autoCreateTargetCollider) { Gizmos.color = Color.cyan; Gizmos.DrawWireSphere(target.position + targetOffset, targetColliderRadius); } // Draw trajectory preview (parabolic arc) Gizmos.color = Color.yellow; DrawParabolicArc(spawnPos, targetPos, 2f, 20); } } private void DrawParabolicArc(Vector3 start, Vector3 end, float arcHeight, int segments) { Vector3 prevPos = start; for (int i = 1; i <= segments; i++) { float t = i / (float)segments; Vector3 linearPos = Vector3.Lerp(start, end, t); float parabola = 4f * arcHeight * t * (1f - t); linearPos.y += parabola; Gizmos.DrawLine(prevPos, linearPos); prevPos = linearPos; } } } }