533 lines
14 KiB
C#
533 lines
14 KiB
C#
using Invector;
|
|
using Invector.vCharacterController.AI;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using Lean.Pool;
|
|
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;
|
|
|
|
[Header("Melee Cooldown")]
|
|
[Tooltip("Minimum seconds between melee attacks")]
|
|
public float meleeCooldownMin = 5f;
|
|
|
|
[Tooltip("Maximum seconds between melee attacks")]
|
|
public float meleeCooldownMax = 8f;
|
|
|
|
[Tooltip("Preferred safe distance from player (used for flee/spacing)")]
|
|
public float safeDistance = 8f;
|
|
|
|
[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("Summon Restrictions")]
|
|
[Tooltip("If true, summoner will only summon when no minions are alive")]
|
|
public bool requireZeroMinionsToSummon = true;
|
|
|
|
[Header("Spell Casting")]
|
|
[Tooltip("Fireball prefab (projectile handles its own targeting)")]
|
|
public GameObject fireballPrefab;
|
|
|
|
[Tooltip("Optional spawn pivot for spells (e.g. staff tip). If null, uses summoner transform.")]
|
|
public Transform spellSpawnPivot;
|
|
|
|
[Tooltip("Cooldown between spell casts (seconds)")]
|
|
public float spellCooldown = 6f;
|
|
|
|
[Tooltip("Minimum distance required to cast spell")]
|
|
public float spellMinDistance = 5f;
|
|
|
|
[Tooltip("Maximum distance allowed to cast spell (0 = no limit)")]
|
|
public float spellMaxDistance = 20f;
|
|
|
|
[Tooltip("Only cast spell when minions are alive")]
|
|
public bool spellRequiresMinionsAlive = true;
|
|
|
|
[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;
|
|
private float lastSpellTime = -999f;
|
|
private bool spawnRequested = false;
|
|
private float nextMeleeTime = 0f;
|
|
|
|
// Public properties for FSM decisions
|
|
public bool IsSpawning => isSpawning;
|
|
|
|
public int ActiveMinionCount => activeMinions.Count;
|
|
public bool CanSpawnMinions
|
|
{
|
|
get
|
|
{
|
|
if (isSpawning) return false;
|
|
if (requireZeroMinionsToSummon && activeMinions.Count > 0) return false;
|
|
return activeMinions.Count < maxActiveMinions;
|
|
}
|
|
}
|
|
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()
|
|
{
|
|
spawnRequested = false;
|
|
if (isSpawning)
|
|
{
|
|
if (enableDebug) Debug.Log("[SummonerAI] Already spawning minions");
|
|
return;
|
|
}
|
|
|
|
if (minionPrefab == null)
|
|
{
|
|
Debug.LogError("[SummonerAI] No minion prefab assigned!");
|
|
return;
|
|
}
|
|
|
|
if (requireZeroMinionsToSummon && HasActiveMinions)
|
|
{
|
|
if (enableDebug) Debug.Log("[SummonerAI] Minions alive - summon blocked");
|
|
return;
|
|
}
|
|
|
|
spawnCoroutine = StartCoroutine(SpawnMinionsCoroutine());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Request spawning to begin on an animation event.
|
|
/// </summary>
|
|
public void RequestSpawn()
|
|
{
|
|
spawnRequested = true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Cancel a pending spawn request (e.g., on state exit).
|
|
/// </summary>
|
|
public void CancelSpawnRequest()
|
|
{
|
|
spawnRequested = false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Animation event hook. Call from the summon animation.
|
|
/// </summary>
|
|
public void OnSummonAnimationEvent()
|
|
{
|
|
if (!spawnRequested)
|
|
{
|
|
return;
|
|
}
|
|
|
|
StartSpawning();
|
|
}
|
|
|
|
/// <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 player is within the desired safe distance
|
|
/// </summary>
|
|
public bool IsPlayerTooCloseForSafeDistance()
|
|
{
|
|
return GetDistanceToPlayer() < safeDistance;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Check if summoner has reached/maintains safe distance
|
|
/// </summary>
|
|
public bool IsAtSafeDistance()
|
|
{
|
|
return GetDistanceToPlayer() >= safeDistance;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Should summoner try to flee to reach safe distance
|
|
/// </summary>
|
|
public bool ShouldFleeToSafeDistance()
|
|
{
|
|
float distance = GetDistanceToPlayer();
|
|
if (distance <= meleeEngageDistance) return false;
|
|
if (CanSpawnMinions || CanCastSpell()) return false;
|
|
return distance < safeDistance;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Check if summoner should engage in melee combat
|
|
/// </summary>
|
|
public bool ShouldEngageMelee()
|
|
{
|
|
if (!IsPlayerInMeleeRange()) return false;
|
|
if (!CanMeleeNow()) return false;
|
|
return !CanSpawnMinions && !CanCastSpell();
|
|
}
|
|
|
|
public bool CanMeleeNow()
|
|
{
|
|
return Time.time >= nextMeleeTime;
|
|
}
|
|
|
|
public void NotifyMeleeAttack()
|
|
{
|
|
float min = Mathf.Min(meleeCooldownMin, meleeCooldownMax);
|
|
float max = Mathf.Max(meleeCooldownMin, meleeCooldownMax);
|
|
if (max <= 0f)
|
|
{
|
|
nextMeleeTime = 0f;
|
|
return;
|
|
}
|
|
nextMeleeTime = Time.time + Random.Range(min, max);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Check if summoner can cast a ranged spell now
|
|
/// </summary>
|
|
public bool CanCastSpell()
|
|
{
|
|
if (isSpawning) return false;
|
|
if (fireballPrefab == null) return false;
|
|
if (spellRequiresMinionsAlive && !HasActiveMinions) return false;
|
|
|
|
float distance = GetDistanceToPlayer();
|
|
if (distance < spellMinDistance) return false;
|
|
if (spellMaxDistance > 0f && distance > spellMaxDistance) return false;
|
|
|
|
return (Time.time - lastSpellTime) >= spellCooldown;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Notify that a spell was cast (updates cooldown timer)
|
|
/// </summary>
|
|
public void NotifySpellCast()
|
|
{
|
|
lastSpellTime = Time.time;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Spawn a fireball from pivot towards player
|
|
/// </summary>
|
|
public void CastFireball()
|
|
{
|
|
if (!CanCastSpell()) return;
|
|
|
|
Transform pivot = spellSpawnPivot != null ? spellSpawnPivot : transform;
|
|
Vector3 targetPos = playerTransform != null ? playerTransform.position + Vector3.up * 1f : (pivot.position + pivot.forward);
|
|
Vector3 dir = (targetPos - pivot.position).normalized;
|
|
if (dir == Vector3.zero) dir = pivot.forward;
|
|
|
|
Quaternion rot = Quaternion.LookRotation(dir);
|
|
LeanPool.Spawn(fireballPrefab, pivot.position, rot);
|
|
|
|
NotifySpellCast();
|
|
|
|
if (enableDebug) Debug.Log("[SummonerAI] Cast fireball");
|
|
}
|
|
|
|
/// <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 safe distance
|
|
Gizmos.color = Color.yellow;
|
|
DrawCircle(transform.position, safeDistance, 24);
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
}
|
|
}
|