421 lines
16 KiB
C#
421 lines
16 KiB
C#
using UnityEngine;
|
|
using UnityEngine.Events;
|
|
|
|
namespace Streamingle
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<GameObject> onObjectThrown;
|
|
public UnityEvent<GameObject, Collision> 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<GameObject> activeNonPooled =
|
|
new System.Collections.Generic.List<GameObject>();
|
|
|
|
// 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<Collider>();
|
|
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<SphereCollider>();
|
|
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<Rigidbody>();
|
|
if (rb == null)
|
|
{
|
|
createdTargetRigidbody = target.gameObject.AddComponent<Rigidbody>();
|
|
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<ThrowableObject>();
|
|
if (throwable == null)
|
|
{
|
|
throwable = obj.AddComponent<ThrowableObject>();
|
|
}
|
|
throwable.launcher = this;
|
|
|
|
objectPool[i][j] = obj;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Throw a random object at the target
|
|
/// </summary>
|
|
public void ThrowObject()
|
|
{
|
|
if (throwablePrefabs == null || throwablePrefabs.Length == 0)
|
|
{
|
|
UnityEngine.Debug.LogWarning("[ThrowableObjectLauncher] No throwable prefabs assigned");
|
|
return;
|
|
}
|
|
ThrowObject(Random.Range(0, throwablePrefabs.Length));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Throw a specific object at the target
|
|
/// </summary>
|
|
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<ThrowableObject>();
|
|
if (throwable != null)
|
|
{
|
|
throwable.Initialize(target, objectLifetime, combinedOffset);
|
|
}
|
|
|
|
onObjectThrown?.Invoke(obj);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Throw multiple objects
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<ThrowableObject>();
|
|
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<ThrowableObject>();
|
|
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<ThrowableObject>();
|
|
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<ThrowableObject>();
|
|
if (throwableOldest != null) throwableOldest.launcher = this;
|
|
return obj;
|
|
}
|
|
else
|
|
{
|
|
// Instantiate new
|
|
obj = Instantiate(throwablePrefabs[prefabIndex]);
|
|
var throwable = obj.GetComponent<ThrowableObject>();
|
|
if (throwable == null)
|
|
{
|
|
throwable = obj.AddComponent<ThrowableObject>();
|
|
}
|
|
throwable.launcher = this;
|
|
// Track so ClearAll can reset directly-instantiated objects.
|
|
activeNonPooled.Add(obj);
|
|
return obj;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Called by ThrowableObject when it reaches the target
|
|
/// </summary>
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|