From 1beff44ada038eaaa5d12b9e9aa47cda9e45252d Mon Sep 17 00:00:00 2001 From: SzymonMis Date: Tue, 20 Jan 2026 21:04:22 +0100 Subject: [PATCH] Summons --- Assets/AI/_ShadowPrince.meta | 8 + Assets/AI/_Summons/SummonDamageReceiver.cs | 101 +++++ Assets/AI/_Summons/Summoner_Barghest.prefab | 4 +- Assets/AI/_Summons/Summoner_Bug.prefab | 4 +- Assets/AI/_Summons/Summoner_Bug_Spider.prefab | 4 +- Assets/AI/_Summons/Summoner_Demon_Dog.prefab | 4 +- .../AI/_Summons/Summoner_Devil_Spider.prefab | 4 +- Assets/AI/_Summons/Summoner_Flying_Bug.prefab | 4 +- Assets/AI/_Summons/Summoner_Golem.prefab | 4 +- .../AI/_Summons/Summoner_Golem_Spider.prefab | 4 +- Assets/AI/_Summons/SwarmAgent.cs | 378 ++++++++++++++++++ Assets/AI/_Summons/SwarmCoordinator.cs | 56 +++ 12 files changed, 559 insertions(+), 16 deletions(-) create mode 100644 Assets/AI/_ShadowPrince.meta create mode 100644 Assets/AI/_Summons/SummonDamageReceiver.cs create mode 100644 Assets/AI/_Summons/SwarmAgent.cs create mode 100644 Assets/AI/_Summons/SwarmCoordinator.cs diff --git a/Assets/AI/_ShadowPrince.meta b/Assets/AI/_ShadowPrince.meta new file mode 100644 index 000000000..2c187019f --- /dev/null +++ b/Assets/AI/_ShadowPrince.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e0df5053eb5137643a9894a4186b74f7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/AI/_Summons/SummonDamageReceiver.cs b/Assets/AI/_Summons/SummonDamageReceiver.cs new file mode 100644 index 000000000..90c82adeb --- /dev/null +++ b/Assets/AI/_Summons/SummonDamageReceiver.cs @@ -0,0 +1,101 @@ +using UnityEngine; +using Invector; + +public class SummonDamageReceiver : MonoBehaviour, vIHealthController +{ + [Header("Health")] + [SerializeField] private int maxHealth = 40; + [SerializeField] private float currentHealth = 40f; + + [Header("Death")] + [SerializeField] private bool destroyOnDeath = true; + [SerializeField] private float destroyDelay = 0.1f; + [SerializeField] private GameObject deathEffect; + + [Header("Debug")] + [SerializeField] private bool enableDebug = false; + + [SerializeField] private OnReceiveDamage _onStartReceiveDamage = new OnReceiveDamage(); + [SerializeField] private OnReceiveDamage _onReceiveDamage = new OnReceiveDamage(); + [SerializeField] private OnDead _onDead = new OnDead(); + + public OnReceiveDamage onStartReceiveDamage => _onStartReceiveDamage; + public OnReceiveDamage onReceiveDamage => _onReceiveDamage; + public OnDead onDead => _onDead; + + public float currentHealth => this.currentHealth; + public int MaxHealth => maxHealth; + public bool isDead { get; set; } + + private void Awake() + { + ResetHealth(); + } + + public void TakeDamage(vDamage damage) + { + if (isDead) return; + _onStartReceiveDamage.Invoke(damage); + + int dmg = Mathf.RoundToInt(damage.damageValue); + if (dmg <= 0) return; + + ChangeHealth(-dmg); + _onReceiveDamage.Invoke(damage); + + if (enableDebug) + Debug.Log($"[SummonDamageReceiver] {gameObject.name} took {dmg}, HP {currentHealth}/{maxHealth}"); + } + + public void AddHealth(int value) + { + if (isDead) return; + currentHealth = Mathf.Min(maxHealth, currentHealth + value); + } + + public void ChangeHealth(int value) + { + if (isDead) return; + currentHealth = Mathf.Clamp(currentHealth + value, 0f, maxHealth); + + if (currentHealth <= 0f) + Die(); + } + + public void ChangeMaxHealth(int value) + { + maxHealth = Mathf.Max(1, maxHealth + value); + currentHealth = Mathf.Min(currentHealth, maxHealth); + } + + public void ResetHealth(float health) + { + maxHealth = Mathf.Max(1, maxHealth); + currentHealth = Mathf.Clamp(health, 0f, maxHealth); + isDead = false; + } + + public void ResetHealth() + { + maxHealth = Mathf.Max(1, maxHealth); + currentHealth = maxHealth; + isDead = false; + } + + private void Die() + { + if (isDead) return; + isDead = true; + + _onDead.Invoke(gameObject); + + if (deathEffect != null) + { + GameObject fx = Instantiate(deathEffect, transform.position, transform.rotation); + Destroy(fx, 5f); + } + + if (destroyOnDeath) + Destroy(gameObject, destroyDelay); + } +} diff --git a/Assets/AI/_Summons/Summoner_Barghest.prefab b/Assets/AI/_Summons/Summoner_Barghest.prefab index 9603e5955..620e857be 100644 --- a/Assets/AI/_Summons/Summoner_Barghest.prefab +++ b/Assets/AI/_Summons/Summoner_Barghest.prefab @@ -284,7 +284,7 @@ Animator: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 2274584332873493075} - m_Enabled: 1 + m_Enabled: 0 m_Avatar: {fileID: 9000000, guid: 0120314ed622daf4f829420bf369f48d, type: 3} m_Controller: {fileID: 9100000, guid: f1afa426b318a544da0ef42f7fe15542, type: 2} m_CullingMode: 1 @@ -936,7 +936,7 @@ MonoBehaviour: openCloseWindow: 1 selectedToolbar: 0 _fsmBehaviour: {fileID: 11400000, guid: 73af8a7d97f5f714d819d2d2f73aa449, type: 2} - _stop: 0 + _stop: 1 _debugMode: 0 onStartFSM: m_PersistentCalls: diff --git a/Assets/AI/_Summons/Summoner_Bug.prefab b/Assets/AI/_Summons/Summoner_Bug.prefab index b96726417..07a225706 100644 --- a/Assets/AI/_Summons/Summoner_Bug.prefab +++ b/Assets/AI/_Summons/Summoner_Bug.prefab @@ -166,7 +166,7 @@ SkinnedMeshRenderer: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 119298518639331252} - m_Enabled: 1 + m_Enabled: 0 m_CastShadows: 1 m_ReceiveShadows: 1 m_DynamicOccludee: 1 @@ -21859,7 +21859,7 @@ MonoBehaviour: openCloseWindow: 1 selectedToolbar: 0 _fsmBehaviour: {fileID: 11400000, guid: 478bc6a7e9fd82c4a9d395af400e05e6, type: 2} - _stop: 0 + _stop: 1 _debugMode: 0 onStartFSM: m_PersistentCalls: diff --git a/Assets/AI/_Summons/Summoner_Bug_Spider.prefab b/Assets/AI/_Summons/Summoner_Bug_Spider.prefab index c1c04d19c..5eeb68c63 100644 --- a/Assets/AI/_Summons/Summoner_Bug_Spider.prefab +++ b/Assets/AI/_Summons/Summoner_Bug_Spider.prefab @@ -4533,7 +4533,7 @@ ParticleSystemRenderer: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 6585598795937982} - m_Enabled: 1 + m_Enabled: 0 m_CastShadows: 1 m_ReceiveShadows: 1 m_DynamicOccludee: 1 @@ -13371,7 +13371,7 @@ MonoBehaviour: openCloseWindow: 1 selectedToolbar: 0 _fsmBehaviour: {fileID: 11400000, guid: d9cf652bbbb9e0c40b58b5d59170055a, type: 2} - _stop: 0 + _stop: 1 _debugMode: 0 onStartFSM: m_PersistentCalls: diff --git a/Assets/AI/_Summons/Summoner_Demon_Dog.prefab b/Assets/AI/_Summons/Summoner_Demon_Dog.prefab index a140fc3e0..ccc82dbb6 100644 --- a/Assets/AI/_Summons/Summoner_Demon_Dog.prefab +++ b/Assets/AI/_Summons/Summoner_Demon_Dog.prefab @@ -103,7 +103,7 @@ MonoBehaviour: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 69635961216777874} - m_Enabled: 1 + m_Enabled: 0 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: 1b04f4ac1b776ff48b5f78a97b57f776, type: 3} m_Name: @@ -30742,7 +30742,7 @@ MonoBehaviour: openCloseWindow: 1 selectedToolbar: 0 _fsmBehaviour: {fileID: 11400000, guid: 73af8a7d97f5f714d819d2d2f73aa449, type: 2} - _stop: 0 + _stop: 1 _debugMode: 0 onStartFSM: m_PersistentCalls: diff --git a/Assets/AI/_Summons/Summoner_Devil_Spider.prefab b/Assets/AI/_Summons/Summoner_Devil_Spider.prefab index 8a8bc0669..2148803a4 100644 --- a/Assets/AI/_Summons/Summoner_Devil_Spider.prefab +++ b/Assets/AI/_Summons/Summoner_Devil_Spider.prefab @@ -39,7 +39,7 @@ SkinnedMeshRenderer: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 226257558141750692} - m_Enabled: 1 + m_Enabled: 0 m_CastShadows: 1 m_ReceiveShadows: 1 m_DynamicOccludee: 1 @@ -6368,7 +6368,7 @@ MonoBehaviour: openCloseWindow: 1 selectedToolbar: 0 _fsmBehaviour: {fileID: 11400000, guid: 478bc6a7e9fd82c4a9d395af400e05e6, type: 2} - _stop: 0 + _stop: 1 _debugMode: 0 onStartFSM: m_PersistentCalls: diff --git a/Assets/AI/_Summons/Summoner_Flying_Bug.prefab b/Assets/AI/_Summons/Summoner_Flying_Bug.prefab index 7255d4269..96fa175cc 100644 --- a/Assets/AI/_Summons/Summoner_Flying_Bug.prefab +++ b/Assets/AI/_Summons/Summoner_Flying_Bug.prefab @@ -4753,7 +4753,7 @@ ParticleSystemRenderer: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 644455550314575239} - m_Enabled: 1 + m_Enabled: 0 m_CastShadows: 0 m_ReceiveShadows: 1 m_DynamicOccludee: 1 @@ -38453,7 +38453,7 @@ MonoBehaviour: openCloseWindow: 1 selectedToolbar: 0 _fsmBehaviour: {fileID: 11400000, guid: 478bc6a7e9fd82c4a9d395af400e05e6, type: 2} - _stop: 0 + _stop: 1 _debugMode: 0 onStartFSM: m_PersistentCalls: diff --git a/Assets/AI/_Summons/Summoner_Golem.prefab b/Assets/AI/_Summons/Summoner_Golem.prefab index 18101637d..6cfda475e 100644 --- a/Assets/AI/_Summons/Summoner_Golem.prefab +++ b/Assets/AI/_Summons/Summoner_Golem.prefab @@ -103,7 +103,7 @@ MonoBehaviour: m_PrefabInstance: {fileID: 0} m_PrefabAsset: {fileID: 0} m_GameObject: {fileID: 466729051031186190} - m_Enabled: 1 + m_Enabled: 0 m_EditorHideFlags: 0 m_Script: {fileID: 11500000, guid: fb97f58541b05b24ab37661346298ff2, type: 3} m_Name: @@ -1005,7 +1005,7 @@ MonoBehaviour: openCloseWindow: 1 selectedToolbar: 0 _fsmBehaviour: {fileID: 0} - _stop: 0 + _stop: 1 _debugMode: 0 onStartFSM: m_PersistentCalls: diff --git a/Assets/AI/_Summons/Summoner_Golem_Spider.prefab b/Assets/AI/_Summons/Summoner_Golem_Spider.prefab index b48305816..dd6896fbc 100644 --- a/Assets/AI/_Summons/Summoner_Golem_Spider.prefab +++ b/Assets/AI/_Summons/Summoner_Golem_Spider.prefab @@ -198,7 +198,7 @@ CapsuleCollider: m_LayerOverridePriority: 0 m_IsTrigger: 0 m_ProvidesContacts: 0 - m_Enabled: 1 + m_Enabled: 0 serializedVersion: 2 m_Radius: 1 m_Height: 4.25 @@ -758,7 +758,7 @@ MonoBehaviour: openCloseWindow: 1 selectedToolbar: 0 _fsmBehaviour: {fileID: 11400000, guid: 478bc6a7e9fd82c4a9d395af400e05e6, type: 2} - _stop: 0 + _stop: 1 _debugMode: 0 onStartFSM: m_PersistentCalls: diff --git a/Assets/AI/_Summons/SwarmAgent.cs b/Assets/AI/_Summons/SwarmAgent.cs new file mode 100644 index 000000000..2c8035be2 --- /dev/null +++ b/Assets/AI/_Summons/SwarmAgent.cs @@ -0,0 +1,378 @@ +using System.Collections; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.AI; +using Invector.vMelee; + +public class SwarmAgent : MonoBehaviour +{ + public enum SwarmProfile + { + Default, + Spider, + Wolf, + Dragonfly + } + + private enum SwarmState + { + Idle, + Chase, + Attack, + Retreat + } + + [Header("Profile")] + [SerializeField] private bool autoProfileByName = true; + [SerializeField] private SwarmProfile profileOverride = SwarmProfile.Default; + + [Header("Targeting")] + [SerializeField] private SwarmCoordinator coordinator; + [SerializeField] private string playerTag = "Player"; + [SerializeField] private float targetRefreshInterval = 1.0f; + + [Header("Movement")] + [SerializeField] private float moveSpeed = 3.5f; + [SerializeField] private float acceleration = 8f; + [SerializeField] private float angularSpeed = 360f; + [SerializeField] private float stoppingDistance = 1.6f; + [SerializeField] private float repathInterval = 0.25f; + + [Header("Attack")] + [SerializeField] private float attackRange = 1.8f; + [SerializeField] private Vector2Int attacksPerBurst = new Vector2Int(1, 2); + [SerializeField] private float attackCooldown = 0.8f; + [SerializeField] private float damageEnableDelay = 0.1f; + [SerializeField] private float damageWindow = 0.25f; + [SerializeField] private string[] attackTriggers = new string[] { "Attack" }; + + [Header("Retreat")] + [SerializeField] private float retreatDuration = 1.25f; + [SerializeField] private float retreatDistance = 3.5f; + + [Header("Spider Strafe")] + [SerializeField] private float strafeRadius = 2.5f; + [SerializeField] private float strafeSpeed = 2.0f; + + [Header("Wolf Flank")] + [SerializeField] private float flankDistance = 2.5f; + [SerializeField] private float flankSideOffset = 2.0f; + [SerializeField] private float flankReplanInterval = 0.6f; + + [Header("Dragonfly Figure 8")] + [SerializeField] private float figureEightRadius = 3.0f; + [SerializeField] private float figureEightSpeed = 2.2f; + + private SwarmProfile profile; + private SwarmState state = SwarmState.Idle; + private NavMeshAgent agent; + private Animator animator; + private vMeleeAttackObject[] meleeAttackObjects; + private Transform target; + private float nextTargetRefreshTime; + private float nextRepathTime; + private float nextTickTime; + private float lastAttackTime; + private float retreatEndTime; + private float flankSide = 1f; + private float nextFlankReplanTime; + private Vector3 retreatCenter; + private float retreatStartTime; + private Coroutine attackRoutine; + + private void Awake() + { + agent = GetComponent(); + animator = GetComponentInChildren(); + meleeAttackObjects = GetComponentsInChildren(true); + + if (coordinator == null) + coordinator = GetComponentInParent(); + + if (autoProfileByName) + profile = ResolveProfileByName(gameObject.name); + else + profile = profileOverride; + } + + private void OnEnable() + { + if (coordinator != null) coordinator.Register(this); + DisableDamage(); + ConfigureAgent(); + } + + private void OnDisable() + { + if (coordinator != null) coordinator.Unregister(this); + } + + private void Update() + { + if (Time.time < nextTickTime) return; + nextTickTime = Time.time + GetUpdateInterval(); + Tick(); + } + + private void Tick() + { + RefreshTargetIfNeeded(); + if (target == null) + { + state = SwarmState.Idle; + if (agent != null) agent.isStopped = true; + return; + } + + if (state == SwarmState.Retreat) + { + UpdateRetreat(); + return; + } + + if (state == SwarmState.Attack) + return; + + float sqrDist = (target.position - transform.position).sqrMagnitude; + float attackRangeSqr = attackRange * attackRange; + + if (sqrDist <= attackRangeSqr && Time.time >= lastAttackTime + attackCooldown) + { + StartAttackBurst(); + return; + } + + UpdateChase(); + } + + private void UpdateChase() + { + if (agent == null) return; + state = SwarmState.Chase; + agent.isStopped = false; + + if (Time.time < nextRepathTime) return; + nextRepathTime = Time.time + repathInterval; + + Vector3 desired = GetDesiredPosition(); + agent.SetDestination(desired); + } + + private void StartAttackBurst() + { + if (attackRoutine != null) StopCoroutine(attackRoutine); + attackRoutine = StartCoroutine(AttackBurstCoroutine()); + } + + private IEnumerator AttackBurstCoroutine() + { + state = SwarmState.Attack; + lastAttackTime = Time.time; + + if (agent != null) agent.isStopped = true; + + int minAttacks = Mathf.Max(1, attacksPerBurst.x); + int maxAttacks = Mathf.Max(minAttacks, attacksPerBurst.y); + int count = Random.Range(minAttacks, maxAttacks + 1); + + for (int i = 0; i < count; i++) + { + TriggerAttackAnimation(); + yield return StartCoroutine(DamageWindowCoroutine()); + yield return new WaitForSeconds(attackCooldown); + } + + StartRetreat(); + } + + private IEnumerator DamageWindowCoroutine() + { + if (damageEnableDelay > 0f) + yield return new WaitForSeconds(damageEnableDelay); + + EnableDamage(); + + if (damageWindow > 0f) + yield return new WaitForSeconds(damageWindow); + + DisableDamage(); + } + + private void StartRetreat() + { + state = SwarmState.Retreat; + retreatStartTime = Time.time; + retreatEndTime = retreatStartTime + retreatDuration; + + if (target != null) + retreatCenter = target.position; + else + retreatCenter = transform.position; + + if (agent != null) agent.isStopped = false; + } + + private void UpdateRetreat() + { + if (agent == null) return; + if (Time.time >= retreatEndTime) + { + state = SwarmState.Chase; + return; + } + + if (Time.time < nextRepathTime) return; + nextRepathTime = Time.time + repathInterval; + + Vector3 retreatTarget; + if (profile == SwarmProfile.Dragonfly) + { + float t = (Time.time - retreatStartTime) * figureEightSpeed; + float x = Mathf.Sin(t); + float z = Mathf.Sin(t * 2f) * 0.5f; + Vector3 offset = new Vector3(x, 0f, z) * figureEightRadius; + retreatTarget = retreatCenter + offset; + } + else + { + Vector3 away = (transform.position - target.position).normalized; + retreatTarget = transform.position + away * retreatDistance; + } + + agent.SetDestination(retreatTarget); + } + + private Vector3 GetDesiredPosition() + { + Vector3 baseTarget = target.position; + Vector3 separation = GetSeparationOffset(); + + switch (profile) + { + case SwarmProfile.Spider: + return baseTarget + GetStrafeOffset() + separation; + case SwarmProfile.Wolf: + return GetFlankPosition() + separation; + case SwarmProfile.Dragonfly: + return baseTarget + separation; + default: + return baseTarget + separation; + } + } + + private Vector3 GetSeparationOffset() + { + if (coordinator == null) return Vector3.zero; + float radius = coordinator.SeparationRadius; + if (radius <= 0f) return Vector3.zero; + + List agents = coordinator.GetAgents(); + if (agents == null || agents.Count == 0) return Vector3.zero; + + Vector3 offset = Vector3.zero; + float radiusSqr = radius * radius; + Vector3 pos = transform.position; + + for (int i = 0; i < agents.Count; i++) + { + SwarmAgent other = agents[i]; + if (other == null || other == this) continue; + Vector3 delta = pos - other.transform.position; + float distSqr = delta.sqrMagnitude; + if (distSqr > 0f && distSqr < radiusSqr) + offset += delta.normalized * (1f - (distSqr / radiusSqr)); + } + + return offset * coordinator.SeparationWeight; + } + + private Vector3 GetStrafeOffset() + { + Vector3 toTarget = (target.position - transform.position).normalized; + Vector3 right = new Vector3(-toTarget.z, 0f, toTarget.x); + float wave = Mathf.Sin(Time.time * strafeSpeed); + return right * (wave * strafeRadius); + } + + private Vector3 GetFlankPosition() + { + if (Time.time >= nextFlankReplanTime) + { + nextFlankReplanTime = Time.time + flankReplanInterval; + flankSide = Random.value < 0.5f ? -1f : 1f; + } + + Vector3 behind = -target.forward * flankDistance; + Vector3 side = target.right * (flankSide * flankSideOffset); + Vector3 desired = target.position + behind + side; + return desired; + } + + private void ConfigureAgent() + { + if (agent == null) return; + agent.speed = moveSpeed; + agent.acceleration = acceleration; + agent.angularSpeed = angularSpeed; + agent.stoppingDistance = stoppingDistance; + } + + private void RefreshTargetIfNeeded() + { + if (coordinator != null && coordinator.Target != null) + { + target = coordinator.Target; + return; + } + + if (Time.time < nextTargetRefreshTime) return; + nextTargetRefreshTime = Time.time + targetRefreshInterval; + + GameObject player = GameObject.FindGameObjectWithTag(playerTag); + if (player != null) target = player.transform; + } + + private void TriggerAttackAnimation() + { + if (animator == null || attackTriggers == null || attackTriggers.Length == 0) return; + string trigger = attackTriggers[Random.Range(0, attackTriggers.Length)]; + if (!string.IsNullOrEmpty(trigger)) + animator.SetTrigger(trigger); + } + + private void EnableDamage() + { + for (int i = 0; i < meleeAttackObjects.Length; i++) + { + if (meleeAttackObjects[i] != null) + meleeAttackObjects[i].SetActiveDamage(true); + } + } + + private void DisableDamage() + { + for (int i = 0; i < meleeAttackObjects.Length; i++) + { + if (meleeAttackObjects[i] != null) + meleeAttackObjects[i].SetActiveDamage(false); + } + } + + private float GetUpdateInterval() + { + if (coordinator == null) return 0.2f; + float jitter = coordinator.UpdateJitter; + return coordinator.UpdateInterval + (jitter > 0f ? Random.Range(-jitter, jitter) : 0f); + } + + private SwarmProfile ResolveProfileByName(string objectName) + { + string nameLower = objectName.ToLowerInvariant(); + if (nameLower.Contains("spider")) return SwarmProfile.Spider; + if (nameLower.Contains("dog") || nameLower.Contains("barghest") || nameLower.Contains("golem")) + return SwarmProfile.Wolf; + if (nameLower.Contains("bug") || nameLower.Contains("flying")) + return SwarmProfile.Dragonfly; + return SwarmProfile.Default; + } +} diff --git a/Assets/AI/_Summons/SwarmCoordinator.cs b/Assets/AI/_Summons/SwarmCoordinator.cs new file mode 100644 index 000000000..31d2a201f --- /dev/null +++ b/Assets/AI/_Summons/SwarmCoordinator.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using UnityEngine; + +public class SwarmCoordinator : MonoBehaviour +{ + [Header("Targeting")] + [SerializeField] private bool autoFindPlayer = true; + [SerializeField] private string playerTag = "Player"; + [SerializeField] private float targetRefreshInterval = 0.75f; + + [Header("Swarm Tuning")] + [SerializeField] private float updateInterval = 0.2f; + [SerializeField] private float updateJitter = 0.05f; + [SerializeField] private float separationRadius = 1.2f; + [SerializeField] private float separationWeight = 1.0f; + + private readonly List agents = new List(); + private Transform target; + private float nextTargetRefreshTime; + + public Transform Target => target; + public float UpdateInterval => updateInterval; + public float UpdateJitter => updateJitter; + public float SeparationRadius => separationRadius; + public float SeparationWeight => separationWeight; + + private void Update() + { + if (!autoFindPlayer) return; + if (Time.time < nextTargetRefreshTime) return; + nextTargetRefreshTime = Time.time + targetRefreshInterval; + + if (target == null) + { + GameObject player = GameObject.FindGameObjectWithTag(playerTag); + if (player != null) target = player.transform; + } + } + + public void Register(SwarmAgent agent) + { + if (agent == null || agents.Contains(agent)) return; + agents.Add(agent); + } + + public void Unregister(SwarmAgent agent) + { + if (agent == null) return; + agents.Remove(agent); + } + + public List GetAgents() + { + return agents; + } +}