Merge branch 'NewStory' of http://185.56.209.148/beyond/beyond into NewStory
This commit is contained in:
327
Assets/AI/_Archer/ArcherProjectile.cs
Normal file
327
Assets/AI/_Archer/ArcherProjectile.cs
Normal file
@@ -0,0 +1,327 @@
|
||||
using Invector;
|
||||
using Invector.vCharacterController;
|
||||
using Lean.Pool;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ArcherEnemy
|
||||
{
|
||||
/// <summary>
|
||||
/// Arrow projectile shot by archer enemies
|
||||
/// Flies in straight line with gravity and deals damage on hit
|
||||
/// </summary>
|
||||
public class ArcherProjectile : MonoBehaviour
|
||||
{
|
||||
#region Configuration
|
||||
|
||||
[Header("Movement")]
|
||||
[Tooltip("Initial velocity of the arrow (m/s)")]
|
||||
public float initialSpeed = 30f;
|
||||
|
||||
[Tooltip("Gravity multiplier (higher = more arc)")]
|
||||
public float gravityMultiplier = 1f;
|
||||
|
||||
[Tooltip("Max lifetime before auto-despawn (seconds)")]
|
||||
public float maxLifetime = 10f;
|
||||
|
||||
[Header("Damage")]
|
||||
[Tooltip("Damage dealt on hit")]
|
||||
public int damage = 15;
|
||||
|
||||
[Tooltip("Knockback force")]
|
||||
public float knockbackForce = 5f;
|
||||
|
||||
[Tooltip("Layers that can be hit")]
|
||||
public LayerMask hitLayers = -1;
|
||||
|
||||
[Header("Effects")]
|
||||
[Tooltip("Impact VFX prefab")]
|
||||
public GameObject impactVFXPrefab;
|
||||
|
||||
[Tooltip("Trail renderer (optional)")]
|
||||
public TrailRenderer trail;
|
||||
|
||||
[Header("Audio")]
|
||||
[Tooltip("Impact sound")]
|
||||
public AudioClip impactSound;
|
||||
|
||||
[Header("Debug")]
|
||||
[Tooltip("Enable debug logging")]
|
||||
public bool enableDebug = false;
|
||||
|
||||
[Tooltip("Show trajectory gizmos")]
|
||||
public bool showGizmos = true;
|
||||
|
||||
#endregion Configuration
|
||||
|
||||
#region Runtime State
|
||||
|
||||
private Vector3 velocity;
|
||||
private float lifetime = 0f;
|
||||
private bool hasHit = false;
|
||||
private AudioSource audioSource;
|
||||
|
||||
#endregion Runtime State
|
||||
|
||||
#region Unity Lifecycle
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (impactSound != null)
|
||||
{
|
||||
audioSource = GetComponent<AudioSource>();
|
||||
if (audioSource == null)
|
||||
{
|
||||
audioSource = gameObject.AddComponent<AudioSource>();
|
||||
audioSource.playOnAwake = false;
|
||||
audioSource.spatialBlend = 1f; // 3D sound
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
ResetState();
|
||||
|
||||
// Set initial velocity in forward direction
|
||||
velocity = transform.forward * initialSpeed;
|
||||
|
||||
if (enableDebug)
|
||||
Debug.Log($"[ArcherProjectile] Spawned at {transform.position}, velocity: {velocity}");
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (hasHit) return;
|
||||
|
||||
lifetime += Time.deltaTime;
|
||||
|
||||
// Check lifetime
|
||||
if (lifetime >= maxLifetime)
|
||||
{
|
||||
if (enableDebug) Debug.Log("[ArcherProjectile] Lifetime expired");
|
||||
Despawn();
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply gravity
|
||||
velocity += Physics.gravity * gravityMultiplier * Time.deltaTime;
|
||||
|
||||
// Calculate movement step
|
||||
Vector3 moveStep = velocity * Time.deltaTime;
|
||||
Vector3 newPosition = transform.position + moveStep;
|
||||
|
||||
// Raycast for collision detection
|
||||
if (Physics.Raycast(transform.position, moveStep.normalized, out RaycastHit hit,
|
||||
moveStep.magnitude, hitLayers, QueryTriggerInteraction.Ignore))
|
||||
{
|
||||
OnHit(hit);
|
||||
return;
|
||||
}
|
||||
|
||||
// Move arrow
|
||||
transform.position = newPosition;
|
||||
|
||||
// Rotate arrow to face movement direction
|
||||
if (velocity.sqrMagnitude > 0.001f)
|
||||
{
|
||||
transform.rotation = Quaternion.LookRotation(velocity.normalized);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion Unity Lifecycle
|
||||
|
||||
#region Collision & Damage
|
||||
|
||||
private void OnHit(RaycastHit hit)
|
||||
{
|
||||
if (hasHit) return;
|
||||
hasHit = true;
|
||||
|
||||
if (enableDebug)
|
||||
Debug.Log($"[ArcherProjectile] Hit: {hit.collider.name} at {hit.point}");
|
||||
|
||||
// Position arrow at impact point
|
||||
transform.position = hit.point;
|
||||
transform.rotation = Quaternion.LookRotation(hit.normal);
|
||||
|
||||
// Try to deal damage
|
||||
DealDamage(hit.collider, hit.point, hit.normal);
|
||||
|
||||
// Spawn impact VFX
|
||||
SpawnImpactVFX(hit.point, hit.normal);
|
||||
|
||||
// Play impact sound
|
||||
PlayImpactSound();
|
||||
|
||||
// Stick arrow to surface or despawn
|
||||
StickToSurface(hit);
|
||||
}
|
||||
|
||||
private void DealDamage(Collider targetCollider, Vector3 hitPoint, Vector3 hitNormal)
|
||||
{
|
||||
// Calculate hit direction (opposite of normal for knockback)
|
||||
Vector3 hitDirection = -hitNormal;
|
||||
if (velocity.sqrMagnitude > 0.001f)
|
||||
{
|
||||
hitDirection = velocity.normalized;
|
||||
}
|
||||
|
||||
// Create damage info
|
||||
vDamage damageInfo = new vDamage(damage)
|
||||
{
|
||||
sender = transform,
|
||||
hitPosition = hitPoint
|
||||
};
|
||||
|
||||
if (knockbackForce > 0f)
|
||||
{
|
||||
damageInfo.force = hitDirection * knockbackForce;
|
||||
}
|
||||
|
||||
bool damageDealt = false;
|
||||
|
||||
// Try vIDamageReceiver
|
||||
var damageReceiver = targetCollider.GetComponent<vIDamageReceiver>() ??
|
||||
targetCollider.GetComponentInParent<vIDamageReceiver>();
|
||||
|
||||
if (damageReceiver != null)
|
||||
{
|
||||
damageReceiver.TakeDamage(damageInfo);
|
||||
damageDealt = true;
|
||||
if (enableDebug) Debug.Log("[ArcherProjectile] Damage dealt via vIDamageReceiver");
|
||||
}
|
||||
|
||||
// Fallback to vHealthController
|
||||
if (!damageDealt)
|
||||
{
|
||||
var healthController = targetCollider.GetComponent<vHealthController>() ??
|
||||
targetCollider.GetComponentInParent<vHealthController>();
|
||||
|
||||
if (healthController != null)
|
||||
{
|
||||
healthController.TakeDamage(damageInfo);
|
||||
damageDealt = true;
|
||||
if (enableDebug) Debug.Log("[ArcherProjectile] Damage dealt via vHealthController");
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to vThirdPersonController
|
||||
if (!damageDealt)
|
||||
{
|
||||
var tpc = targetCollider.GetComponent<vThirdPersonController>() ??
|
||||
targetCollider.GetComponentInParent<vThirdPersonController>();
|
||||
|
||||
if (tpc != null)
|
||||
{
|
||||
// Handle Beyond variant
|
||||
if (tpc is Beyond.bThirdPersonController beyond)
|
||||
{
|
||||
if (!beyond.GodMode && !beyond.isImmortal)
|
||||
{
|
||||
tpc.TakeDamage(damageInfo);
|
||||
damageDealt = true;
|
||||
if (enableDebug) Debug.Log("[ArcherProjectile] Damage dealt via bThirdPersonController");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (enableDebug) Debug.Log("[ArcherProjectile] Target is immortal - no damage");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
tpc.TakeDamage(damageInfo);
|
||||
damageDealt = true;
|
||||
if (enableDebug) Debug.Log("[ArcherProjectile] Damage dealt via vThirdPersonController");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!damageDealt && enableDebug)
|
||||
{
|
||||
Debug.Log("[ArcherProjectile] No damage dealt - no valid receiver found");
|
||||
}
|
||||
}
|
||||
|
||||
#endregion Collision & Damage
|
||||
|
||||
#region Effects
|
||||
|
||||
private void SpawnImpactVFX(Vector3 position, Vector3 normal)
|
||||
{
|
||||
if (impactVFXPrefab == null) return;
|
||||
|
||||
Quaternion rotation = Quaternion.LookRotation(normal);
|
||||
GameObject vfx = LeanPool.Spawn(impactVFXPrefab, position, rotation);
|
||||
LeanPool.Despawn(vfx, 3f);
|
||||
|
||||
if (enableDebug) Debug.Log("[ArcherProjectile] Impact VFX spawned");
|
||||
}
|
||||
|
||||
private void PlayImpactSound()
|
||||
{
|
||||
if (audioSource != null && impactSound != null)
|
||||
{
|
||||
audioSource.PlayOneShot(impactSound);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion Effects
|
||||
|
||||
#region Arrow Sticking
|
||||
|
||||
private void StickToSurface(RaycastHit hit)
|
||||
{
|
||||
// Option 1: Parent arrow to hit object (if it has rigidbody, it will move with it)
|
||||
// Option 2: Just despawn after short delay
|
||||
|
||||
// For now, despawn after brief delay to show impact
|
||||
LeanPool.Despawn(gameObject, 0.1f);
|
||||
}
|
||||
|
||||
#endregion Arrow Sticking
|
||||
|
||||
#region Pooling
|
||||
|
||||
private void ResetState()
|
||||
{
|
||||
hasHit = false;
|
||||
lifetime = 0f;
|
||||
velocity = Vector3.zero;
|
||||
|
||||
// Reset trail if present
|
||||
if (trail != null)
|
||||
{
|
||||
trail.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void Despawn()
|
||||
{
|
||||
if (enableDebug) Debug.Log("[ArcherProjectile] Despawning");
|
||||
LeanPool.Despawn(gameObject);
|
||||
}
|
||||
|
||||
#endregion Pooling
|
||||
|
||||
#region Gizmos
|
||||
|
||||
#if UNITY_EDITOR
|
||||
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
if (!showGizmos || !Application.isPlaying) return;
|
||||
|
||||
// Draw velocity vector
|
||||
Gizmos.color = Color.yellow;
|
||||
Gizmos.DrawRay(transform.position, velocity.normalized * 2f);
|
||||
|
||||
// Draw forward direction
|
||||
Gizmos.color = Color.blue;
|
||||
Gizmos.DrawRay(transform.position, transform.forward * 1f);
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
#endregion Gizmos
|
||||
}
|
||||
}
|
||||
2
Assets/AI/_Archer/ArcherProjectile.cs.meta
Normal file
2
Assets/AI/_Archer/ArcherProjectile.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9c5e129708014c64981ec3d5665de1b4
|
||||
430
Assets/AI/_Archer/ArcherShootingAI.cs
Normal file
430
Assets/AI/_Archer/ArcherShootingAI.cs
Normal file
@@ -0,0 +1,430 @@
|
||||
using Lean.Pool;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ArcherEnemy
|
||||
{
|
||||
/// <summary>
|
||||
/// AI component for archer enemy that shoots arrows at target
|
||||
/// Should be attached to archer enemy prefab
|
||||
/// </summary>
|
||||
public class ArcherShootingAI : MonoBehaviour
|
||||
{
|
||||
[Header("References")]
|
||||
[Tooltip("Transform point from which arrows are shot (usually hand bone or weapon tip)")]
|
||||
public Transform shootPoint;
|
||||
|
||||
[Tooltip("Arrow prefab with ArcherProjectile component")]
|
||||
public GameObject arrowPrefab;
|
||||
|
||||
[Tooltip("Animator for triggering shoot animation")]
|
||||
public Animator animator;
|
||||
|
||||
[Header("Targeting")]
|
||||
[Tooltip("Auto-find player on start")]
|
||||
public bool autoFindPlayer = true;
|
||||
|
||||
[Tooltip("Player tag to search for")]
|
||||
public string playerTag = "Player";
|
||||
|
||||
[Tooltip("Height offset for aiming (aim at chest/head)")]
|
||||
public float targetHeightOffset = 1.2f;
|
||||
|
||||
[Header("Shooting Parameters")]
|
||||
[Tooltip("Minimum distance to shoot from")]
|
||||
public float minShootDistance = 8f;
|
||||
|
||||
[Tooltip("Maximum distance to shoot from")]
|
||||
public float maxShootDistance = 25f;
|
||||
|
||||
[Tooltip("Time between shots (seconds)")]
|
||||
public float shootCooldown = 2f;
|
||||
|
||||
[Tooltip("Arrow launch speed (m/s)")]
|
||||
public float arrowSpeed = 30f;
|
||||
|
||||
[Tooltip("How much to lead the target (predict movement)")]
|
||||
[Range(0f, 1f)]
|
||||
public float leadTargetAmount = 0.5f;
|
||||
|
||||
[Header("Animation")]
|
||||
[Tooltip("Animator trigger parameter for shooting")]
|
||||
public string shootTriggerName = "Shoot";
|
||||
|
||||
[Tooltip("Delay after animation starts before spawning arrow")]
|
||||
public float shootAnimationDelay = 0.3f;
|
||||
|
||||
[Header("Aiming")]
|
||||
[Tooltip("How quickly archer rotates to face target (degrees/sec)")]
|
||||
public float turnSpeed = 180f;
|
||||
|
||||
[Tooltip("Angle tolerance for shooting (degrees)")]
|
||||
public float aimTolerance = 15f;
|
||||
|
||||
[Header("Effects")]
|
||||
[Tooltip("Muzzle flash effect at shoot point")]
|
||||
public GameObject muzzleFlashPrefab;
|
||||
|
||||
[Tooltip("Shoot sound")]
|
||||
public AudioClip shootSound;
|
||||
|
||||
[Header("Debug")]
|
||||
[Tooltip("Enable debug logging")]
|
||||
public bool enableDebug = false;
|
||||
|
||||
[Tooltip("Show aiming gizmos")]
|
||||
public bool showGizmos = true;
|
||||
|
||||
#region Private Fields
|
||||
|
||||
private Transform target;
|
||||
private float lastShootTime = -999f;
|
||||
private bool isAiming = false;
|
||||
private AudioSource audioSource;
|
||||
private Vector3 lastKnownTargetVelocity;
|
||||
private Vector3 lastTargetPosition;
|
||||
|
||||
#endregion Private Fields
|
||||
|
||||
#region Unity Lifecycle
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
// Setup audio source
|
||||
if (shootSound != null)
|
||||
{
|
||||
audioSource = GetComponent<AudioSource>();
|
||||
if (audioSource == null)
|
||||
{
|
||||
audioSource = gameObject.AddComponent<AudioSource>();
|
||||
audioSource.playOnAwake = false;
|
||||
audioSource.spatialBlend = 1f;
|
||||
}
|
||||
}
|
||||
|
||||
// Find animator if not assigned
|
||||
if (animator == null)
|
||||
{
|
||||
animator = GetComponent<Animator>();
|
||||
}
|
||||
|
||||
// Find shoot point if not assigned
|
||||
if (shootPoint == null)
|
||||
{
|
||||
// Try to find "ShootPoint" child transform
|
||||
Transform shootPointChild = transform.Find("ShootPoint");
|
||||
shootPoint = shootPointChild != null ? shootPointChild : transform;
|
||||
}
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
if (autoFindPlayer)
|
||||
{
|
||||
FindPlayer();
|
||||
}
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (target != null && isAiming)
|
||||
{
|
||||
RotateTowardsTarget();
|
||||
UpdateTargetVelocity();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion Unity Lifecycle
|
||||
|
||||
#region Target Management
|
||||
|
||||
/// <summary>
|
||||
/// Finds player by tag
|
||||
/// </summary>
|
||||
private void FindPlayer()
|
||||
{
|
||||
GameObject player = GameObject.FindGameObjectWithTag(playerTag);
|
||||
if (player != null)
|
||||
{
|
||||
SetTarget(player.transform);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the target to shoot at
|
||||
/// </summary>
|
||||
public void SetTarget(Transform newTarget)
|
||||
{
|
||||
target = newTarget;
|
||||
if (target != null)
|
||||
{
|
||||
lastTargetPosition = target.position;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates target velocity for prediction
|
||||
/// </summary>
|
||||
private void UpdateTargetVelocity()
|
||||
{
|
||||
if (target == null) return;
|
||||
|
||||
Vector3 currentPosition = target.position;
|
||||
lastKnownTargetVelocity = (currentPosition - lastTargetPosition) / Time.deltaTime;
|
||||
lastTargetPosition = currentPosition;
|
||||
}
|
||||
|
||||
#endregion Target Management
|
||||
|
||||
#region Shooting Logic
|
||||
|
||||
/// <summary>
|
||||
/// Checks if archer can shoot at target
|
||||
/// </summary>
|
||||
public bool CanShoot()
|
||||
{
|
||||
if (target == null) return false;
|
||||
|
||||
float distance = Vector3.Distance(transform.position, target.position);
|
||||
|
||||
// Check distance range
|
||||
if (distance < minShootDistance || distance > maxShootDistance)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check cooldown
|
||||
if (Time.time < lastShootTime + shootCooldown)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if facing target (within tolerance)
|
||||
Vector3 directionToTarget = (target.position - transform.position).normalized;
|
||||
float angleToTarget = Vector3.Angle(transform.forward, directionToTarget);
|
||||
|
||||
if (angleToTarget > aimTolerance)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initiates shooting sequence
|
||||
/// </summary>
|
||||
public void StartShooting()
|
||||
{
|
||||
if (!CanShoot())
|
||||
{
|
||||
if (enableDebug) Debug.Log("[ArcherShootingAI] Cannot shoot - conditions not met");
|
||||
return;
|
||||
}
|
||||
|
||||
isAiming = true;
|
||||
|
||||
// Trigger animation
|
||||
if (animator != null && !string.IsNullOrEmpty(shootTriggerName))
|
||||
{
|
||||
animator.SetTrigger(shootTriggerName);
|
||||
}
|
||||
|
||||
// Schedule arrow spawn after animation delay
|
||||
Invoke(nameof(SpawnArrow), shootAnimationDelay);
|
||||
|
||||
lastShootTime = Time.time;
|
||||
|
||||
if (enableDebug) Debug.Log($"[ArcherShootingAI] Started shooting at {target.name}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spawns arrow projectile
|
||||
/// </summary>
|
||||
private void SpawnArrow()
|
||||
{
|
||||
if (arrowPrefab == null)
|
||||
{
|
||||
Debug.LogError("[ArcherShootingAI] Arrow prefab not assigned!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (shootPoint == null)
|
||||
{
|
||||
Debug.LogError("[ArcherShootingAI] Shoot point not assigned!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate aim point with prediction
|
||||
Vector3 aimPoint = CalculateAimPoint();
|
||||
|
||||
// Calculate shoot direction
|
||||
Vector3 shootDirection = (aimPoint - shootPoint.position).normalized;
|
||||
|
||||
// Calculate rotation for arrow
|
||||
Quaternion arrowRotation = Quaternion.LookRotation(shootDirection);
|
||||
|
||||
// Spawn arrow
|
||||
GameObject arrow = LeanPool.Spawn(arrowPrefab, shootPoint.position, arrowRotation);
|
||||
|
||||
// Configure arrow projectile
|
||||
var projectile = arrow.GetComponent<ArcherProjectile>();
|
||||
if (projectile != null)
|
||||
{
|
||||
projectile.initialSpeed = arrowSpeed;
|
||||
}
|
||||
|
||||
// Play effects
|
||||
PlayShootEffects();
|
||||
|
||||
if (enableDebug)
|
||||
Debug.Log($"[ArcherShootingAI] Arrow spawned, direction: {shootDirection}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates aim point with target prediction
|
||||
/// </summary>
|
||||
private Vector3 CalculateAimPoint()
|
||||
{
|
||||
if (target == null) return shootPoint.position + transform.forward * 10f;
|
||||
|
||||
// Base aim point
|
||||
Vector3 targetPosition = target.position + Vector3.up * targetHeightOffset;
|
||||
|
||||
// Add prediction based on target velocity
|
||||
if (leadTargetAmount > 0f && lastKnownTargetVelocity.sqrMagnitude > 0.1f)
|
||||
{
|
||||
// Estimate time to target
|
||||
float distance = Vector3.Distance(shootPoint.position, targetPosition);
|
||||
float timeToTarget = distance / arrowSpeed;
|
||||
|
||||
// Predict future position
|
||||
Vector3 predictedOffset = lastKnownTargetVelocity * timeToTarget * leadTargetAmount;
|
||||
targetPosition += predictedOffset;
|
||||
}
|
||||
|
||||
return targetPosition;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops shooting sequence
|
||||
/// </summary>
|
||||
public void StopShooting()
|
||||
{
|
||||
isAiming = false;
|
||||
CancelInvoke(nameof(SpawnArrow));
|
||||
}
|
||||
|
||||
#endregion Shooting Logic
|
||||
|
||||
#region Effects
|
||||
|
||||
private void PlayShootEffects()
|
||||
{
|
||||
// Spawn muzzle flash
|
||||
if (muzzleFlashPrefab != null && shootPoint != null)
|
||||
{
|
||||
GameObject flash = LeanPool.Spawn(muzzleFlashPrefab, shootPoint.position, shootPoint.rotation);
|
||||
LeanPool.Despawn(flash, 2f);
|
||||
}
|
||||
|
||||
// Play shoot sound
|
||||
if (audioSource != null && shootSound != null)
|
||||
{
|
||||
audioSource.PlayOneShot(shootSound);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion Effects
|
||||
|
||||
#region Rotation
|
||||
|
||||
/// <summary>
|
||||
/// Smoothly rotates archer to face target
|
||||
/// </summary>
|
||||
private void RotateTowardsTarget()
|
||||
{
|
||||
if (target == null) return;
|
||||
|
||||
Vector3 directionToTarget = (target.position - transform.position);
|
||||
directionToTarget.y = 0f; // Keep rotation on Y axis only
|
||||
|
||||
if (directionToTarget.sqrMagnitude > 0.001f)
|
||||
{
|
||||
Quaternion targetRotation = Quaternion.LookRotation(directionToTarget);
|
||||
transform.rotation = Quaternion.RotateTowards(
|
||||
transform.rotation,
|
||||
targetRotation,
|
||||
turnSpeed * Time.deltaTime
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if archer is facing target within tolerance
|
||||
/// </summary>
|
||||
public bool IsFacingTarget()
|
||||
{
|
||||
if (target == null) return false;
|
||||
|
||||
Vector3 directionToTarget = (target.position - transform.position).normalized;
|
||||
float angle = Vector3.Angle(transform.forward, directionToTarget);
|
||||
|
||||
return angle <= aimTolerance;
|
||||
}
|
||||
|
||||
#endregion Rotation
|
||||
|
||||
#region Public Query Methods
|
||||
|
||||
public Transform GetTarget() => target;
|
||||
|
||||
public bool IsAiming() => isAiming;
|
||||
|
||||
public float GetTimeSinceLastShot() => Time.time - lastShootTime;
|
||||
|
||||
public float GetDistanceToTarget() => target != null ? Vector3.Distance(transform.position, target.position) : float.MaxValue;
|
||||
|
||||
public bool IsInShootingRange() => GetDistanceToTarget() >= minShootDistance && GetDistanceToTarget() <= maxShootDistance;
|
||||
|
||||
#endregion Public Query Methods
|
||||
|
||||
#region Gizmos
|
||||
|
||||
#if UNITY_EDITOR
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
if (!showGizmos) return;
|
||||
|
||||
// Draw shooting range
|
||||
Gizmos.color = Color.yellow;
|
||||
UnityEditor.Handles.color = new Color(1f, 1f, 0f, 0.1f);
|
||||
UnityEditor.Handles.DrawSolidDisc(transform.position, Vector3.up, minShootDistance);
|
||||
|
||||
Gizmos.color = Color.green;
|
||||
UnityEditor.Handles.color = new Color(0f, 1f, 0f, 0.1f);
|
||||
UnityEditor.Handles.DrawSolidDisc(transform.position, Vector3.up, maxShootDistance);
|
||||
|
||||
// Draw shoot point
|
||||
if (shootPoint != null)
|
||||
{
|
||||
Gizmos.color = Color.red;
|
||||
Gizmos.DrawWireSphere(shootPoint.position, 0.1f);
|
||||
Gizmos.DrawLine(shootPoint.position, shootPoint.position + shootPoint.forward * 2f);
|
||||
}
|
||||
|
||||
// Draw aim line to target
|
||||
if (target != null && Application.isPlaying)
|
||||
{
|
||||
Vector3 aimPoint = CalculateAimPoint();
|
||||
Gizmos.color = Color.cyan;
|
||||
Gizmos.DrawLine(shootPoint != null ? shootPoint.position : transform.position, aimPoint);
|
||||
Gizmos.DrawWireSphere(aimPoint, 0.2f);
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
#endregion Gizmos
|
||||
}
|
||||
}
|
||||
2
Assets/AI/_Archer/ArcherShootingAI.cs.meta
Normal file
2
Assets/AI/_Archer/ArcherShootingAI.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6b6fc5c257dfe0a42856b4f8169fb925
|
||||
66
Assets/AI/_Archer/DEC_CanShoot.cs
Normal file
66
Assets/AI/_Archer/DEC_CanShoot.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using Invector.vCharacterController.AI.FSMBehaviour;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ArcherEnemy
|
||||
{
|
||||
/// <summary>
|
||||
/// Decision checking if archer can shoot
|
||||
/// Checks cooldown, aiming, and射ing AI component readiness
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Invector/FSM/Decisions/Archer/Can Shoot")]
|
||||
public class DEC_CanShoot : vStateDecision
|
||||
{
|
||||
public override string categoryName => "Archer/Combat";
|
||||
public override string defaultName => "Can Shoot";
|
||||
|
||||
[Header("Configuration")]
|
||||
[Tooltip("Check if archer is facing target within tolerance")]
|
||||
public bool checkFacingTarget = true;
|
||||
|
||||
[Tooltip("Angle tolerance for shooting (degrees)")]
|
||||
public float aimTolerance = 20f;
|
||||
|
||||
[Header("Debug")]
|
||||
[Tooltip("Enable debug logging")]
|
||||
public bool enableDebug = false;
|
||||
|
||||
public override bool Decide(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
// Get ArcherShootingAI component
|
||||
var shootingAI = fsmBehaviour.gameObject.GetComponent<ArcherShootingAI>();
|
||||
|
||||
if (shootingAI == null)
|
||||
{
|
||||
if (enableDebug) Debug.LogWarning("[DEC_CanShoot] No ArcherShootingAI component found!");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use the shooting AI's CanShoot method
|
||||
bool canShoot = shootingAI.CanShoot();
|
||||
|
||||
// Optional: additional facing check
|
||||
if (canShoot && checkFacingTarget)
|
||||
{
|
||||
Transform target = shootingAI.GetTarget();
|
||||
if (target != null)
|
||||
{
|
||||
Vector3 directionToTarget = (target.position - fsmBehaviour.transform.position).normalized;
|
||||
float angle = Vector3.Angle(fsmBehaviour.transform.forward, directionToTarget);
|
||||
|
||||
if (angle > aimTolerance)
|
||||
{
|
||||
canShoot = false;
|
||||
if (enableDebug) Debug.Log($"[DEC_CanShoot] Not facing target: {angle:F1}° (tolerance: {aimTolerance}°)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (enableDebug)
|
||||
{
|
||||
Debug.Log($"[DEC_CanShoot] {(canShoot ? "CAN SHOOT" : "CANNOT SHOOT")}");
|
||||
}
|
||||
|
||||
return canShoot;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/AI/_Archer/DEC_CanShoot.cs.meta
Normal file
2
Assets/AI/_Archer/DEC_CanShoot.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dd96521d511252744900d3adb9ef1b2e
|
||||
112
Assets/AI/_Archer/DEC_PlayerInShootRange.cs
Normal file
112
Assets/AI/_Archer/DEC_PlayerInShootRange.cs
Normal file
@@ -0,0 +1,112 @@
|
||||
using Invector.vCharacterController.AI.FSMBehaviour;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ArcherEnemy
|
||||
{
|
||||
/// <summary>
|
||||
/// Decision checking if player is in optimal shooting range
|
||||
/// Returns true when player is far enough but not too far
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Invector/FSM/Decisions/Archer/Player In Shoot Range")]
|
||||
public class DEC_PlayerInShootRange : vStateDecision
|
||||
{
|
||||
public override string categoryName => "Archer/Combat";
|
||||
public override string defaultName => "Player In Shoot Range";
|
||||
|
||||
[Header("Range Configuration")]
|
||||
[Tooltip("Minimum safe distance to start shooting")]
|
||||
public float minShootDistance = 8f;
|
||||
|
||||
[Tooltip("Maximum effective shooting distance")]
|
||||
public float maxShootDistance = 25f;
|
||||
|
||||
[Tooltip("Also check if we have clear line of sight")]
|
||||
public bool checkLineOfSight = true;
|
||||
|
||||
[Tooltip("Layers that block line of sight")]
|
||||
public LayerMask obstacleMask = -1;
|
||||
|
||||
[Header("Debug")]
|
||||
[Tooltip("Enable debug logging")]
|
||||
public bool enableDebug = false;
|
||||
|
||||
[Tooltip("Show range gizmos")]
|
||||
public bool showGizmos = true;
|
||||
|
||||
public override bool Decide(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
Transform target = GetTarget(fsmBehaviour);
|
||||
|
||||
if (target == null)
|
||||
{
|
||||
if (enableDebug) Debug.Log("[DEC_PlayerInShootRange] No target found");
|
||||
return false;
|
||||
}
|
||||
|
||||
Vector3 archerPos = fsmBehaviour.transform.position;
|
||||
Vector3 targetPos = target.position;
|
||||
|
||||
float distance = Vector3.Distance(archerPos, targetPos);
|
||||
|
||||
// Check distance range
|
||||
bool inRange = distance >= minShootDistance && distance <= maxShootDistance;
|
||||
|
||||
if (!inRange)
|
||||
{
|
||||
if (enableDebug)
|
||||
{
|
||||
if (distance < minShootDistance)
|
||||
Debug.Log($"[DEC_PlayerInShootRange] Player too close: {distance:F1}m (min: {minShootDistance})");
|
||||
else
|
||||
Debug.Log($"[DEC_PlayerInShootRange] Player too far: {distance:F1}m (max: {maxShootDistance})");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check line of sight if enabled
|
||||
if (checkLineOfSight)
|
||||
{
|
||||
Vector3 shootPoint = archerPos + Vector3.up * 1.5f; // Approximate chest height
|
||||
Vector3 targetPoint = targetPos + Vector3.up * 1f;
|
||||
Vector3 direction = targetPoint - shootPoint;
|
||||
|
||||
if (Physics.Raycast(shootPoint, direction.normalized, distance, obstacleMask, QueryTriggerInteraction.Ignore))
|
||||
{
|
||||
if (enableDebug) Debug.Log("[DEC_PlayerInShootRange] Line of sight blocked");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (enableDebug)
|
||||
{
|
||||
Debug.Log($"[DEC_PlayerInShootRange] IN RANGE: {distance:F1}m");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private Transform GetTarget(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
// Try through AI controller
|
||||
var aiController = fsmBehaviour as Invector.vCharacterController.AI.vIControlAI;
|
||||
if (aiController != null && aiController.currentTarget != null)
|
||||
return aiController.currentTarget.transform;
|
||||
|
||||
// Fallback - find player
|
||||
GameObject player = GameObject.FindGameObjectWithTag("Player");
|
||||
return player?.transform;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
if (!showGizmos) return;
|
||||
|
||||
// This would need to be drawn from the archer's position in-game
|
||||
// For now, just a visual reference
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
}
|
||||
2
Assets/AI/_Archer/DEC_PlayerInShootRange.cs.meta
Normal file
2
Assets/AI/_Archer/DEC_PlayerInShootRange.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6577855f02c3b2649b6a787e88baea8b
|
||||
80
Assets/AI/_Archer/DEC_PlayerTooClose.cs
Normal file
80
Assets/AI/_Archer/DEC_PlayerTooClose.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using Invector.vCharacterController.AI.FSMBehaviour;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ArcherEnemy
|
||||
{
|
||||
/// <summary>
|
||||
/// Decision checking if player is too close to the archer
|
||||
/// Used to trigger retreat/flee behavior
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Invector/FSM/Decisions/Archer/Player Too Close")]
|
||||
public class DEC_PlayerTooClose : vStateDecision
|
||||
{
|
||||
public override string categoryName => "Archer/Combat";
|
||||
public override string defaultName => "Player Too Close";
|
||||
|
||||
[Header("Distance Configuration")]
|
||||
[Tooltip("Distance below which player is considered too close")]
|
||||
public float dangerDistance = 6f;
|
||||
|
||||
[Tooltip("Optional: check only if player is approaching (not retreating)")]
|
||||
public bool checkIfApproaching = false;
|
||||
|
||||
[Header("Debug")]
|
||||
[Tooltip("Enable debug logging")]
|
||||
public bool enableDebug = false;
|
||||
|
||||
private Vector3 lastPlayerPosition;
|
||||
private bool hasLastPosition = false;
|
||||
|
||||
public override bool Decide(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
Transform target = GetTarget(fsmBehaviour);
|
||||
|
||||
if (target == null)
|
||||
{
|
||||
if (enableDebug) Debug.Log("[DEC_PlayerTooClose] No target found");
|
||||
hasLastPosition = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
float distance = Vector3.Distance(fsmBehaviour.transform.position, target.position);
|
||||
bool tooClose = distance < dangerDistance;
|
||||
|
||||
// Optional: check if player is approaching
|
||||
if (checkIfApproaching && hasLastPosition)
|
||||
{
|
||||
float previousDistance = Vector3.Distance(fsmBehaviour.transform.position, lastPlayerPosition);
|
||||
bool isApproaching = distance < previousDistance;
|
||||
|
||||
if (!isApproaching)
|
||||
{
|
||||
tooClose = false; // Player is moving away, not a threat
|
||||
}
|
||||
}
|
||||
|
||||
// Store current position for next frame
|
||||
lastPlayerPosition = target.position;
|
||||
hasLastPosition = true;
|
||||
|
||||
if (enableDebug)
|
||||
{
|
||||
Debug.Log($"[DEC_PlayerTooClose] Distance: {distance:F1}m - {(tooClose ? "TOO CLOSE" : "SAFE")}");
|
||||
}
|
||||
|
||||
return tooClose;
|
||||
}
|
||||
|
||||
private Transform GetTarget(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
// Try through AI controller
|
||||
var aiController = fsmBehaviour as Invector.vCharacterController.AI.vIControlAI;
|
||||
if (aiController != null && aiController.currentTarget != null)
|
||||
return aiController.currentTarget.transform;
|
||||
|
||||
// Fallback - find player
|
||||
GameObject player = GameObject.FindGameObjectWithTag("Player");
|
||||
return player?.transform;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/AI/_Archer/DEC_PlayerTooClose.cs.meta
Normal file
2
Assets/AI/_Archer/DEC_PlayerTooClose.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3508c9b7b0af0d540a18d406403cff4d
|
||||
269
Assets/AI/_Archer/SA_FleeFromPlayer.cs
Normal file
269
Assets/AI/_Archer/SA_FleeFromPlayer.cs
Normal file
@@ -0,0 +1,269 @@
|
||||
using Invector.vCharacterController.AI.FSMBehaviour;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ArcherEnemy
|
||||
{
|
||||
/// <summary>
|
||||
/// State action that makes archer flee/retreat from player
|
||||
/// Moves archer away from player while trying to maintain shooting distance
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Invector/FSM/Actions/Archer/Flee From Player")]
|
||||
public class SA_FleeFromPlayer : vStateAction
|
||||
{
|
||||
public override string categoryName => "Archer/Combat";
|
||||
public override string defaultName => "Flee From Player";
|
||||
|
||||
[Header("Flee Configuration")]
|
||||
[Tooltip("Desired safe distance from player")]
|
||||
public float safeDistance = 12f;
|
||||
|
||||
[Tooltip("How far to look ahead when fleeing")]
|
||||
public float fleeDistance = 5f;
|
||||
|
||||
[Tooltip("Check for obstacles when fleeing")]
|
||||
public bool avoidObstacles = true;
|
||||
|
||||
[Tooltip("Layers considered as obstacles")]
|
||||
public LayerMask obstacleMask = -1;
|
||||
|
||||
[Tooltip("Number of directions to try when finding flee path")]
|
||||
public int directionSamples = 8;
|
||||
|
||||
[Header("Movement")]
|
||||
[Tooltip("Movement speed multiplier (uses AI's speed)")]
|
||||
[Range(0.5f, 2f)]
|
||||
public float speedMultiplier = 1.2f;
|
||||
|
||||
[Tooltip("Make archer sprint while fleeing")]
|
||||
public bool useSprint = true;
|
||||
|
||||
[Header("Rotation")]
|
||||
[Tooltip("Keep facing player while backing away")]
|
||||
public bool facePlayer = true;
|
||||
|
||||
[Tooltip("Rotation speed when facing player (degrees/sec)")]
|
||||
public float turnSpeed = 180f;
|
||||
|
||||
[Header("Debug")]
|
||||
[Tooltip("Enable debug logging")]
|
||||
public bool enableDebug = false;
|
||||
|
||||
[Tooltip("Show flee direction gizmos")]
|
||||
public bool showGizmos = true;
|
||||
|
||||
private Vector3 currentFleeDirection;
|
||||
private Transform currentTarget;
|
||||
|
||||
public override void DoAction(vIFSMBehaviourController fsmBehaviour, vFSMComponentExecutionType executionType = vFSMComponentExecutionType.OnStateUpdate)
|
||||
{
|
||||
if (executionType == vFSMComponentExecutionType.OnStateEnter)
|
||||
{
|
||||
OnEnter(fsmBehaviour);
|
||||
}
|
||||
else if (executionType == vFSMComponentExecutionType.OnStateUpdate)
|
||||
{
|
||||
OnUpdate(fsmBehaviour);
|
||||
}
|
||||
else if (executionType == vFSMComponentExecutionType.OnStateExit)
|
||||
{
|
||||
OnExit(fsmBehaviour);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnter(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
currentTarget = GetTarget(fsmBehaviour);
|
||||
|
||||
if (currentTarget == null)
|
||||
{
|
||||
if (enableDebug) Debug.LogWarning("[SA_FleeFromPlayer] No target found!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate initial flee direction
|
||||
currentFleeDirection = CalculateFleeDirection(fsmBehaviour);
|
||||
|
||||
// Set AI to sprint if enabled
|
||||
var aiController = fsmBehaviour as Invector.vCharacterController.AI.vIControlAI;
|
||||
if (aiController != null && useSprint)
|
||||
{
|
||||
}
|
||||
|
||||
if (enableDebug) Debug.Log("[SA_FleeFromPlayer] Started fleeing from player");
|
||||
}
|
||||
|
||||
private void OnUpdate(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
if (currentTarget == null)
|
||||
{
|
||||
currentTarget = GetTarget(fsmBehaviour);
|
||||
if (currentTarget == null) return;
|
||||
}
|
||||
|
||||
// Recalculate flee direction periodically
|
||||
currentFleeDirection = CalculateFleeDirection(fsmBehaviour);
|
||||
|
||||
// Move in flee direction
|
||||
MoveInDirection(fsmBehaviour, currentFleeDirection);
|
||||
|
||||
// Face player while fleeing if enabled
|
||||
if (facePlayer)
|
||||
{
|
||||
RotateTowardsPlayer(fsmBehaviour);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnExit(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
// Reset AI speed
|
||||
var aiController = fsmBehaviour as Invector.vCharacterController.AI.vIControlAI;
|
||||
if (aiController != null)
|
||||
{
|
||||
}
|
||||
|
||||
if (enableDebug) Debug.Log("[SA_FleeFromPlayer] Stopped fleeing");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the best direction to flee
|
||||
/// </summary>
|
||||
private Vector3 CalculateFleeDirection(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
Vector3 archerPos = fsmBehaviour.transform.position;
|
||||
Vector3 playerPos = currentTarget.position;
|
||||
|
||||
// Basic flee direction: away from player
|
||||
Vector3 awayFromPlayer = (archerPos - playerPos).normalized;
|
||||
|
||||
// If not avoiding obstacles, return simple direction
|
||||
if (!avoidObstacles)
|
||||
{
|
||||
return awayFromPlayer;
|
||||
}
|
||||
|
||||
// Try to find clear path
|
||||
Vector3 bestDirection = awayFromPlayer;
|
||||
float bestScore = EvaluateDirection(archerPos, awayFromPlayer, playerPos);
|
||||
|
||||
// Sample multiple directions around the flee vector
|
||||
for (int i = 0; i < directionSamples; i++)
|
||||
{
|
||||
float angle = (360f / directionSamples) * i;
|
||||
Vector3 testDirection = Quaternion.Euler(0f, angle, 0f) * awayFromPlayer;
|
||||
float score = EvaluateDirection(archerPos, testDirection, playerPos);
|
||||
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
bestDirection = testDirection;
|
||||
}
|
||||
}
|
||||
|
||||
return bestDirection;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates how good a flee direction is (higher = better)
|
||||
/// </summary>
|
||||
private float EvaluateDirection(Vector3 from, Vector3 direction, Vector3 playerPos)
|
||||
{
|
||||
float score = 0f;
|
||||
|
||||
// Check if path is clear
|
||||
Ray ray = new Ray(from + Vector3.up * 0.5f, direction);
|
||||
bool isBlocked = Physics.Raycast(ray, fleeDistance, obstacleMask, QueryTriggerInteraction.Ignore);
|
||||
|
||||
if (!isBlocked)
|
||||
{
|
||||
score += 10f; // Big bonus for clear path
|
||||
}
|
||||
|
||||
// Prefer directions that move away from player
|
||||
Vector3 awayFromPlayer = (from - playerPos).normalized;
|
||||
float alignment = Vector3.Dot(direction, awayFromPlayer);
|
||||
score += alignment * 5f;
|
||||
|
||||
// Check destination height (avoid running off cliffs)
|
||||
Vector3 destination = from + direction * fleeDistance;
|
||||
if (Physics.Raycast(destination + Vector3.up * 2f, Vector3.down, 5f, obstacleMask))
|
||||
{
|
||||
score += 3f; // Bonus for having ground
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves archer in specified direction
|
||||
/// </summary>
|
||||
private void MoveInDirection(vIFSMBehaviourController fsmBehaviour, Vector3 direction)
|
||||
{
|
||||
var aiController = fsmBehaviour as Invector.vCharacterController.AI.vIControlAI;
|
||||
|
||||
if (aiController == null) return;
|
||||
|
||||
// Calculate destination point
|
||||
Vector3 destination = fsmBehaviour.transform.position + direction * fleeDistance;
|
||||
|
||||
// Use Invector's AI movement
|
||||
aiController.MoveTo(destination);
|
||||
|
||||
// Apply speed multiplier
|
||||
var motor = aiController as Invector.vCharacterController.AI.vSimpleMeleeAI_Controller;
|
||||
if (motor != null)
|
||||
{
|
||||
// This would need to be adapted to your specific Invector version
|
||||
// The goal is to make the archer move faster while fleeing
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rotates archer to face player while backing away
|
||||
/// </summary>
|
||||
private void RotateTowardsPlayer(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
if (currentTarget == null) return;
|
||||
|
||||
Vector3 directionToPlayer = (currentTarget.position - fsmBehaviour.transform.position);
|
||||
directionToPlayer.y = 0f;
|
||||
|
||||
if (directionToPlayer.sqrMagnitude > 0.001f)
|
||||
{
|
||||
Quaternion targetRotation = Quaternion.LookRotation(directionToPlayer);
|
||||
fsmBehaviour.transform.rotation = Quaternion.RotateTowards(
|
||||
fsmBehaviour.transform.rotation,
|
||||
targetRotation,
|
||||
turnSpeed * Time.deltaTime
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private Transform GetTarget(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
// Try through AI controller
|
||||
var aiController = fsmBehaviour as Invector.vCharacterController.AI.vIControlAI;
|
||||
if (aiController != null && aiController.currentTarget != null)
|
||||
return aiController.currentTarget.transform;
|
||||
|
||||
// Fallback - find player
|
||||
GameObject player = GameObject.FindGameObjectWithTag("Player");
|
||||
return player?.transform;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
if (!showGizmos || !Application.isPlaying) return;
|
||||
|
||||
// Draw current flee direction
|
||||
if (currentFleeDirection != Vector3.zero)
|
||||
{
|
||||
Gizmos.color = Color.red;
|
||||
// This would need the archer's position to draw properly
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
}
|
||||
2
Assets/AI/_Archer/SA_FleeFromPlayer.cs.meta
Normal file
2
Assets/AI/_Archer/SA_FleeFromPlayer.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e5e9bb52e9254ac4895a3e9192dc2692
|
||||
105
Assets/AI/_Archer/SA_ShootArrow.cs
Normal file
105
Assets/AI/_Archer/SA_ShootArrow.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using Invector.vCharacterController.AI.FSMBehaviour;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ArcherEnemy
|
||||
{
|
||||
/// <summary>
|
||||
/// State action that makes archer shoot an arrow
|
||||
/// Should be called in OnStateEnter or OnStateUpdate
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Invector/FSM/Actions/Archer/Shoot Arrow")]
|
||||
public class SA_ShootArrow : vStateAction
|
||||
{
|
||||
public override string categoryName => "Archer/Combat";
|
||||
public override string defaultName => "Shoot Arrow";
|
||||
|
||||
[Header("Configuration")]
|
||||
[Tooltip("Shoot once per state enter, or continuously?")]
|
||||
public bool shootOnce = true;
|
||||
|
||||
[Tooltip("Time between shots when shooting continuously")]
|
||||
public float shootInterval = 2f;
|
||||
|
||||
[Header("Debug")]
|
||||
[Tooltip("Enable debug logging")]
|
||||
public bool enableDebug = false;
|
||||
|
||||
private float lastShootTime = -999f;
|
||||
private bool hasShotThisState = false;
|
||||
|
||||
public override void DoAction(vIFSMBehaviourController fsmBehaviour, vFSMComponentExecutionType executionType = vFSMComponentExecutionType.OnStateUpdate)
|
||||
{
|
||||
if (executionType == vFSMComponentExecutionType.OnStateEnter)
|
||||
{
|
||||
OnEnter(fsmBehaviour);
|
||||
}
|
||||
else if (executionType == vFSMComponentExecutionType.OnStateUpdate)
|
||||
{
|
||||
OnUpdate(fsmBehaviour);
|
||||
}
|
||||
else if (executionType == vFSMComponentExecutionType.OnStateExit)
|
||||
{
|
||||
OnExit(fsmBehaviour);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnter(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
hasShotThisState = false;
|
||||
|
||||
if (shootOnce)
|
||||
{
|
||||
TryShoot(fsmBehaviour);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnUpdate(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
if (shootOnce && hasShotThisState)
|
||||
{
|
||||
return; // Already shot once this state
|
||||
}
|
||||
|
||||
// Check interval for continuous shooting
|
||||
if (!shootOnce && Time.time >= lastShootTime + shootInterval)
|
||||
{
|
||||
TryShoot(fsmBehaviour);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnExit(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
// Stop any shooting sequence
|
||||
var shootingAI = fsmBehaviour.gameObject.GetComponent<ArcherShootingAI>();
|
||||
if (shootingAI != null)
|
||||
{
|
||||
shootingAI.StopShooting();
|
||||
}
|
||||
}
|
||||
|
||||
private void TryShoot(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
var shootingAI = fsmBehaviour.gameObject.GetComponent<ArcherShootingAI>();
|
||||
|
||||
if (shootingAI == null)
|
||||
{
|
||||
if (enableDebug) Debug.LogError("[SA_ShootArrow] No ArcherShootingAI component found!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to shoot
|
||||
if (shootingAI.CanShoot())
|
||||
{
|
||||
shootingAI.StartShooting();
|
||||
hasShotThisState = true;
|
||||
lastShootTime = Time.time;
|
||||
|
||||
if (enableDebug) Debug.Log("[SA_ShootArrow] Shooting arrow");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (enableDebug) Debug.Log("[SA_ShootArrow] Cannot shoot - conditions not met");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/AI/_Archer/SA_ShootArrow.cs.meta
Normal file
2
Assets/AI/_Archer/SA_ShootArrow.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cbef6f8d442ae24408f1132173eca9e0
|
||||
@@ -1,290 +1,290 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,205 +1,205 @@
|
||||
using Invector.vCharacterController.AI.FSMBehaviour;
|
||||
using UnityEngine;
|
||||
|
||||
namespace DemonBoss.Magic
|
||||
{
|
||||
/// <summary>
|
||||
/// Decision node checking cooldown for different boss abilities
|
||||
/// Stores Time.time in FSM timers and checks if required cooldown time has passed
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Invector/FSM/Decisions/DemonBoss/Check Cooldown")]
|
||||
public class DEC_CheckCooldown : vStateDecision
|
||||
{
|
||||
public override string categoryName => "DemonBoss/Magic";
|
||||
public override string defaultName => "Check Cooldown";
|
||||
|
||||
[Header("Cooldown Configuration")]
|
||||
[Tooltip("Unique key for this ability (e.g. 'Shield', 'Turret', 'Meteor')")]
|
||||
public string cooldownKey = "Shield";
|
||||
|
||||
[Tooltip("Cooldown time in seconds")]
|
||||
public float cooldownTime = 10f;
|
||||
|
||||
[Tooltip("Whether ability should be available immediately at fight start")]
|
||||
public bool availableAtStart = true;
|
||||
|
||||
[Header("Debug")]
|
||||
[Tooltip("Enable debug logging")]
|
||||
public bool enableDebug = false;
|
||||
|
||||
/// <summary>
|
||||
/// Main method checking if ability is available
|
||||
/// </summary>
|
||||
/// <returns>True if cooldown has passed and ability can be used</returns>
|
||||
public override bool Decide(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
if (fsmBehaviour == null)
|
||||
{
|
||||
if (enableDebug) Debug.LogWarning($"[DEC_CheckCooldown] No FSM Behaviour for key: {cooldownKey}");
|
||||
return false;
|
||||
}
|
||||
|
||||
string timerKey = "cooldown_" + cooldownKey;
|
||||
|
||||
if (!fsmBehaviour.HasTimer(timerKey))
|
||||
{
|
||||
if (availableAtStart)
|
||||
{
|
||||
if (enableDebug) Debug.Log($"[DEC_CheckCooldown] First use for {cooldownKey} - available");
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
SetCooldown(fsmBehaviour, cooldownTime);
|
||||
if (enableDebug) Debug.Log($"[DEC_CheckCooldown] First use for {cooldownKey} - setting cooldown");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
float lastUsedTime = fsmBehaviour.GetTimer(timerKey);
|
||||
float timeSinceLastUse = Time.time - lastUsedTime;
|
||||
|
||||
bool isAvailable = timeSinceLastUse >= cooldownTime;
|
||||
|
||||
if (enableDebug)
|
||||
{
|
||||
if (isAvailable)
|
||||
{
|
||||
Debug.Log($"[DEC_CheckCooldown] {cooldownKey} available - {timeSinceLastUse:F1}s passed of required {cooldownTime}s");
|
||||
}
|
||||
else
|
||||
{
|
||||
float remainingTime = cooldownTime - timeSinceLastUse;
|
||||
Debug.Log($"[DEC_CheckCooldown] {cooldownKey} on cooldown - {remainingTime:F1}s remaining");
|
||||
}
|
||||
}
|
||||
|
||||
return isAvailable;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets cooldown for ability - call this after using ability
|
||||
/// </summary>
|
||||
public void SetCooldown(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
SetCooldown(fsmBehaviour, cooldownTime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets cooldown with custom time
|
||||
/// </summary>
|
||||
/// <param name="fsmBehaviour">FSM behaviour reference</param>
|
||||
/// <param name="customCooldownTime">Custom cooldown time</param>
|
||||
public void SetCooldown(vIFSMBehaviourController fsmBehaviour, float customCooldownTime)
|
||||
{
|
||||
if (fsmBehaviour == null)
|
||||
{
|
||||
if (enableDebug) Debug.LogWarning($"[DEC_CheckCooldown] Cannot set cooldown - no FSM Behaviour");
|
||||
return;
|
||||
}
|
||||
|
||||
string timerKey = "cooldown_" + cooldownKey;
|
||||
|
||||
fsmBehaviour.SetTimer(timerKey, Time.time);
|
||||
|
||||
if (enableDebug)
|
||||
{
|
||||
Debug.Log($"[DEC_CheckCooldown] Set cooldown for {cooldownKey}: {customCooldownTime}s");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets cooldown - ability becomes immediately available
|
||||
/// </summary>
|
||||
public void ResetCooldown(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
if (fsmBehaviour == null)
|
||||
{
|
||||
if (enableDebug) Debug.LogWarning($"[DEC_CheckCooldown] Cannot reset cooldown - no FSM Behaviour");
|
||||
return;
|
||||
}
|
||||
|
||||
string timerKey = "cooldown_" + cooldownKey;
|
||||
|
||||
float pastTime = Time.time - cooldownTime - 1f;
|
||||
fsmBehaviour.SetTimer(timerKey, pastTime);
|
||||
|
||||
if (enableDebug) Debug.Log($"[DEC_CheckCooldown] Reset cooldown for {cooldownKey}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns remaining cooldown time in seconds
|
||||
/// </summary>
|
||||
/// <returns>Remaining cooldown time (0 if available)</returns>
|
||||
public float GetRemainingCooldown(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
if (fsmBehaviour == null) return 0f;
|
||||
|
||||
string timerKey = "cooldown_" + cooldownKey;
|
||||
|
||||
if (!fsmBehaviour.HasTimer(timerKey))
|
||||
{
|
||||
return availableAtStart ? 0f : cooldownTime;
|
||||
}
|
||||
|
||||
float lastUsedTime = fsmBehaviour.GetTimer(timerKey);
|
||||
float timeSinceLastUse = Time.time - lastUsedTime;
|
||||
|
||||
return Mathf.Max(0f, cooldownTime - timeSinceLastUse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if ability is available without running main Decision logic
|
||||
/// </summary>
|
||||
/// <returns>True if ability is available</returns>
|
||||
public bool IsAvailable(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
return GetRemainingCooldown(fsmBehaviour) <= 0f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns cooldown progress percentage (0-1)
|
||||
/// </summary>
|
||||
/// <returns>Progress percentage: 0 = just used, 1 = fully recharged</returns>
|
||||
public float GetCooldownProgress(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
if (cooldownTime <= 0f) return 1f;
|
||||
|
||||
float remainingTime = GetRemainingCooldown(fsmBehaviour);
|
||||
return 1f - (remainingTime / cooldownTime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method for setting cooldown from external code (e.g. from StateAction)
|
||||
/// </summary>
|
||||
/// <param name="fsmBehaviour">FSM reference</param>
|
||||
/// <param name="key">Ability key</param>
|
||||
/// <param name="cooldown">Cooldown time</param>
|
||||
public static void SetCooldownStatic(vIFSMBehaviourController fsmBehaviour, string key, float cooldown)
|
||||
{
|
||||
if (fsmBehaviour == null) return;
|
||||
|
||||
string timerKey = "cooldown_" + key;
|
||||
fsmBehaviour.SetTimer(timerKey, Time.time);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method for checking cooldown from external code
|
||||
/// </summary>
|
||||
/// <param name="fsmBehaviour">FSM reference</param>
|
||||
/// <param name="key">Ability key</param>
|
||||
/// <param name="cooldown">Cooldown time</param>
|
||||
/// <returns>True if available</returns>
|
||||
public static bool CheckCooldownStatic(vIFSMBehaviourController fsmBehaviour, string key, float cooldown)
|
||||
{
|
||||
if (fsmBehaviour == null) return false;
|
||||
|
||||
string timerKey = "cooldown_" + key;
|
||||
|
||||
if (!fsmBehaviour.HasTimer(timerKey)) return true;
|
||||
|
||||
float lastUsedTime = fsmBehaviour.GetTimer(timerKey);
|
||||
return (Time.time - lastUsedTime) >= cooldown;
|
||||
}
|
||||
}
|
||||
using Invector.vCharacterController.AI.FSMBehaviour;
|
||||
using UnityEngine;
|
||||
|
||||
namespace DemonBoss.Magic
|
||||
{
|
||||
/// <summary>
|
||||
/// Decision node checking cooldown for different boss abilities
|
||||
/// Stores Time.time in FSM timers and checks if required cooldown time has passed
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Invector/FSM/Decisions/DemonBoss/Check Cooldown")]
|
||||
public class DEC_CheckCooldown : vStateDecision
|
||||
{
|
||||
public override string categoryName => "DemonBoss/Magic";
|
||||
public override string defaultName => "Check Cooldown";
|
||||
|
||||
[Header("Cooldown Configuration")]
|
||||
[Tooltip("Unique key for this ability (e.g. 'Shield', 'Turret', 'Meteor')")]
|
||||
public string cooldownKey = "Shield";
|
||||
|
||||
[Tooltip("Cooldown time in seconds")]
|
||||
public float cooldownTime = 10f;
|
||||
|
||||
[Tooltip("Whether ability should be available immediately at fight start")]
|
||||
public bool availableAtStart = true;
|
||||
|
||||
[Header("Debug")]
|
||||
[Tooltip("Enable debug logging")]
|
||||
public bool enableDebug = false;
|
||||
|
||||
/// <summary>
|
||||
/// Main method checking if ability is available
|
||||
/// </summary>
|
||||
/// <returns>True if cooldown has passed and ability can be used</returns>
|
||||
public override bool Decide(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
if (fsmBehaviour == null)
|
||||
{
|
||||
if (enableDebug) Debug.LogWarning($"[DEC_CheckCooldown] No FSM Behaviour for key: {cooldownKey}");
|
||||
return false;
|
||||
}
|
||||
|
||||
string timerKey = "cooldown_" + cooldownKey;
|
||||
|
||||
if (!fsmBehaviour.HasTimer(timerKey))
|
||||
{
|
||||
if (availableAtStart)
|
||||
{
|
||||
if (enableDebug) Debug.Log($"[DEC_CheckCooldown] First use for {cooldownKey} - available");
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
SetCooldown(fsmBehaviour, cooldownTime);
|
||||
if (enableDebug) Debug.Log($"[DEC_CheckCooldown] First use for {cooldownKey} - setting cooldown");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
float lastUsedTime = fsmBehaviour.GetTimer(timerKey);
|
||||
float timeSinceLastUse = Time.time - lastUsedTime;
|
||||
|
||||
bool isAvailable = timeSinceLastUse >= cooldownTime;
|
||||
|
||||
if (enableDebug)
|
||||
{
|
||||
if (isAvailable)
|
||||
{
|
||||
Debug.Log($"[DEC_CheckCooldown] {cooldownKey} available - {timeSinceLastUse:F1}s passed of required {cooldownTime}s");
|
||||
}
|
||||
else
|
||||
{
|
||||
float remainingTime = cooldownTime - timeSinceLastUse;
|
||||
Debug.Log($"[DEC_CheckCooldown] {cooldownKey} on cooldown - {remainingTime:F1}s remaining");
|
||||
}
|
||||
}
|
||||
|
||||
return isAvailable;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets cooldown for ability - call this after using ability
|
||||
/// </summary>
|
||||
public void SetCooldown(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
SetCooldown(fsmBehaviour, cooldownTime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets cooldown with custom time
|
||||
/// </summary>
|
||||
/// <param name="fsmBehaviour">FSM behaviour reference</param>
|
||||
/// <param name="customCooldownTime">Custom cooldown time</param>
|
||||
public void SetCooldown(vIFSMBehaviourController fsmBehaviour, float customCooldownTime)
|
||||
{
|
||||
if (fsmBehaviour == null)
|
||||
{
|
||||
if (enableDebug) Debug.LogWarning($"[DEC_CheckCooldown] Cannot set cooldown - no FSM Behaviour");
|
||||
return;
|
||||
}
|
||||
|
||||
string timerKey = "cooldown_" + cooldownKey;
|
||||
|
||||
fsmBehaviour.SetTimer(timerKey, Time.time);
|
||||
|
||||
if (enableDebug)
|
||||
{
|
||||
Debug.Log($"[DEC_CheckCooldown] Set cooldown for {cooldownKey}: {customCooldownTime}s");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets cooldown - ability becomes immediately available
|
||||
/// </summary>
|
||||
public void ResetCooldown(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
if (fsmBehaviour == null)
|
||||
{
|
||||
if (enableDebug) Debug.LogWarning($"[DEC_CheckCooldown] Cannot reset cooldown - no FSM Behaviour");
|
||||
return;
|
||||
}
|
||||
|
||||
string timerKey = "cooldown_" + cooldownKey;
|
||||
|
||||
float pastTime = Time.time - cooldownTime - 1f;
|
||||
fsmBehaviour.SetTimer(timerKey, pastTime);
|
||||
|
||||
if (enableDebug) Debug.Log($"[DEC_CheckCooldown] Reset cooldown for {cooldownKey}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns remaining cooldown time in seconds
|
||||
/// </summary>
|
||||
/// <returns>Remaining cooldown time (0 if available)</returns>
|
||||
public float GetRemainingCooldown(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
if (fsmBehaviour == null) return 0f;
|
||||
|
||||
string timerKey = "cooldown_" + cooldownKey;
|
||||
|
||||
if (!fsmBehaviour.HasTimer(timerKey))
|
||||
{
|
||||
return availableAtStart ? 0f : cooldownTime;
|
||||
}
|
||||
|
||||
float lastUsedTime = fsmBehaviour.GetTimer(timerKey);
|
||||
float timeSinceLastUse = Time.time - lastUsedTime;
|
||||
|
||||
return Mathf.Max(0f, cooldownTime - timeSinceLastUse);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if ability is available without running main Decision logic
|
||||
/// </summary>
|
||||
/// <returns>True if ability is available</returns>
|
||||
public bool IsAvailable(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
return GetRemainingCooldown(fsmBehaviour) <= 0f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns cooldown progress percentage (0-1)
|
||||
/// </summary>
|
||||
/// <returns>Progress percentage: 0 = just used, 1 = fully recharged</returns>
|
||||
public float GetCooldownProgress(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
if (cooldownTime <= 0f) return 1f;
|
||||
|
||||
float remainingTime = GetRemainingCooldown(fsmBehaviour);
|
||||
return 1f - (remainingTime / cooldownTime);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method for setting cooldown from external code (e.g. from StateAction)
|
||||
/// </summary>
|
||||
/// <param name="fsmBehaviour">FSM reference</param>
|
||||
/// <param name="key">Ability key</param>
|
||||
/// <param name="cooldown">Cooldown time</param>
|
||||
public static void SetCooldownStatic(vIFSMBehaviourController fsmBehaviour, string key, float cooldown)
|
||||
{
|
||||
if (fsmBehaviour == null) return;
|
||||
|
||||
string timerKey = "cooldown_" + key;
|
||||
fsmBehaviour.SetTimer(timerKey, Time.time);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method for checking cooldown from external code
|
||||
/// </summary>
|
||||
/// <param name="fsmBehaviour">FSM reference</param>
|
||||
/// <param name="key">Ability key</param>
|
||||
/// <param name="cooldown">Cooldown time</param>
|
||||
/// <returns>True if available</returns>
|
||||
public static bool CheckCooldownStatic(vIFSMBehaviourController fsmBehaviour, string key, float cooldown)
|
||||
{
|
||||
if (fsmBehaviour == null) return false;
|
||||
|
||||
string timerKey = "cooldown_" + key;
|
||||
|
||||
if (!fsmBehaviour.HasTimer(timerKey)) return true;
|
||||
|
||||
float lastUsedTime = fsmBehaviour.GetTimer(timerKey);
|
||||
return (Time.time - lastUsedTime) >= cooldown;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,105 +1,105 @@
|
||||
using Invector.vCharacterController.AI.FSMBehaviour;
|
||||
using UnityEngine;
|
||||
|
||||
namespace DemonBoss.Magic
|
||||
{
|
||||
/// <summary>
|
||||
/// Decision checking if target has clear sky above (for meteor)
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Invector/FSM/Decisions/DemonBoss/Target Clear Sky")]
|
||||
public class DEC_TargetClearSky : vStateDecision
|
||||
{
|
||||
public override string categoryName => "DemonBoss/Magic";
|
||||
public override string defaultName => "Target Clear Sky";
|
||||
|
||||
[Header("Sky Check Configuration")]
|
||||
[Tooltip("Check height above target")]
|
||||
public float checkHeight = 25f;
|
||||
|
||||
[Tooltip("Obstacle check radius")]
|
||||
public float checkRadius = 2f;
|
||||
|
||||
[Tooltip("Obstacle layer mask")]
|
||||
public LayerMask obstacleLayerMask = -1;
|
||||
|
||||
[Header("Debug")]
|
||||
[Tooltip("Enable debug logging")]
|
||||
public bool enableDebug = false;
|
||||
|
||||
[Tooltip("Show gizmos in Scene View")]
|
||||
public bool showGizmos = true;
|
||||
|
||||
public override bool Decide(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
Transform target = GetTarget(fsmBehaviour);
|
||||
if (target == null)
|
||||
{
|
||||
if (enableDebug) Debug.Log("[DEC_TargetClearSky] No target found");
|
||||
return false;
|
||||
}
|
||||
|
||||
bool isClear = IsSkyClear(target.position);
|
||||
|
||||
if (enableDebug)
|
||||
{
|
||||
Debug.Log($"[DEC_TargetClearSky] Sky above target: {(isClear ? "CLEAR" : "BLOCKED")}");
|
||||
}
|
||||
|
||||
return isClear;
|
||||
}
|
||||
|
||||
private bool IsSkyClear(Vector3 targetPosition)
|
||||
{
|
||||
Vector3 skyCheckPoint = targetPosition + Vector3.up * checkHeight;
|
||||
|
||||
if (Physics.CheckSphere(skyCheckPoint, checkRadius, obstacleLayerMask))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Ray skyRay = new Ray(skyCheckPoint, Vector3.down);
|
||||
RaycastHit[] hits = Physics.RaycastAll(skyRay, checkHeight, obstacleLayerMask);
|
||||
|
||||
foreach (var hit in hits)
|
||||
{
|
||||
if (hit.point.y <= targetPosition.y + 0.5f) continue;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private Transform GetTarget(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
// Try through AI controller
|
||||
var aiController = fsmBehaviour as Invector.vCharacterController.AI.vIControlAI;
|
||||
if (aiController != null && aiController.currentTarget != null)
|
||||
return aiController.currentTarget.transform;
|
||||
|
||||
// Fallback - find player
|
||||
GameObject player = GameObject.FindGameObjectWithTag("Player");
|
||||
return player?.transform;
|
||||
}
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
if (!showGizmos) return;
|
||||
|
||||
GameObject player = GameObject.FindGameObjectWithTag("Player");
|
||||
if (player == null) return;
|
||||
|
||||
Vector3 targetPos = player.transform.position;
|
||||
Vector3 skyCheckPoint = targetPos + Vector3.up * checkHeight;
|
||||
|
||||
Gizmos.color = Color.cyan;
|
||||
Gizmos.DrawWireSphere(skyCheckPoint, checkRadius);
|
||||
|
||||
Gizmos.color = Color.yellow;
|
||||
Gizmos.DrawLine(targetPos, skyCheckPoint);
|
||||
|
||||
Gizmos.color = Color.red;
|
||||
Gizmos.DrawWireSphere(targetPos, 0.5f);
|
||||
}
|
||||
}
|
||||
using Invector.vCharacterController.AI.FSMBehaviour;
|
||||
using UnityEngine;
|
||||
|
||||
namespace DemonBoss.Magic
|
||||
{
|
||||
/// <summary>
|
||||
/// Decision checking if target has clear sky above (for meteor)
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Invector/FSM/Decisions/DemonBoss/Target Clear Sky")]
|
||||
public class DEC_TargetClearSky : vStateDecision
|
||||
{
|
||||
public override string categoryName => "DemonBoss/Magic";
|
||||
public override string defaultName => "Target Clear Sky";
|
||||
|
||||
[Header("Sky Check Configuration")]
|
||||
[Tooltip("Check height above target")]
|
||||
public float checkHeight = 25f;
|
||||
|
||||
[Tooltip("Obstacle check radius")]
|
||||
public float checkRadius = 2f;
|
||||
|
||||
[Tooltip("Obstacle layer mask")]
|
||||
public LayerMask obstacleLayerMask = -1;
|
||||
|
||||
[Header("Debug")]
|
||||
[Tooltip("Enable debug logging")]
|
||||
public bool enableDebug = false;
|
||||
|
||||
[Tooltip("Show gizmos in Scene View")]
|
||||
public bool showGizmos = true;
|
||||
|
||||
public override bool Decide(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
Transform target = GetTarget(fsmBehaviour);
|
||||
if (target == null)
|
||||
{
|
||||
if (enableDebug) Debug.Log("[DEC_TargetClearSky] No target found");
|
||||
return false;
|
||||
}
|
||||
|
||||
bool isClear = IsSkyClear(target.position);
|
||||
|
||||
if (enableDebug)
|
||||
{
|
||||
Debug.Log($"[DEC_TargetClearSky] Sky above target: {(isClear ? "CLEAR" : "BLOCKED")}");
|
||||
}
|
||||
|
||||
return isClear;
|
||||
}
|
||||
|
||||
private bool IsSkyClear(Vector3 targetPosition)
|
||||
{
|
||||
Vector3 skyCheckPoint = targetPosition + Vector3.up * checkHeight;
|
||||
|
||||
if (Physics.CheckSphere(skyCheckPoint, checkRadius, obstacleLayerMask))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Ray skyRay = new Ray(skyCheckPoint, Vector3.down);
|
||||
RaycastHit[] hits = Physics.RaycastAll(skyRay, checkHeight, obstacleLayerMask);
|
||||
|
||||
foreach (var hit in hits)
|
||||
{
|
||||
if (hit.point.y <= targetPosition.y + 0.5f) continue;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private Transform GetTarget(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
// Try through AI controller
|
||||
var aiController = fsmBehaviour as Invector.vCharacterController.AI.vIControlAI;
|
||||
if (aiController != null && aiController.currentTarget != null)
|
||||
return aiController.currentTarget.transform;
|
||||
|
||||
// Fallback - find player
|
||||
GameObject player = GameObject.FindGameObjectWithTag("Player");
|
||||
return player?.transform;
|
||||
}
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
if (!showGizmos) return;
|
||||
|
||||
GameObject player = GameObject.FindGameObjectWithTag("Player");
|
||||
if (player == null) return;
|
||||
|
||||
Vector3 targetPos = player.transform.position;
|
||||
Vector3 skyCheckPoint = targetPos + Vector3.up * checkHeight;
|
||||
|
||||
Gizmos.color = Color.cyan;
|
||||
Gizmos.DrawWireSphere(skyCheckPoint, checkRadius);
|
||||
|
||||
Gizmos.color = Color.yellow;
|
||||
Gizmos.DrawLine(targetPos, skyCheckPoint);
|
||||
|
||||
Gizmos.color = Color.red;
|
||||
Gizmos.DrawWireSphere(targetPos, 0.5f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,58 +1,58 @@
|
||||
using Invector.vCharacterController.AI.FSMBehaviour;
|
||||
using UnityEngine;
|
||||
|
||||
namespace DemonBoss.Magic
|
||||
{
|
||||
/// <summary>
|
||||
/// Decision checking if target is far away (for Turret ability)
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Invector/FSM/Decisions/DemonBoss/Target Far")]
|
||||
public class DEC_TargetFar : vStateDecision
|
||||
{
|
||||
public override string categoryName => "DemonBoss/Magic";
|
||||
public override string defaultName => "Target Far";
|
||||
|
||||
[Header("Distance Configuration")]
|
||||
[Tooltip("Minimum distance for target to be considered far")]
|
||||
public float minDistance = 8f;
|
||||
|
||||
[Tooltip("Maximum distance for checking")]
|
||||
public float maxDistance = 30f;
|
||||
|
||||
[Header("Debug")]
|
||||
[Tooltip("Enable debug logging")]
|
||||
public bool enableDebug = false;
|
||||
|
||||
public override bool Decide(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
Transform target = GetTarget(fsmBehaviour);
|
||||
if (target == null)
|
||||
{
|
||||
if (enableDebug) Debug.Log("[DEC_TargetFar] No target found");
|
||||
return false;
|
||||
}
|
||||
|
||||
float distance = Vector3.Distance(fsmBehaviour.transform.position, target.position);
|
||||
bool isFar = distance >= minDistance && distance <= maxDistance;
|
||||
|
||||
if (enableDebug)
|
||||
{
|
||||
Debug.Log($"[DEC_TargetFar] Distance to target: {distance:F1}m - {(isFar ? "FAR" : "CLOSE")}");
|
||||
}
|
||||
|
||||
return isFar;
|
||||
}
|
||||
|
||||
private Transform GetTarget(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
// Try through AI controller
|
||||
var aiController = fsmBehaviour as Invector.vCharacterController.AI.vIControlAI;
|
||||
if (aiController != null && aiController.currentTarget != null)
|
||||
return aiController.currentTarget.transform;
|
||||
|
||||
// Fallback - find player
|
||||
GameObject player = GameObject.FindGameObjectWithTag("Player");
|
||||
return player?.transform;
|
||||
}
|
||||
}
|
||||
using Invector.vCharacterController.AI.FSMBehaviour;
|
||||
using UnityEngine;
|
||||
|
||||
namespace DemonBoss.Magic
|
||||
{
|
||||
/// <summary>
|
||||
/// Decision checking if target is far away (for Turret ability)
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Invector/FSM/Decisions/DemonBoss/Target Far")]
|
||||
public class DEC_TargetFar : vStateDecision
|
||||
{
|
||||
public override string categoryName => "DemonBoss/Magic";
|
||||
public override string defaultName => "Target Far";
|
||||
|
||||
[Header("Distance Configuration")]
|
||||
[Tooltip("Minimum distance for target to be considered far")]
|
||||
public float minDistance = 8f;
|
||||
|
||||
[Tooltip("Maximum distance for checking")]
|
||||
public float maxDistance = 30f;
|
||||
|
||||
[Header("Debug")]
|
||||
[Tooltip("Enable debug logging")]
|
||||
public bool enableDebug = false;
|
||||
|
||||
public override bool Decide(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
Transform target = GetTarget(fsmBehaviour);
|
||||
if (target == null)
|
||||
{
|
||||
if (enableDebug) Debug.Log("[DEC_TargetFar] No target found");
|
||||
return false;
|
||||
}
|
||||
|
||||
float distance = Vector3.Distance(fsmBehaviour.transform.position, target.position);
|
||||
bool isFar = distance >= minDistance && distance <= maxDistance;
|
||||
|
||||
if (enableDebug)
|
||||
{
|
||||
Debug.Log($"[DEC_TargetFar] Distance to target: {distance:F1}m - {(isFar ? "FAR" : "CLOSE")}");
|
||||
}
|
||||
|
||||
return isFar;
|
||||
}
|
||||
|
||||
private Transform GetTarget(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
// Try through AI controller
|
||||
var aiController = fsmBehaviour as Invector.vCharacterController.AI.vIControlAI;
|
||||
if (aiController != null && aiController.currentTarget != null)
|
||||
return aiController.currentTarget.transform;
|
||||
|
||||
// Fallback - find player
|
||||
GameObject player = GameObject.FindGameObjectWithTag("Player");
|
||||
return player?.transform;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,359 +1,359 @@
|
||||
using Invector.vCharacterController.AI.FSMBehaviour;
|
||||
using Lean.Pool;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Animations;
|
||||
using UnityEngine.Playables;
|
||||
|
||||
namespace DemonBoss.Magic
|
||||
{
|
||||
/// <summary>
|
||||
/// Spawns multiple meteors behind the BOSS and launches them toward the player's position.
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Invector/FSM/Actions/DemonBoss/Call Meteor")]
|
||||
public class SA_CallMeteor : vStateAction
|
||||
{
|
||||
public override string categoryName => "DemonBoss/Magic";
|
||||
public override string defaultName => "Call Meteor";
|
||||
|
||||
[Header("Meteor Setup")]
|
||||
[Tooltip("Prefab with MeteorProjectile component")]
|
||||
public GameObject meteorPrefab;
|
||||
|
||||
[Tooltip("Distance behind the BOSS to spawn meteor (meters)")]
|
||||
public float behindBossDistance = 3f;
|
||||
|
||||
[Tooltip("Height above the BOSS to spawn meteor (meters)")]
|
||||
public float aboveBossHeight = 8f;
|
||||
|
||||
[Header("Multi-Meteor Configuration")]
|
||||
[Tooltip("Number of meteors to spawn in sequence")]
|
||||
public int meteorCount = 5;
|
||||
|
||||
[Tooltip("Delay before first meteor spawns (wind-up)")]
|
||||
public float initialCastDelay = 0.4f;
|
||||
|
||||
[Tooltip("Time between each meteor spawn")]
|
||||
public float meteorSpawnInterval = 0.6f;
|
||||
|
||||
[Header("Muzzle Flash Effect")]
|
||||
[Tooltip("Particle effect prefab for muzzle flash at spawn position")]
|
||||
public GameObject muzzleFlashPrefab;
|
||||
|
||||
[Tooltip("Duration to keep muzzle flash alive (seconds)")]
|
||||
public float muzzleFlashDuration = 1.5f;
|
||||
|
||||
[Header("Targeting")]
|
||||
[Tooltip("Tag used to find the target (usually Player)")]
|
||||
public string targetTag = "Player";
|
||||
|
||||
[Header("One-off Overlay Clip (No Animator Params)")]
|
||||
public AnimationClip overlayClip;
|
||||
|
||||
[Tooltip("Playback speed (1 = normal)")] public float overlaySpeed = 1f;
|
||||
[Tooltip("Blend-in seconds (instant in this minimal impl)")] public float overlayFadeIn = 0.10f;
|
||||
[Tooltip("Blend-out seconds (instant in this minimal impl)")] public float overlayFadeOut = 0.10f;
|
||||
|
||||
[Header("Debug")]
|
||||
public bool enableDebug = false;
|
||||
|
||||
private Transform _boss;
|
||||
private Transform _target;
|
||||
|
||||
// --- Multi-meteor state ---
|
||||
private int _meteorsSpawned = 0;
|
||||
|
||||
private bool _spawningActive = false;
|
||||
|
||||
// --- Playables runtime ---
|
||||
private PlayableGraph _overlayGraph;
|
||||
|
||||
private AnimationPlayableOutput _overlayOutput;
|
||||
private AnimationClipPlayable _overlayPlayable;
|
||||
private bool _overlayPlaying;
|
||||
private float _overlayStopAtTime;
|
||||
|
||||
public override void DoAction(vIFSMBehaviourController fsm, vFSMComponentExecutionType execType = vFSMComponentExecutionType.OnStateUpdate)
|
||||
{
|
||||
if (execType == vFSMComponentExecutionType.OnStateEnter)
|
||||
{
|
||||
OnEnter(fsm);
|
||||
}
|
||||
else if (execType == vFSMComponentExecutionType.OnStateUpdate)
|
||||
{
|
||||
// Keep the state active until all meteors are spawned
|
||||
if (_spawningActive && _meteorsSpawned < meteorCount)
|
||||
{
|
||||
// Don't allow the state to exit while spawning
|
||||
return;
|
||||
}
|
||||
|
||||
if (_overlayPlaying && Time.time >= _overlayStopAtTime)
|
||||
{
|
||||
StopOverlayWithFade();
|
||||
}
|
||||
|
||||
// Only signal completion when all meteors are done AND overlay is finished
|
||||
if (!_spawningActive && !_overlayPlaying && _meteorsSpawned >= meteorCount)
|
||||
{
|
||||
if (enableDebug) Debug.Log($"[SA_CallMeteor] Sequence complete, ready to exit state");
|
||||
}
|
||||
}
|
||||
else if (execType == vFSMComponentExecutionType.OnStateExit)
|
||||
{
|
||||
OnExit(fsm);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnter(vIFSMBehaviourController fsm)
|
||||
{
|
||||
_boss = fsm.transform;
|
||||
_target = GameObject.FindGameObjectWithTag(targetTag)?.transform;
|
||||
|
||||
if (_target == null)
|
||||
{
|
||||
if (enableDebug) Debug.LogWarning("[SA_CallMeteor] No target found – abort");
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset multi-meteor state
|
||||
_meteorsSpawned = 0;
|
||||
_spawningActive = true;
|
||||
|
||||
// SET COOLDOWN IMMEDIATELY when meteor ability is used
|
||||
DEC_CheckCooldown.SetCooldownStatic(fsm, "Meteor", 80f);
|
||||
|
||||
if (enableDebug) Debug.Log($"[SA_CallMeteor] Boss: {_boss.name}, Target: {_target.name}, Count: {meteorCount}");
|
||||
|
||||
// Fire overlay clip (no Animator params)
|
||||
PlayOverlayOnce(_boss);
|
||||
|
||||
// Start the meteor sequence
|
||||
if (initialCastDelay > 0f)
|
||||
_boss.gameObject.AddComponent<DelayedInvoker>().Init(initialCastDelay, StartMeteorSequence);
|
||||
else
|
||||
StartMeteorSequence();
|
||||
}
|
||||
|
||||
private void OnExit(vIFSMBehaviourController fsm)
|
||||
{
|
||||
// Stop any active spawning
|
||||
_spawningActive = false;
|
||||
StopOverlayImmediate();
|
||||
|
||||
// Clean up any DelayedInvokers attached to the boss
|
||||
var invokers = _boss?.GetComponents<DelayedInvoker>();
|
||||
if (invokers != null)
|
||||
{
|
||||
foreach (var invoker in invokers)
|
||||
{
|
||||
if (invoker != null) Object.Destroy(invoker);
|
||||
}
|
||||
}
|
||||
|
||||
if (enableDebug) Debug.Log($"[SA_CallMeteor] State exited. Spawned {_meteorsSpawned}/{meteorCount} meteors");
|
||||
}
|
||||
|
||||
private void StartMeteorSequence()
|
||||
{
|
||||
if (!_spawningActive) return;
|
||||
SpawnNextMeteor();
|
||||
}
|
||||
|
||||
private void SpawnNextMeteor()
|
||||
{
|
||||
if (!_spawningActive || _meteorsSpawned >= meteorCount) return;
|
||||
|
||||
if (_boss == null || _target == null)
|
||||
{
|
||||
if (enableDebug) Debug.LogError("[SA_CallMeteor] Missing boss or target reference");
|
||||
return;
|
||||
}
|
||||
|
||||
SpawnSingleMeteor();
|
||||
_meteorsSpawned++;
|
||||
|
||||
if (enableDebug) Debug.Log($"[SA_CallMeteor] Spawned meteor {_meteorsSpawned}/{meteorCount}");
|
||||
|
||||
// Schedule next meteor if needed
|
||||
if (_meteorsSpawned < meteorCount && _spawningActive)
|
||||
{
|
||||
if (enableDebug) Debug.Log($"[SA_CallMeteor] Scheduling next meteor in {meteorSpawnInterval}s");
|
||||
_boss.gameObject.AddComponent<DelayedInvoker>().Init(meteorSpawnInterval, SpawnNextMeteor);
|
||||
}
|
||||
else
|
||||
{
|
||||
// All meteors spawned
|
||||
_spawningActive = false;
|
||||
if (enableDebug) Debug.Log("[SA_CallMeteor] All meteors spawned, sequence complete");
|
||||
}
|
||||
}
|
||||
|
||||
private void SpawnSingleMeteor()
|
||||
{
|
||||
if (meteorPrefab == null)
|
||||
{
|
||||
if (enableDebug) Debug.LogError("[SA_CallMeteor] Missing meteorPrefab");
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate spawn position: behind the BOSS + height
|
||||
Vector3 bossForward = _boss.forward.normalized;
|
||||
Vector3 behindBoss = _boss.position - (bossForward * behindBossDistance);
|
||||
Vector3 spawnPos = behindBoss + Vector3.up * aboveBossHeight;
|
||||
|
||||
// Add slight randomization to spawn position for multiple meteors
|
||||
if (_meteorsSpawned > 0)
|
||||
{
|
||||
Vector3 randomOffset = new Vector3(
|
||||
Random.Range(-1f, 1f),
|
||||
Random.Range(-0.5f, 0.5f),
|
||||
Random.Range(-1f, 1f)
|
||||
);
|
||||
spawnPos += randomOffset;
|
||||
}
|
||||
|
||||
if (enableDebug) Debug.Log($"[SA_CallMeteor] Spawning meteor #{_meteorsSpawned + 1} at: {spawnPos}");
|
||||
|
||||
// Spawn muzzle flash effect first
|
||||
SpawnMuzzleFlash(spawnPos);
|
||||
|
||||
// Spawn the meteor
|
||||
var meteorGO = LeanPool.Spawn(meteorPrefab, spawnPos, Quaternion.identity);
|
||||
|
||||
// Configure the projectile to target the player
|
||||
var meteorScript = meteorGO.GetComponent<MeteorProjectile>();
|
||||
if (meteorScript != null)
|
||||
{
|
||||
// Update target position for each meteor (player might be moving)
|
||||
Vector3 targetPos = _target.position;
|
||||
|
||||
// Add slight prediction/leading for moving targets
|
||||
var playerRigidbody = _target.GetComponent<Rigidbody>();
|
||||
if (playerRigidbody != null)
|
||||
{
|
||||
Vector3 playerVelocity = playerRigidbody.linearVelocity;
|
||||
float estimatedFlightTime = 2f; // rough estimate
|
||||
targetPos += playerVelocity * estimatedFlightTime * 0.5f; // partial leading
|
||||
}
|
||||
|
||||
meteorScript.useOverrideImpactPoint = true;
|
||||
meteorScript.overrideImpactPoint = targetPos;
|
||||
meteorScript.snapImpactToGround = true;
|
||||
|
||||
if (enableDebug) Debug.Log($"[SA_CallMeteor] Meteor #{_meteorsSpawned + 1} configured to target: {targetPos}");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (enableDebug) Debug.LogError("[SA_CallMeteor] Meteor prefab missing MeteorProjectile component!");
|
||||
}
|
||||
}
|
||||
|
||||
private void SpawnMuzzleFlash(Vector3 position)
|
||||
{
|
||||
if (muzzleFlashPrefab == null) return;
|
||||
|
||||
var muzzleFlash = LeanPool.Spawn(muzzleFlashPrefab, position, Quaternion.identity);
|
||||
|
||||
if (muzzleFlashDuration > 0f)
|
||||
{
|
||||
// Auto-despawn muzzle flash after duration
|
||||
_boss.gameObject.AddComponent<DelayedInvoker>().Init(muzzleFlashDuration, () =>
|
||||
{
|
||||
if (muzzleFlash != null)
|
||||
{
|
||||
LeanPool.Despawn(muzzleFlash);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (enableDebug) Debug.Log($"[SA_CallMeteor] Muzzle flash spawned at: {position}");
|
||||
}
|
||||
|
||||
private void PlayOverlayOnce(Transform owner)
|
||||
{
|
||||
if (overlayClip == null) return;
|
||||
|
||||
var animator = owner.GetComponent<Animator>();
|
||||
if (animator == null) return;
|
||||
|
||||
StopOverlayImmediate();
|
||||
|
||||
_overlayGraph = PlayableGraph.Create("ActionOverlay(CallMeteor)");
|
||||
_overlayGraph.SetTimeUpdateMode(DirectorUpdateMode.GameTime);
|
||||
|
||||
_overlayPlayable = AnimationClipPlayable.Create(_overlayGraph, overlayClip);
|
||||
_overlayPlayable.SetApplyFootIK(false);
|
||||
_overlayPlayable.SetApplyPlayableIK(false);
|
||||
_overlayPlayable.SetSpeed(Mathf.Max(0.0001f, overlaySpeed));
|
||||
|
||||
_overlayOutput = AnimationPlayableOutput.Create(_overlayGraph, "AnimOut", animator);
|
||||
_overlayOutput.SetSourcePlayable(_overlayPlayable);
|
||||
|
||||
_overlayOutput.SetWeight(1f);
|
||||
_overlayGraph.Play();
|
||||
_overlayPlaying = true;
|
||||
|
||||
// Calculate total sequence duration for overlay
|
||||
float totalSequenceDuration = initialCastDelay + (meteorCount * meteorSpawnInterval) + 2f; // +2s buffer
|
||||
float overlayDuration = overlayClip.length / Mathf.Max(0.0001f, overlaySpeed);
|
||||
|
||||
// Use the longer of the two durations, ensuring overlay covers entire sequence
|
||||
float finalDuration = Mathf.Max(overlayDuration, totalSequenceDuration);
|
||||
_overlayStopAtTime = Time.time + finalDuration;
|
||||
|
||||
if (enableDebug) Debug.Log($"[SA_CallMeteor] Overlay clip started via Playables, duration: {finalDuration:F1}s (sequence: {totalSequenceDuration:F1}s, clip: {overlayDuration:F1}s)");
|
||||
}
|
||||
|
||||
private void StopOverlayImmediate()
|
||||
{
|
||||
if (_overlayGraph.IsValid())
|
||||
{
|
||||
_overlayGraph.Stop();
|
||||
_overlayGraph.Destroy();
|
||||
}
|
||||
_overlayPlaying = false;
|
||||
}
|
||||
|
||||
private void StopOverlayWithFade()
|
||||
{
|
||||
if (!_overlayPlaying) { StopOverlayImmediate(); return; }
|
||||
if (_overlayOutput.IsOutputNull() == false) _overlayOutput.SetWeight(0f);
|
||||
StopOverlayImmediate();
|
||||
if (enableDebug) Debug.Log("[SA_CallMeteor] Overlay clip stopped");
|
||||
}
|
||||
|
||||
// Public methods for external monitoring
|
||||
public bool IsSequenceActive() => _spawningActive;
|
||||
|
||||
public int GetMeteorsSpawned() => _meteorsSpawned;
|
||||
|
||||
public int GetTotalMeteorCount() => meteorCount;
|
||||
|
||||
public float GetSequenceProgress() => meteorCount > 0 ? (float)_meteorsSpawned / meteorCount : 1f;
|
||||
|
||||
/// <summary>
|
||||
/// Tiny helper MonoBehaviour to delay a callback without coroutines here.
|
||||
/// </summary>
|
||||
private sealed class DelayedInvoker : MonoBehaviour
|
||||
{
|
||||
private float _timeLeft;
|
||||
private System.Action _callback;
|
||||
|
||||
public void Init(float delay, System.Action callback)
|
||||
{
|
||||
_timeLeft = delay;
|
||||
_callback = callback;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
_timeLeft -= Time.deltaTime;
|
||||
if (_timeLeft <= 0f)
|
||||
{
|
||||
try { _callback?.Invoke(); }
|
||||
finally { Destroy(this); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
using Invector.vCharacterController.AI.FSMBehaviour;
|
||||
using Lean.Pool;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Animations;
|
||||
using UnityEngine.Playables;
|
||||
|
||||
namespace DemonBoss.Magic
|
||||
{
|
||||
/// <summary>
|
||||
/// Spawns multiple meteors behind the BOSS and launches them toward the player's position.
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Invector/FSM/Actions/DemonBoss/Call Meteor")]
|
||||
public class SA_CallMeteor : vStateAction
|
||||
{
|
||||
public override string categoryName => "DemonBoss/Magic";
|
||||
public override string defaultName => "Call Meteor";
|
||||
|
||||
[Header("Meteor Setup")]
|
||||
[Tooltip("Prefab with MeteorProjectile component")]
|
||||
public GameObject meteorPrefab;
|
||||
|
||||
[Tooltip("Distance behind the BOSS to spawn meteor (meters)")]
|
||||
public float behindBossDistance = 3f;
|
||||
|
||||
[Tooltip("Height above the BOSS to spawn meteor (meters)")]
|
||||
public float aboveBossHeight = 8f;
|
||||
|
||||
[Header("Multi-Meteor Configuration")]
|
||||
[Tooltip("Number of meteors to spawn in sequence")]
|
||||
public int meteorCount = 5;
|
||||
|
||||
[Tooltip("Delay before first meteor spawns (wind-up)")]
|
||||
public float initialCastDelay = 0.4f;
|
||||
|
||||
[Tooltip("Time between each meteor spawn")]
|
||||
public float meteorSpawnInterval = 0.6f;
|
||||
|
||||
[Header("Muzzle Flash Effect")]
|
||||
[Tooltip("Particle effect prefab for muzzle flash at spawn position")]
|
||||
public GameObject muzzleFlashPrefab;
|
||||
|
||||
[Tooltip("Duration to keep muzzle flash alive (seconds)")]
|
||||
public float muzzleFlashDuration = 1.5f;
|
||||
|
||||
[Header("Targeting")]
|
||||
[Tooltip("Tag used to find the target (usually Player)")]
|
||||
public string targetTag = "Player";
|
||||
|
||||
[Header("One-off Overlay Clip (No Animator Params)")]
|
||||
public AnimationClip overlayClip;
|
||||
|
||||
[Tooltip("Playback speed (1 = normal)")] public float overlaySpeed = 1f;
|
||||
[Tooltip("Blend-in seconds (instant in this minimal impl)")] public float overlayFadeIn = 0.10f;
|
||||
[Tooltip("Blend-out seconds (instant in this minimal impl)")] public float overlayFadeOut = 0.10f;
|
||||
|
||||
[Header("Debug")]
|
||||
public bool enableDebug = false;
|
||||
|
||||
private Transform _boss;
|
||||
private Transform _target;
|
||||
|
||||
// --- Multi-meteor state ---
|
||||
private int _meteorsSpawned = 0;
|
||||
|
||||
private bool _spawningActive = false;
|
||||
|
||||
// --- Playables runtime ---
|
||||
private PlayableGraph _overlayGraph;
|
||||
|
||||
private AnimationPlayableOutput _overlayOutput;
|
||||
private AnimationClipPlayable _overlayPlayable;
|
||||
private bool _overlayPlaying;
|
||||
private float _overlayStopAtTime;
|
||||
|
||||
public override void DoAction(vIFSMBehaviourController fsm, vFSMComponentExecutionType execType = vFSMComponentExecutionType.OnStateUpdate)
|
||||
{
|
||||
if (execType == vFSMComponentExecutionType.OnStateEnter)
|
||||
{
|
||||
OnEnter(fsm);
|
||||
}
|
||||
else if (execType == vFSMComponentExecutionType.OnStateUpdate)
|
||||
{
|
||||
// Keep the state active until all meteors are spawned
|
||||
if (_spawningActive && _meteorsSpawned < meteorCount)
|
||||
{
|
||||
// Don't allow the state to exit while spawning
|
||||
return;
|
||||
}
|
||||
|
||||
if (_overlayPlaying && Time.time >= _overlayStopAtTime)
|
||||
{
|
||||
StopOverlayWithFade();
|
||||
}
|
||||
|
||||
// Only signal completion when all meteors are done AND overlay is finished
|
||||
if (!_spawningActive && !_overlayPlaying && _meteorsSpawned >= meteorCount)
|
||||
{
|
||||
if (enableDebug) Debug.Log($"[SA_CallMeteor] Sequence complete, ready to exit state");
|
||||
}
|
||||
}
|
||||
else if (execType == vFSMComponentExecutionType.OnStateExit)
|
||||
{
|
||||
OnExit(fsm);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnter(vIFSMBehaviourController fsm)
|
||||
{
|
||||
_boss = fsm.transform;
|
||||
_target = GameObject.FindGameObjectWithTag(targetTag)?.transform;
|
||||
|
||||
if (_target == null)
|
||||
{
|
||||
if (enableDebug) Debug.LogWarning("[SA_CallMeteor] No target found – abort");
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset multi-meteor state
|
||||
_meteorsSpawned = 0;
|
||||
_spawningActive = true;
|
||||
|
||||
// SET COOLDOWN IMMEDIATELY when meteor ability is used
|
||||
DEC_CheckCooldown.SetCooldownStatic(fsm, "Meteor", 80f);
|
||||
|
||||
if (enableDebug) Debug.Log($"[SA_CallMeteor] Boss: {_boss.name}, Target: {_target.name}, Count: {meteorCount}");
|
||||
|
||||
// Fire overlay clip (no Animator params)
|
||||
PlayOverlayOnce(_boss);
|
||||
|
||||
// Start the meteor sequence
|
||||
if (initialCastDelay > 0f)
|
||||
_boss.gameObject.AddComponent<DelayedInvoker>().Init(initialCastDelay, StartMeteorSequence);
|
||||
else
|
||||
StartMeteorSequence();
|
||||
}
|
||||
|
||||
private void OnExit(vIFSMBehaviourController fsm)
|
||||
{
|
||||
// Stop any active spawning
|
||||
_spawningActive = false;
|
||||
StopOverlayImmediate();
|
||||
|
||||
// Clean up any DelayedInvokers attached to the boss
|
||||
var invokers = _boss?.GetComponents<DelayedInvoker>();
|
||||
if (invokers != null)
|
||||
{
|
||||
foreach (var invoker in invokers)
|
||||
{
|
||||
if (invoker != null) Object.Destroy(invoker);
|
||||
}
|
||||
}
|
||||
|
||||
if (enableDebug) Debug.Log($"[SA_CallMeteor] State exited. Spawned {_meteorsSpawned}/{meteorCount} meteors");
|
||||
}
|
||||
|
||||
private void StartMeteorSequence()
|
||||
{
|
||||
if (!_spawningActive) return;
|
||||
SpawnNextMeteor();
|
||||
}
|
||||
|
||||
private void SpawnNextMeteor()
|
||||
{
|
||||
if (!_spawningActive || _meteorsSpawned >= meteorCount) return;
|
||||
|
||||
if (_boss == null || _target == null)
|
||||
{
|
||||
if (enableDebug) Debug.LogError("[SA_CallMeteor] Missing boss or target reference");
|
||||
return;
|
||||
}
|
||||
|
||||
SpawnSingleMeteor();
|
||||
_meteorsSpawned++;
|
||||
|
||||
if (enableDebug) Debug.Log($"[SA_CallMeteor] Spawned meteor {_meteorsSpawned}/{meteorCount}");
|
||||
|
||||
// Schedule next meteor if needed
|
||||
if (_meteorsSpawned < meteorCount && _spawningActive)
|
||||
{
|
||||
if (enableDebug) Debug.Log($"[SA_CallMeteor] Scheduling next meteor in {meteorSpawnInterval}s");
|
||||
_boss.gameObject.AddComponent<DelayedInvoker>().Init(meteorSpawnInterval, SpawnNextMeteor);
|
||||
}
|
||||
else
|
||||
{
|
||||
// All meteors spawned
|
||||
_spawningActive = false;
|
||||
if (enableDebug) Debug.Log("[SA_CallMeteor] All meteors spawned, sequence complete");
|
||||
}
|
||||
}
|
||||
|
||||
private void SpawnSingleMeteor()
|
||||
{
|
||||
if (meteorPrefab == null)
|
||||
{
|
||||
if (enableDebug) Debug.LogError("[SA_CallMeteor] Missing meteorPrefab");
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate spawn position: behind the BOSS + height
|
||||
Vector3 bossForward = _boss.forward.normalized;
|
||||
Vector3 behindBoss = _boss.position - (bossForward * behindBossDistance);
|
||||
Vector3 spawnPos = behindBoss + Vector3.up * aboveBossHeight;
|
||||
|
||||
// Add slight randomization to spawn position for multiple meteors
|
||||
if (_meteorsSpawned > 0)
|
||||
{
|
||||
Vector3 randomOffset = new Vector3(
|
||||
Random.Range(-1f, 1f),
|
||||
Random.Range(-0.5f, 0.5f),
|
||||
Random.Range(-1f, 1f)
|
||||
);
|
||||
spawnPos += randomOffset;
|
||||
}
|
||||
|
||||
if (enableDebug) Debug.Log($"[SA_CallMeteor] Spawning meteor #{_meteorsSpawned + 1} at: {spawnPos}");
|
||||
|
||||
// Spawn muzzle flash effect first
|
||||
SpawnMuzzleFlash(spawnPos);
|
||||
|
||||
// Spawn the meteor
|
||||
var meteorGO = LeanPool.Spawn(meteorPrefab, spawnPos, Quaternion.identity);
|
||||
|
||||
// Configure the projectile to target the player
|
||||
var meteorScript = meteorGO.GetComponent<MeteorProjectile>();
|
||||
if (meteorScript != null)
|
||||
{
|
||||
// Update target position for each meteor (player might be moving)
|
||||
Vector3 targetPos = _target.position;
|
||||
|
||||
// Add slight prediction/leading for moving targets
|
||||
var playerRigidbody = _target.GetComponent<Rigidbody>();
|
||||
if (playerRigidbody != null)
|
||||
{
|
||||
Vector3 playerVelocity = playerRigidbody.linearVelocity;
|
||||
float estimatedFlightTime = 2f; // rough estimate
|
||||
targetPos += playerVelocity * estimatedFlightTime * 0.5f; // partial leading
|
||||
}
|
||||
|
||||
meteorScript.useOverrideImpactPoint = true;
|
||||
meteorScript.overrideImpactPoint = targetPos;
|
||||
meteorScript.snapImpactToGround = true;
|
||||
|
||||
if (enableDebug) Debug.Log($"[SA_CallMeteor] Meteor #{_meteorsSpawned + 1} configured to target: {targetPos}");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (enableDebug) Debug.LogError("[SA_CallMeteor] Meteor prefab missing MeteorProjectile component!");
|
||||
}
|
||||
}
|
||||
|
||||
private void SpawnMuzzleFlash(Vector3 position)
|
||||
{
|
||||
if (muzzleFlashPrefab == null) return;
|
||||
|
||||
var muzzleFlash = LeanPool.Spawn(muzzleFlashPrefab, position, Quaternion.identity);
|
||||
|
||||
if (muzzleFlashDuration > 0f)
|
||||
{
|
||||
// Auto-despawn muzzle flash after duration
|
||||
_boss.gameObject.AddComponent<DelayedInvoker>().Init(muzzleFlashDuration, () =>
|
||||
{
|
||||
if (muzzleFlash != null)
|
||||
{
|
||||
LeanPool.Despawn(muzzleFlash);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (enableDebug) Debug.Log($"[SA_CallMeteor] Muzzle flash spawned at: {position}");
|
||||
}
|
||||
|
||||
private void PlayOverlayOnce(Transform owner)
|
||||
{
|
||||
if (overlayClip == null) return;
|
||||
|
||||
var animator = owner.GetComponent<Animator>();
|
||||
if (animator == null) return;
|
||||
|
||||
StopOverlayImmediate();
|
||||
|
||||
_overlayGraph = PlayableGraph.Create("ActionOverlay(CallMeteor)");
|
||||
_overlayGraph.SetTimeUpdateMode(DirectorUpdateMode.GameTime);
|
||||
|
||||
_overlayPlayable = AnimationClipPlayable.Create(_overlayGraph, overlayClip);
|
||||
_overlayPlayable.SetApplyFootIK(false);
|
||||
_overlayPlayable.SetApplyPlayableIK(false);
|
||||
_overlayPlayable.SetSpeed(Mathf.Max(0.0001f, overlaySpeed));
|
||||
|
||||
_overlayOutput = AnimationPlayableOutput.Create(_overlayGraph, "AnimOut", animator);
|
||||
_overlayOutput.SetSourcePlayable(_overlayPlayable);
|
||||
|
||||
_overlayOutput.SetWeight(1f);
|
||||
_overlayGraph.Play();
|
||||
_overlayPlaying = true;
|
||||
|
||||
// Calculate total sequence duration for overlay
|
||||
float totalSequenceDuration = initialCastDelay + (meteorCount * meteorSpawnInterval) + 2f; // +2s buffer
|
||||
float overlayDuration = overlayClip.length / Mathf.Max(0.0001f, overlaySpeed);
|
||||
|
||||
// Use the longer of the two durations, ensuring overlay covers entire sequence
|
||||
float finalDuration = Mathf.Max(overlayDuration, totalSequenceDuration);
|
||||
_overlayStopAtTime = Time.time + finalDuration;
|
||||
|
||||
if (enableDebug) Debug.Log($"[SA_CallMeteor] Overlay clip started via Playables, duration: {finalDuration:F1}s (sequence: {totalSequenceDuration:F1}s, clip: {overlayDuration:F1}s)");
|
||||
}
|
||||
|
||||
private void StopOverlayImmediate()
|
||||
{
|
||||
if (_overlayGraph.IsValid())
|
||||
{
|
||||
_overlayGraph.Stop();
|
||||
_overlayGraph.Destroy();
|
||||
}
|
||||
_overlayPlaying = false;
|
||||
}
|
||||
|
||||
private void StopOverlayWithFade()
|
||||
{
|
||||
if (!_overlayPlaying) { StopOverlayImmediate(); return; }
|
||||
if (_overlayOutput.IsOutputNull() == false) _overlayOutput.SetWeight(0f);
|
||||
StopOverlayImmediate();
|
||||
if (enableDebug) Debug.Log("[SA_CallMeteor] Overlay clip stopped");
|
||||
}
|
||||
|
||||
// Public methods for external monitoring
|
||||
public bool IsSequenceActive() => _spawningActive;
|
||||
|
||||
public int GetMeteorsSpawned() => _meteorsSpawned;
|
||||
|
||||
public int GetTotalMeteorCount() => meteorCount;
|
||||
|
||||
public float GetSequenceProgress() => meteorCount > 0 ? (float)_meteorsSpawned / meteorCount : 1f;
|
||||
|
||||
/// <summary>
|
||||
/// Tiny helper MonoBehaviour to delay a callback without coroutines here.
|
||||
/// </summary>
|
||||
private sealed class DelayedInvoker : MonoBehaviour
|
||||
{
|
||||
private float _timeLeft;
|
||||
private System.Action _callback;
|
||||
|
||||
public void Init(float delay, System.Action callback)
|
||||
{
|
||||
_timeLeft = delay;
|
||||
_callback = callback;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
_timeLeft -= Time.deltaTime;
|
||||
if (_timeLeft <= 0f)
|
||||
{
|
||||
try { _callback?.Invoke(); }
|
||||
finally { Destroy(this); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,38 @@
|
||||
using UnityEngine;
|
||||
|
||||
public class ShieldScaleUp : MonoBehaviour
|
||||
{
|
||||
[Tooltip("Time it takes to fully grow the shield")]
|
||||
public float growDuration = 0.5f;
|
||||
|
||||
[Tooltip("Curve to control growth speed over time")]
|
||||
public AnimationCurve scaleCurve = AnimationCurve.EaseInOut(0, 0, 1, 1);
|
||||
|
||||
private float timer = 0f;
|
||||
private Vector3 targetScale;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
targetScale = transform.localScale;
|
||||
transform.localScale = Vector3.zero;
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
timer = 0f;
|
||||
transform.localScale = Vector3.zero;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (timer < growDuration)
|
||||
{
|
||||
timer += Time.deltaTime;
|
||||
float t = Mathf.Clamp01(timer / growDuration);
|
||||
|
||||
float scaleValue = scaleCurve.Evaluate(t);
|
||||
|
||||
transform.localScale = targetScale * scaleValue;
|
||||
}
|
||||
}
|
||||
using UnityEngine;
|
||||
|
||||
public class ShieldScaleUp : MonoBehaviour
|
||||
{
|
||||
[Tooltip("Time it takes to fully grow the shield")]
|
||||
public float growDuration = 0.5f;
|
||||
|
||||
[Tooltip("Curve to control growth speed over time")]
|
||||
public AnimationCurve scaleCurve = AnimationCurve.EaseInOut(0, 0, 1, 1);
|
||||
|
||||
private float timer = 0f;
|
||||
private Vector3 targetScale;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
targetScale = transform.localScale;
|
||||
transform.localScale = Vector3.zero;
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
timer = 0f;
|
||||
transform.localScale = Vector3.zero;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (timer < growDuration)
|
||||
{
|
||||
timer += Time.deltaTime;
|
||||
float t = Mathf.Clamp01(timer / growDuration);
|
||||
|
||||
float scaleValue = scaleCurve.Evaluate(t);
|
||||
|
||||
transform.localScale = targetScale * scaleValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
8
Assets/AI/_Stalker.meta
Normal file
8
Assets/AI/_Stalker.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f81dc6f089de88d419e022edfdeb3cea
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
8
Assets/AI/_Summoner.meta
Normal file
8
Assets/AI/_Summoner.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6233c0fbab2ef8f41b45a57062fc585e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
105
Assets/AI/_Summoner/DEC_CanSpawnMinions.cs
Normal file
105
Assets/AI/_Summoner/DEC_CanSpawnMinions.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using Invector.vCharacterController.AI.FSMBehaviour;
|
||||
using UnityEngine;
|
||||
|
||||
namespace DemonBoss.Summoner
|
||||
{
|
||||
/// <summary>
|
||||
/// FSM Decision checking if summoner can spawn minions
|
||||
/// Checks: not already spawning, minion count below max, cooldown passed
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Invector/FSM/Decisions/Summoner/Can Spawn Minions")]
|
||||
public class DEC_CanSpawnMinions : vStateDecision
|
||||
{
|
||||
public override string categoryName => "Summoner";
|
||||
public override string defaultName => "Can Spawn Minions";
|
||||
|
||||
[Header("Spawn Conditions")]
|
||||
[Tooltip("Check if health is below threshold")]
|
||||
public bool checkHealthThreshold = true;
|
||||
|
||||
[Tooltip("Check if minions are below max count")]
|
||||
public bool checkMinionCount = true;
|
||||
|
||||
[Tooltip("Check if cooldown has passed")]
|
||||
public bool checkCooldown = true;
|
||||
|
||||
[Tooltip("Cooldown between summon attempts (seconds)")]
|
||||
public float cooldownTime = 15f;
|
||||
|
||||
[Header("Distance Check")]
|
||||
[Tooltip("Only spawn if player is within this distance (0 = disabled)")]
|
||||
public float maxDistanceToPlayer = 0f;
|
||||
|
||||
[Header("Debug")]
|
||||
[Tooltip("Enable debug logging")]
|
||||
public bool enableDebug = false;
|
||||
|
||||
private string cooldownKey = "SummonMinions";
|
||||
|
||||
public override bool Decide(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
var summoner = fsmBehaviour.gameObject.GetComponent<SummonerAI>();
|
||||
if (summoner == null)
|
||||
{
|
||||
if (enableDebug) Debug.LogWarning("[DEC_CanSpawnMinions] No SummonerAI component found!");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if already spawning
|
||||
if (summoner.IsSpawning)
|
||||
{
|
||||
if (enableDebug) Debug.Log("[DEC_CanSpawnMinions] Already spawning - FALSE");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check minion count
|
||||
if (checkMinionCount && !summoner.CanSpawnMinions)
|
||||
{
|
||||
if (enableDebug) Debug.Log($"[DEC_CanSpawnMinions] Max minions reached ({summoner.ActiveMinionCount}) - FALSE");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check health threshold
|
||||
if (checkHealthThreshold && !summoner.ShouldSummonByHealth())
|
||||
{
|
||||
if (enableDebug) Debug.Log("[DEC_CanSpawnMinions] Health threshold not met - FALSE");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check distance to player
|
||||
if (maxDistanceToPlayer > 0f)
|
||||
{
|
||||
float distance = summoner.GetDistanceToPlayer();
|
||||
if (distance > maxDistanceToPlayer)
|
||||
{
|
||||
if (enableDebug) Debug.Log($"[DEC_CanSpawnMinions] Player too far ({distance:F1}m) - FALSE");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check cooldown
|
||||
if (checkCooldown)
|
||||
{
|
||||
string timerKey = "cooldown_" + cooldownKey;
|
||||
|
||||
if (fsmBehaviour.HasTimer(timerKey))
|
||||
{
|
||||
float lastUsedTime = fsmBehaviour.GetTimer(timerKey);
|
||||
float timeSinceLastUse = Time.time - lastUsedTime;
|
||||
|
||||
if (timeSinceLastUse < cooldownTime)
|
||||
{
|
||||
if (enableDebug) Debug.Log($"[DEC_CanSpawnMinions] On cooldown - {cooldownTime - timeSinceLastUse:F1}s remaining - FALSE");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Set cooldown for next use
|
||||
fsmBehaviour.SetTimer(timerKey, Time.time);
|
||||
}
|
||||
|
||||
if (enableDebug) Debug.Log("[DEC_CanSpawnMinions] All conditions met - TRUE");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/AI/_Summoner/DEC_CanSpawnMinions.cs.meta
Normal file
2
Assets/AI/_Summoner/DEC_CanSpawnMinions.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6932f86375a52954785f89df47a2d61e
|
||||
16
Assets/AI/_Summoner/DEC_CheckDistanceToPlayer.cs
Normal file
16
Assets/AI/_Summoner/DEC_CheckDistanceToPlayer.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using UnityEngine;
|
||||
|
||||
public class DEC_CheckDistanceToPlayer : MonoBehaviour
|
||||
{
|
||||
// Start is called once before the first execution of Update after the MonoBehaviour is created
|
||||
void Start()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
// Update is called once per frame
|
||||
void Update()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
2
Assets/AI/_Summoner/DEC_CheckDistanceToPlayer.cs.meta
Normal file
2
Assets/AI/_Summoner/DEC_CheckDistanceToPlayer.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 40326c8e0d34db64d9aec176f7d5bc97
|
||||
16
Assets/AI/_Summoner/DEC_HasActiveMinions.cs
Normal file
16
Assets/AI/_Summoner/DEC_HasActiveMinions.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using UnityEngine;
|
||||
|
||||
public class DEC_HasActiveMinions : MonoBehaviour
|
||||
{
|
||||
// Start is called once before the first execution of Update after the MonoBehaviour is created
|
||||
void Start()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
// Update is called once per frame
|
||||
void Update()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
2
Assets/AI/_Summoner/DEC_HasActiveMinions.cs.meta
Normal file
2
Assets/AI/_Summoner/DEC_HasActiveMinions.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3a537cd142b23fa4b8285f3d22cda409
|
||||
74
Assets/AI/_Summoner/DEC_ShouldMeleeAttack.cs
Normal file
74
Assets/AI/_Summoner/DEC_ShouldMeleeAttack.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using Invector.vCharacterController.AI.FSMBehaviour;
|
||||
using UnityEngine;
|
||||
|
||||
namespace DemonBoss.Summoner
|
||||
{
|
||||
/// <summary>
|
||||
/// FSM Decision checking if summoner should engage in melee combat
|
||||
/// Checks: player distance, minion status, combat settings
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Invector/FSM/Decisions/Summoner/Should Melee Attack")]
|
||||
public class DEC_ShouldMeleeAttack : vStateDecision
|
||||
{
|
||||
public override string categoryName => "Summoner";
|
||||
public override string defaultName => "Should Melee Attack";
|
||||
|
||||
[Header("Distance Configuration")]
|
||||
[Tooltip("Minimum distance to engage melee")]
|
||||
public float minMeleeDistance = 0f;
|
||||
|
||||
[Tooltip("Maximum distance to engage melee")]
|
||||
public float maxMeleeDistance = 3f;
|
||||
|
||||
[Header("Behavior")]
|
||||
[Tooltip("Attack even when minions are alive")]
|
||||
public bool attackWithMinions = false;
|
||||
|
||||
[Header("Debug")]
|
||||
[Tooltip("Enable debug logging")]
|
||||
public bool enableDebug = false;
|
||||
|
||||
public override bool Decide(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
var summoner = fsmBehaviour.gameObject.GetComponent<SummonerAI>();
|
||||
if (summoner == null)
|
||||
{
|
||||
if (enableDebug) Debug.LogWarning("[DEC_ShouldMeleeAttack] No SummonerAI component found!");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't attack while spawning
|
||||
if (summoner.IsSpawning)
|
||||
{
|
||||
if (enableDebug) Debug.Log("[DEC_ShouldMeleeAttack] Currently spawning - FALSE");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if has minions and shouldn't attack with them
|
||||
if (!attackWithMinions && summoner.HasActiveMinions)
|
||||
{
|
||||
if (enableDebug) Debug.Log($"[DEC_ShouldMeleeAttack] Has {summoner.ActiveMinionCount} minions and attackWithMinions=false - FALSE");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check distance to player
|
||||
float distance = summoner.GetDistanceToPlayer();
|
||||
|
||||
bool inRange = distance >= minMeleeDistance && distance <= maxMeleeDistance;
|
||||
|
||||
if (enableDebug)
|
||||
{
|
||||
if (inRange)
|
||||
{
|
||||
Debug.Log($"[DEC_ShouldMeleeAttack] Player in melee range ({distance:F1}m) - TRUE");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.Log($"[DEC_ShouldMeleeAttack] Player not in melee range ({distance:F1}m, need {minMeleeDistance:F1}-{maxMeleeDistance:F1}m) - FALSE");
|
||||
}
|
||||
}
|
||||
|
||||
return inRange;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/AI/_Summoner/DEC_ShouldMeleeAttack.cs.meta
Normal file
2
Assets/AI/_Summoner/DEC_ShouldMeleeAttack.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f8abf79055070ed47a11a57e0cc5aea3
|
||||
125
Assets/AI/_Summoner/SA_SpawnMinions.cs
Normal file
125
Assets/AI/_Summoner/SA_SpawnMinions.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
using Invector.vCharacterController.AI.FSMBehaviour;
|
||||
using UnityEngine;
|
||||
|
||||
namespace DemonBoss.Summoner
|
||||
{
|
||||
/// <summary>
|
||||
/// FSM State Action that triggers minion spawning
|
||||
/// Calls SummonerAI.StartSpawning() on state enter
|
||||
/// </summary>
|
||||
[CreateAssetMenu(menuName = "Invector/FSM/Actions/Summoner/Spawn Minions")]
|
||||
public class SA_SpawnMinions : vStateAction
|
||||
{
|
||||
public override string categoryName => "Summoner";
|
||||
public override string defaultName => "Spawn Minions";
|
||||
|
||||
[Header("Animation")]
|
||||
[Tooltip("Animator trigger parameter for summoning animation")]
|
||||
public string summonTriggerName = "Summon";
|
||||
|
||||
[Tooltip("Animator bool for summoning state")]
|
||||
public string summoningBoolName = "IsSummoning";
|
||||
|
||||
[Header("Behavior")]
|
||||
[Tooltip("Wait for spawning to complete before allowing state exit")]
|
||||
public bool waitForCompletion = true;
|
||||
|
||||
[Header("Debug")]
|
||||
[Tooltip("Enable debug logging")]
|
||||
public bool enableDebug = false;
|
||||
|
||||
private SummonerAI summoner;
|
||||
private Animator animator;
|
||||
private bool hasStartedSpawning = false;
|
||||
|
||||
public override void DoAction(vIFSMBehaviourController fsmBehaviour, vFSMComponentExecutionType executionType = vFSMComponentExecutionType.OnStateUpdate)
|
||||
{
|
||||
if (executionType == vFSMComponentExecutionType.OnStateEnter)
|
||||
{
|
||||
OnEnter(fsmBehaviour);
|
||||
}
|
||||
else if (executionType == vFSMComponentExecutionType.OnStateUpdate)
|
||||
{
|
||||
OnUpdate(fsmBehaviour);
|
||||
}
|
||||
else if (executionType == vFSMComponentExecutionType.OnStateExit)
|
||||
{
|
||||
OnExit(fsmBehaviour);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEnter(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
summoner = fsmBehaviour.gameObject.GetComponent<SummonerAI>();
|
||||
animator = fsmBehaviour.gameObject.GetComponent<Animator>();
|
||||
|
||||
if (summoner == null)
|
||||
{
|
||||
Debug.LogError("[SA_SpawnMinions] No SummonerAI component found!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Trigger animation
|
||||
if (animator != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(summonTriggerName))
|
||||
{
|
||||
animator.SetTrigger(summonTriggerName);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(summoningBoolName))
|
||||
{
|
||||
animator.SetBool(summoningBoolName, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Start spawning minions
|
||||
summoner.StartSpawning();
|
||||
hasStartedSpawning = true;
|
||||
|
||||
if (enableDebug) Debug.Log("[SA_SpawnMinions] Started spawning minions");
|
||||
}
|
||||
|
||||
private void OnUpdate(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
// If waiting for completion, keep state active until spawning is done
|
||||
if (waitForCompletion && summoner != null && summoner.IsSpawning)
|
||||
{
|
||||
// State will continue until spawning is complete
|
||||
if (enableDebug && Time.frameCount % 60 == 0) // Log once per second
|
||||
{
|
||||
Debug.Log("[SA_SpawnMinions] Waiting for spawning to complete...");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnExit(vIFSMBehaviourController fsmBehaviour)
|
||||
{
|
||||
// Reset animation bool
|
||||
if (animator != null && !string.IsNullOrEmpty(summoningBoolName))
|
||||
{
|
||||
animator.SetBool(summoningBoolName, false);
|
||||
}
|
||||
|
||||
// If spawning was interrupted, stop it
|
||||
if (summoner != null && summoner.IsSpawning)
|
||||
{
|
||||
summoner.StopSpawning();
|
||||
if (enableDebug) Debug.Log("[SA_SpawnMinions] Spawning interrupted on state exit");
|
||||
}
|
||||
|
||||
hasStartedSpawning = false;
|
||||
|
||||
if (enableDebug) Debug.Log("[SA_SpawnMinions] State exited");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if spawning is complete (for FSM decision nodes)
|
||||
/// </summary>
|
||||
public bool IsSpawningComplete()
|
||||
{
|
||||
if (summoner == null) return true;
|
||||
return hasStartedSpawning && !summoner.IsSpawning;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/AI/_Summoner/SA_SpawnMinions.cs.meta
Normal file
2
Assets/AI/_Summoner/SA_SpawnMinions.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ad57378f916739249891d117d50e333b
|
||||
359
Assets/AI/_Summoner/SummonerAI.cs
Normal file
359
Assets/AI/_Summoner/SummonerAI.cs
Normal file
@@ -0,0 +1,359 @@
|
||||
using Invector;
|
||||
using Invector.vCharacterController.AI;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
namespace DemonBoss.Summoner
|
||||
{
|
||||
/// <summary>
|
||||
/// Main AI controller for Summoner enemy
|
||||
/// Manages spawned minions and provides combat state tracking
|
||||
/// Attach to Summoner character along with vControlAI
|
||||
/// </summary>
|
||||
public class SummonerAI : MonoBehaviour
|
||||
{
|
||||
[Header("Minion Management")]
|
||||
[Tooltip("Prefab of minion to spawn")]
|
||||
public GameObject minionPrefab;
|
||||
|
||||
[Tooltip("Maximum number of minions alive at once")]
|
||||
public int maxActiveMinions = 3;
|
||||
|
||||
[Tooltip("Distance from summoner to spawn minions")]
|
||||
public float spawnRadius = 5f;
|
||||
|
||||
[Tooltip("Height offset for spawn position")]
|
||||
public float spawnHeightOffset = 0f;
|
||||
|
||||
[Tooltip("Should minions look at summoner after spawn?")]
|
||||
public bool minionsLookAtCenter = false;
|
||||
|
||||
[Header("Spawn Configuration")]
|
||||
[Tooltip("Number of minions to spawn per summon action")]
|
||||
public int minionsPerSummon = 3;
|
||||
|
||||
[Tooltip("Delay before first minion spawns")]
|
||||
public float initialSpawnDelay = 0.5f;
|
||||
|
||||
[Tooltip("Time between spawning each minion")]
|
||||
public float timeBetweenSpawns = 0.3f;
|
||||
|
||||
[Header("Combat Behavior")]
|
||||
[Tooltip("Minimum distance to player before engaging in melee")]
|
||||
public float meleeEngageDistance = 3f;
|
||||
|
||||
[Tooltip("Should summoner fight when minions are alive?")]
|
||||
public bool fightWithMinions = false;
|
||||
|
||||
[Tooltip("Health percentage threshold to spawn minions (0-1)")]
|
||||
[Range(0f, 1f)]
|
||||
public float healthThresholdForSummon = 0.7f;
|
||||
|
||||
[Header("Targeting")]
|
||||
[Tooltip("Tag to find player")]
|
||||
public string playerTag = "Player";
|
||||
|
||||
[Header("Effects")]
|
||||
[Tooltip("Particle effect at spawn location")]
|
||||
public GameObject spawnEffectPrefab;
|
||||
|
||||
[Tooltip("Sound played when spawning minions")]
|
||||
public AudioClip summonSound;
|
||||
|
||||
[Header("Debug")]
|
||||
[Tooltip("Enable debug logging")]
|
||||
public bool enableDebug = false;
|
||||
|
||||
[Tooltip("Show gizmos in Scene View")]
|
||||
public bool showGizmos = true;
|
||||
|
||||
// Runtime state
|
||||
private List<GameObject> activeMinions = new List<GameObject>();
|
||||
|
||||
private Transform playerTransform;
|
||||
private AudioSource audioSource;
|
||||
private vHealthController healthController;
|
||||
private bool isSpawning = false;
|
||||
private Coroutine spawnCoroutine;
|
||||
|
||||
// Public properties for FSM decisions
|
||||
public bool IsSpawning => isSpawning;
|
||||
|
||||
public int ActiveMinionCount => activeMinions.Count;
|
||||
public bool CanSpawnMinions => activeMinions.Count < maxActiveMinions && !isSpawning;
|
||||
public bool HasActiveMinions => activeMinions.Count > 0;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
healthController = GetComponent<vHealthController>();
|
||||
|
||||
audioSource = GetComponent<AudioSource>();
|
||||
if (audioSource == null && summonSound != null)
|
||||
{
|
||||
audioSource = gameObject.AddComponent<AudioSource>();
|
||||
audioSource.playOnAwake = false;
|
||||
audioSource.spatialBlend = 1f;
|
||||
}
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
FindPlayer();
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
CleanupDeadMinions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Find player by tag
|
||||
/// </summary>
|
||||
private void FindPlayer()
|
||||
{
|
||||
GameObject player = GameObject.FindGameObjectWithTag(playerTag);
|
||||
if (player != null)
|
||||
{
|
||||
playerTransform = player.transform;
|
||||
if (enableDebug) Debug.Log("[SummonerAI] Player found: " + player.name);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start spawning minions
|
||||
/// </summary>
|
||||
public void StartSpawning()
|
||||
{
|
||||
if (isSpawning)
|
||||
{
|
||||
if (enableDebug) Debug.Log("[SummonerAI] Already spawning minions");
|
||||
return;
|
||||
}
|
||||
|
||||
if (minionPrefab == null)
|
||||
{
|
||||
Debug.LogError("[SummonerAI] No minion prefab assigned!");
|
||||
return;
|
||||
}
|
||||
|
||||
spawnCoroutine = StartCoroutine(SpawnMinionsCoroutine());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stop spawning minions immediately
|
||||
/// </summary>
|
||||
public void StopSpawning()
|
||||
{
|
||||
if (spawnCoroutine != null)
|
||||
{
|
||||
StopCoroutine(spawnCoroutine);
|
||||
spawnCoroutine = null;
|
||||
}
|
||||
isSpawning = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Coroutine that spawns minions with delays
|
||||
/// </summary>
|
||||
private IEnumerator SpawnMinionsCoroutine()
|
||||
{
|
||||
isSpawning = true;
|
||||
|
||||
if (enableDebug) Debug.Log($"[SummonerAI] Starting to spawn {minionsPerSummon} minions");
|
||||
|
||||
// Initial delay
|
||||
yield return new WaitForSeconds(initialSpawnDelay);
|
||||
|
||||
// Play summon sound
|
||||
if (audioSource != null && summonSound != null)
|
||||
{
|
||||
audioSource.PlayOneShot(summonSound);
|
||||
}
|
||||
|
||||
// Spawn minions
|
||||
int spawned = 0;
|
||||
for (int i = 0; i < minionsPerSummon && activeMinions.Count < maxActiveMinions; i++)
|
||||
{
|
||||
SpawnSingleMinion();
|
||||
spawned++;
|
||||
|
||||
// Wait between spawns (except after last one)
|
||||
if (i < minionsPerSummon - 1)
|
||||
{
|
||||
yield return new WaitForSeconds(timeBetweenSpawns);
|
||||
}
|
||||
}
|
||||
|
||||
if (enableDebug) Debug.Log($"[SummonerAI] Finished spawning {spawned} minions. Total active: {activeMinions.Count}");
|
||||
|
||||
isSpawning = false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spawn a single minion at random position around summoner
|
||||
/// </summary>
|
||||
private void SpawnSingleMinion()
|
||||
{
|
||||
// Calculate random spawn position
|
||||
float angle = Random.Range(0f, 360f) * Mathf.Deg2Rad;
|
||||
float x = transform.position.x + spawnRadius * Mathf.Cos(angle);
|
||||
float z = transform.position.z + spawnRadius * Mathf.Sin(angle);
|
||||
Vector3 spawnPosition = new Vector3(x, transform.position.y + spawnHeightOffset, z);
|
||||
|
||||
// Spawn minion
|
||||
GameObject minion = Instantiate(minionPrefab, spawnPosition, Quaternion.identity);
|
||||
|
||||
// Set rotation
|
||||
if (minionsLookAtCenter)
|
||||
{
|
||||
minion.transform.LookAt(transform.position);
|
||||
}
|
||||
else if (playerTransform != null)
|
||||
{
|
||||
// Make minion face player
|
||||
Vector3 directionToPlayer = (playerTransform.position - minion.transform.position).normalized;
|
||||
directionToPlayer.y = 0;
|
||||
if (directionToPlayer != Vector3.zero)
|
||||
{
|
||||
minion.transform.rotation = Quaternion.LookRotation(directionToPlayer);
|
||||
}
|
||||
}
|
||||
|
||||
// Configure minion AI to target player
|
||||
var minionAI = minion.GetComponent<vControlAI>();
|
||||
if (minionAI != null && playerTransform != null)
|
||||
{
|
||||
// Set player as target through AI system
|
||||
minionAI.SetCurrentTarget(playerTransform);
|
||||
}
|
||||
|
||||
// Add to active minions list
|
||||
activeMinions.Add(minion);
|
||||
|
||||
// Spawn visual effect
|
||||
if (spawnEffectPrefab != null)
|
||||
{
|
||||
GameObject effect = Instantiate(spawnEffectPrefab, spawnPosition, Quaternion.identity);
|
||||
Destroy(effect, 3f);
|
||||
}
|
||||
|
||||
if (enableDebug) Debug.Log($"[SummonerAI] Spawned minion at {spawnPosition}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove destroyed/null minions from list
|
||||
/// </summary>
|
||||
private void CleanupDeadMinions()
|
||||
{
|
||||
activeMinions.RemoveAll(minion => minion == null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if summoner should spawn minions based on health
|
||||
/// </summary>
|
||||
public bool ShouldSummonByHealth()
|
||||
{
|
||||
if (healthController == null) return false;
|
||||
|
||||
float healthPercent = healthController.currentHealth / healthController.MaxHealth;
|
||||
return healthPercent <= healthThresholdForSummon;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get distance to player
|
||||
/// </summary>
|
||||
public float GetDistanceToPlayer()
|
||||
{
|
||||
if (playerTransform == null)
|
||||
{
|
||||
FindPlayer();
|
||||
if (playerTransform == null) return float.MaxValue;
|
||||
}
|
||||
|
||||
return Vector3.Distance(transform.position, playerTransform.position);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if player is in melee range
|
||||
/// </summary>
|
||||
public bool IsPlayerInMeleeRange()
|
||||
{
|
||||
return GetDistanceToPlayer() <= meleeEngageDistance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if summoner should engage in melee combat
|
||||
/// </summary>
|
||||
public bool ShouldEngageMelee()
|
||||
{
|
||||
if (!IsPlayerInMeleeRange()) return false;
|
||||
if (fightWithMinions) return true;
|
||||
return !HasActiveMinions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Destroy all active minions (e.g., when summoner dies)
|
||||
/// </summary>
|
||||
public void DestroyAllMinions()
|
||||
{
|
||||
foreach (GameObject minion in activeMinions)
|
||||
{
|
||||
if (minion != null)
|
||||
{
|
||||
Destroy(minion);
|
||||
}
|
||||
}
|
||||
activeMinions.Clear();
|
||||
|
||||
if (enableDebug) Debug.Log("[SummonerAI] All minions destroyed");
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
// Cleanup minions when summoner dies
|
||||
DestroyAllMinions();
|
||||
}
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
if (!showGizmos) return;
|
||||
|
||||
// Draw spawn radius
|
||||
Gizmos.color = Color.cyan;
|
||||
DrawCircle(transform.position, spawnRadius, 32);
|
||||
|
||||
// Draw melee range
|
||||
Gizmos.color = Color.red;
|
||||
DrawCircle(transform.position, meleeEngageDistance, 16);
|
||||
|
||||
// Draw lines to active minions
|
||||
Gizmos.color = Color.green;
|
||||
foreach (GameObject minion in activeMinions)
|
||||
{
|
||||
if (minion != null)
|
||||
{
|
||||
Gizmos.DrawLine(transform.position + Vector3.up, minion.transform.position + Vector3.up);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DrawCircle(Vector3 center, float radius, int segments)
|
||||
{
|
||||
float angleStep = 360f / segments;
|
||||
Vector3 previousPoint = center + new Vector3(radius, 0, 0);
|
||||
|
||||
for (int i = 1; i <= segments; i++)
|
||||
{
|
||||
float angle = i * angleStep * Mathf.Deg2Rad;
|
||||
Vector3 newPoint = center + new Vector3(
|
||||
Mathf.Cos(angle) * radius,
|
||||
0,
|
||||
Mathf.Sin(angle) * radius
|
||||
);
|
||||
Gizmos.DrawLine(previousPoint, newPoint);
|
||||
previousPoint = newPoint;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/AI/_Summoner/SummonerAI.cs.meta
Normal file
2
Assets/AI/_Summoner/SummonerAI.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ab91807f36ca9204a8515cfaffac091c
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user