290 lines
8.3 KiB
C#
290 lines
8.3 KiB
C#
using Lean.Pool;
|
|
using System.Collections;
|
|
using UnityEngine;
|
|
|
|
namespace DemonBoss.Magic
|
|
{
|
|
public class CrystalShooterAI : MonoBehaviour
|
|
{
|
|
[Header("Shooting Configuration")]
|
|
[Tooltip("Transform point from which projectiles are fired")]
|
|
public Transform muzzle;
|
|
|
|
[Tooltip("Fireball prefab (projectile with its own targeting logic)")]
|
|
public GameObject fireballPrefab;
|
|
|
|
[Tooltip("Base seconds between shots")]
|
|
public float fireRate = 0.7f;
|
|
|
|
[Tooltip("Maximum number of shots before auto-despawn")]
|
|
public int maxShots = 10;
|
|
|
|
[Tooltip("Wait time after last shot before despawn")]
|
|
public float despawnDelay = 3f;
|
|
|
|
[Header("Randomization (Desync)")]
|
|
[Tooltip("Random initial delay after spawn to desync turrets (seconds)")]
|
|
public Vector2 initialStaggerRange = new Vector2(0.0f, 0.6f);
|
|
|
|
[Tooltip("Per-shot random jitter added to fireRate (seconds). Range x means +/- x.")]
|
|
public float fireRateJitter = 0.2f;
|
|
|
|
[Tooltip("Aim wobble in degrees (0 = disabled). Small value adds natural dispersion.")]
|
|
public float aimJitterDegrees = 0f;
|
|
|
|
[Header("Rotation Configuration")]
|
|
[Tooltip("Yaw rotation speed in degrees per second")]
|
|
public float turnSpeed = 120f;
|
|
|
|
[Tooltip("Idle spin speed when no target (degrees/s, 0 = disabled)")]
|
|
public float idleSpinSpeed = 30f;
|
|
|
|
[Tooltip("Aiming accuracy in degrees (smaller = stricter)")]
|
|
public float aimTolerance = 5f;
|
|
|
|
[Header("Targeting (Turret-Side Only)")]
|
|
[Tooltip("Auto-find player on start by tag")]
|
|
public bool autoFindPlayer = true;
|
|
|
|
[Tooltip("Player tag to search for")]
|
|
public string playerTag = "Player";
|
|
|
|
[Tooltip("Max range for allowing shots")]
|
|
public float maxShootingRange = 50f;
|
|
|
|
[Header("Effects")]
|
|
[Tooltip("Enable or disable muzzle flash & sound effects when firing")]
|
|
public bool useShootEffects = true;
|
|
|
|
[Tooltip("Particle effect at shot (pooled)")]
|
|
public GameObject muzzleFlashPrefab;
|
|
|
|
[Tooltip("Shoot sound (played on AudioSource)")]
|
|
public AudioClip shootSound;
|
|
|
|
[Header("Debug")]
|
|
[Tooltip("Enable debug logging")]
|
|
public bool enableDebug = false;
|
|
|
|
[Tooltip("Show gizmos in Scene View")]
|
|
public bool showGizmos = true;
|
|
|
|
private Transform target;
|
|
private AudioSource audioSource;
|
|
private Coroutine shootingCoroutine;
|
|
private bool isActive = false;
|
|
private int shotsFired = 0;
|
|
private float lastShotTime = 0f;
|
|
private Transform crystalTransform;
|
|
|
|
private void Awake()
|
|
{
|
|
crystalTransform = transform;
|
|
|
|
audioSource = GetComponent<AudioSource>();
|
|
if (audioSource == null && shootSound != null)
|
|
{
|
|
audioSource = gameObject.AddComponent<AudioSource>();
|
|
audioSource.playOnAwake = false;
|
|
audioSource.spatialBlend = 1f;
|
|
}
|
|
|
|
if (muzzle == null)
|
|
{
|
|
Transform muzzleChild = crystalTransform.Find("muzzle");
|
|
muzzle = muzzleChild != null ? muzzleChild : crystalTransform;
|
|
}
|
|
}
|
|
|
|
private void Start()
|
|
{
|
|
if (autoFindPlayer && target == null)
|
|
FindPlayer();
|
|
|
|
StartShooting();
|
|
}
|
|
|
|
/// <summary> Update tick: rotate towards target or idle spin. </summary>
|
|
private void Update()
|
|
{
|
|
if (!isActive) return;
|
|
RotateTowardsTarget();
|
|
}
|
|
|
|
/// <summary> Attempts to find the player by tag (for turret-only aiming). </summary>
|
|
private void FindPlayer()
|
|
{
|
|
GameObject player = GameObject.FindGameObjectWithTag(playerTag);
|
|
if (player != null) SetTarget(player.transform);
|
|
}
|
|
|
|
/// <summary> Sets the turret's aiming target (does NOT propagate to projectiles). </summary>
|
|
public void SetTarget(Transform newTarget) => target = newTarget;
|
|
|
|
/// <summary> Starts the timed shooting routine (fires until maxShots, then despawns). </summary>
|
|
public void StartShooting()
|
|
{
|
|
if (isActive) return;
|
|
isActive = true;
|
|
shotsFired = 0;
|
|
shootingCoroutine = StartCoroutine(ShootingCoroutine());
|
|
}
|
|
|
|
/// <summary> Stops the shooting routine immediately. </summary>
|
|
public void StopShooting()
|
|
{
|
|
isActive = false;
|
|
if (shootingCoroutine != null)
|
|
{
|
|
StopCoroutine(shootingCoroutine);
|
|
shootingCoroutine = null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main shooting loop with initial spawn stagger and per-shot jitter.
|
|
/// </summary>
|
|
private IEnumerator ShootingCoroutine()
|
|
{
|
|
// 1) Initial stagger so multiple crystals don't start at the same frame
|
|
if (initialStaggerRange.y > 0f)
|
|
{
|
|
float stagger = Random.Range(initialStaggerRange.x, initialStaggerRange.y);
|
|
if (stagger > 0f) yield return new WaitForSeconds(stagger);
|
|
}
|
|
|
|
// 2) Normal loop with CanShoot gate and per-shot jittered waits
|
|
while (shotsFired < maxShots && isActive)
|
|
{
|
|
if (CanShoot())
|
|
{
|
|
FireFireball();
|
|
shotsFired++;
|
|
lastShotTime = Time.time;
|
|
}
|
|
|
|
float wait = fireRate + (fireRateJitter > 0f ? Random.Range(-fireRateJitter, fireRateJitter) : 0f);
|
|
// Clamp wait to something safe so it never becomes non-positive
|
|
if (wait < 0.05f) wait = 0.05f;
|
|
yield return new WaitForSeconds(wait);
|
|
}
|
|
|
|
yield return new WaitForSeconds(despawnDelay);
|
|
DespawnCrystal();
|
|
}
|
|
|
|
/// <summary> Aiming/range gate for firing. </summary>
|
|
private bool CanShoot()
|
|
{
|
|
if (target == null) return false;
|
|
|
|
float distanceToTarget = Vector3.Distance(crystalTransform.position, target.position);
|
|
if (distanceToTarget > maxShootingRange) return false;
|
|
|
|
Vector3 directionToTarget = (target.position - crystalTransform.position).normalized;
|
|
float angleToTarget = Vector3.Angle(crystalTransform.forward, directionToTarget);
|
|
|
|
return angleToTarget <= aimTolerance;
|
|
}
|
|
|
|
/// <summary> Spawns a fireball oriented towards the turret's current aim direction with optional dispersion. </summary>
|
|
private void FireFireball()
|
|
{
|
|
if (fireballPrefab == null || muzzle == null) return;
|
|
|
|
Vector3 shootDirection;
|
|
if (target != null)
|
|
{
|
|
Vector3 targetCenter = target.position + Vector3.up * 1f;
|
|
shootDirection = (targetCenter - muzzle.position).normalized;
|
|
}
|
|
else shootDirection = crystalTransform.forward;
|
|
|
|
// Apply small aim jitter (random yaw/pitch) to avoid perfect sync volleys
|
|
if (aimJitterDegrees > 0f)
|
|
{
|
|
float yaw = Random.Range(-aimJitterDegrees, aimJitterDegrees);
|
|
float pitch = Random.Range(-aimJitterDegrees * 0.5f, aimJitterDegrees * 0.5f); // usually less pitch dispersion
|
|
shootDirection = Quaternion.Euler(pitch, yaw, 0f) * shootDirection;
|
|
}
|
|
|
|
Vector3 spawnPosition = muzzle.position;
|
|
Quaternion spawnRotation = Quaternion.LookRotation(shootDirection);
|
|
|
|
LeanPool.Spawn(fireballPrefab, spawnPosition, spawnRotation);
|
|
PlayShootEffects();
|
|
}
|
|
|
|
/// <summary> Plays muzzle VFX and shoot SFX (if enabled). </summary>
|
|
private void PlayShootEffects()
|
|
{
|
|
if (!useShootEffects) return;
|
|
|
|
if (muzzleFlashPrefab != null && muzzle != null)
|
|
{
|
|
GameObject flash = LeanPool.Spawn(muzzleFlashPrefab, muzzle.position, muzzle.rotation);
|
|
LeanPool.Despawn(flash, 2f);
|
|
}
|
|
|
|
if (audioSource != null && shootSound != null)
|
|
audioSource.PlayOneShot(shootSound);
|
|
}
|
|
|
|
/// <summary> Smooth yaw rotation towards target; idles by spinning when no target. </summary>
|
|
private void RotateTowardsTarget()
|
|
{
|
|
if (target != null)
|
|
{
|
|
Vector3 directionToTarget = target.position - crystalTransform.position;
|
|
directionToTarget.y = 0f;
|
|
|
|
if (directionToTarget != Vector3.zero)
|
|
{
|
|
Quaternion targetRotation = Quaternion.LookRotation(directionToTarget);
|
|
crystalTransform.rotation = Quaternion.RotateTowards(
|
|
crystalTransform.rotation,
|
|
targetRotation,
|
|
turnSpeed * Time.deltaTime
|
|
);
|
|
}
|
|
}
|
|
else if (idleSpinSpeed > 0f)
|
|
{
|
|
crystalTransform.Rotate(Vector3.up, idleSpinSpeed * Time.deltaTime);
|
|
}
|
|
}
|
|
|
|
/// <summary> Despawns the turret via Lean Pool. </summary>
|
|
public void DespawnCrystal()
|
|
{
|
|
StopShooting();
|
|
LeanPool.Despawn(gameObject);
|
|
}
|
|
|
|
/// <summary> Forces immediate despawn (e.g., boss death). </summary>
|
|
public void ForceDespawn() => DespawnCrystal();
|
|
|
|
/// <summary> Returns crystal state information. </summary>
|
|
public bool IsActive() => isActive;
|
|
|
|
public int GetShotsFired() => shotsFired;
|
|
|
|
public int GetRemainingShots() => Mathf.Max(0, maxShots - shotsFired);
|
|
|
|
public float GetTimeSinceLastShot() => Time.time - lastShotTime;
|
|
|
|
private void OnDrawGizmosSelected()
|
|
{
|
|
if (!showGizmos) return;
|
|
|
|
Gizmos.color = Color.red;
|
|
Gizmos.DrawWireSphere(transform.position, maxShootingRange);
|
|
|
|
if (muzzle != null)
|
|
{
|
|
Gizmos.color = Color.blue;
|
|
Gizmos.DrawWireSphere(muzzle.position, 0.2f);
|
|
}
|
|
}
|
|
}
|
|
} |