using System; using System.Collections; using UnityEngine; using UnityEngine.AI; using Arena.Combat; namespace Arena.Characters { /// /// Enemy moved by a NavMeshAgent towards the player. /// Without launcher (grunt): stops in front of the player, plays its attack and hits through a child DamageZone. /// With a launcher (shooter): within attack range, checks the line of fire with a raycast; fires if it is clear, /// otherwise keeps moving towards the player. /// [RequireComponent(typeof(NavMeshAgent))] public class Enemy : MonoBehaviour, IDamageable { [System.Serializable] private class Settings { [Tooltip("Distance at which a shooter stops and fires, in meters. Unused without a launcher.")] [Min(0f)] public float AttackRange = 10f; [Tooltip("Distance at which a grunt stops and attacks, in meters. Unused with a launcher.")] [Min(0f)] public float MeleeRange = 1.5f; [Tooltip("Time between the start of two melee attacks, in seconds.")] [Min(0.1f)] public float AttackInterval = 1.2f; [Tooltip("Time between the start of the attack animation and the hit, in seconds.")] [Min(0f)] public float HitDelay = 0.3f; [Tooltip("Time between two checks of the line of fire, in seconds.")] [Min(0.05f)] public float LineOfFireInterval = 0.2f; [Tooltip("Time between two path updates, in seconds.")] [Min(0.05f)] public float RepathInterval = 0.25f; [Tooltip("Time left to the die animation before the enemy is removed, in seconds.")] [Min(0f)] public float DeathDuration = 2f; [Tooltip("After the death duration, wait until the body is off screen before removing it.")] public bool DestroyWhenOffScreen; [Tooltip("Time between two visibility checks of the body, in seconds.")] [Min(0.05f)] public float OffScreenCheckInterval = 0.5f; } [System.Serializable] private struct References { public NavMeshAgent Agent; [Tooltip("Optional: makes this enemy a shooter.")] public ProjectileLauncher Launcher; [Tooltip("Grunt: child DamageZone in Instant mode, Explode On Enable unticked, placed in front of the enemy.")] public DamageZone MeleeHitbox; public CharacterAnimator CharacterAnimator; } [System.Serializable] public class StateContainer { public bool IsChasing; public bool IsAttacking; public bool HasLineOfFire; public bool IsGrounded = true; public bool IsStunned; } #region FIELDS [SerializeField] private Settings _settings; [SerializeField] private References _references; [SerializeField] private StateContainer _state; [SerializeField] private Health _health; /// /// Raised once, when the enemy dies, just before it is destroyed. /// public event Action OnDied; public StateContainer State => _state; public Health Health => _health; private float _nextRepathTime; private float _stunEndTime; private bool _hasWarnedNoNavMesh; private float _nextAttackTime; private float _hitTime = -1f; private float _nextLineOfFireTime; private Collider _playerCollider; private readonly RaycastHit[] _lineOfFireHits = new RaycastHit[8]; #endregion #region UNITY LIFECYCLES void Awake() { _health.Initialize(); _health.OnDied += HandleDied; } void OnDestroy() { _health.OnDied -= HandleDied; } void Update() { NavMeshAgent agent = _references.Agent; // No NavMesh baked, or the enemy is not placed on it: it waits instead of throwing errors if (!agent.isOnNavMesh) { WarnNoNavMesh(); return; } _hasWarnedNoNavMesh = false; // An agent on a NavMesh link (jump, drop) is not on the ground SetGrounded(!agent.isOnOffMeshLink); // A started swing lands even if the player has moved away UpdatePendingHit(); if (_state.IsStunned && Time.time >= _stunEndTime) _state.IsStunned = false; // A stunned enemy cannot shoot if (_references.Launcher) _references.Launcher.SetDisabled(_state.IsStunned); if (_state.IsStunned) return; Player player = Player.Instance; if (!player || player.Health.IsDead) { Stop(); return; } Vector3 toPlayer = player.transform.position - transform.position; toPlayer.y = 0f; if (_references.Launcher) { // Out of range, or something in the way: get closer if (IsWithin(toPlayer, _settings.AttackRange) && HasLineOfFire(player)) Shoot(toPlayer); else Chase(player.transform.position); } else { if (IsWithin(toPlayer, _settings.MeleeRange)) MeleeAttack(toPlayer); else Chase(player.transform.position); } } #endregion #region PUBLIC METHODS /// /// Enemies are always on the enemy side. /// public Team Team => Team.Enemy; public void TakeDamage(DamageInfo damage) { _health.TakeDamage(damage); } /// /// Stops the enemy for a duration, in seconds, and plays the stunned animation. /// public void Stun(float duration) { if (_health.IsDead) return; _stunEndTime = Time.time + duration; _state.IsStunned = true; // A stun interrupts the attack in progress _hitTime = -1f; Stop(); _references.CharacterAnimator.Stun(duration); } #endregion #region PRIVATE METHODS private void Chase(Vector3 target) { _state.IsChasing = true; _state.IsAttacking = false; _references.Agent.isStopped = false; // Recomputing a path every frame is costly: a few times per second is enough if (Time.time < _nextRepathTime) return; _nextRepathTime = Time.time + _settings.RepathInterval; _references.Agent.SetDestination(target); } private void Shoot(Vector3 toPlayer) { StopAndFace(toPlayer); _references.Launcher.TryFire(); } /// /// Raycast from the muzzle to the center of the player: true if the first thing hit is the player. /// Checked a few times per second, the last result is kept in between. /// private bool HasLineOfFire(Player player) { if (Time.time < _nextLineOfFireTime) return _state.HasLineOfFire; _nextLineOfFireTime = Time.time + _settings.LineOfFireInterval; if (!_playerCollider) _playerCollider = player.GetComponent(); Vector3 origin = _references.Launcher.Muzzle.position; Vector3 target = _playerCollider ? _playerCollider.bounds.center : player.transform.position; Vector3 toTarget = target - origin; float distance = toTarget.magnitude; if (distance < 0.01f) return _state.HasLineOfFire = true; int hitCount = Physics.RaycastNonAlloc(origin, toTarget / distance, _lineOfFireHits, distance, ~0, QueryTriggerInteraction.Ignore); // The hits are not sorted: keep the closest one that is not this enemy float closestDistance = float.MaxValue; Transform closest = null; for (int i = 0; i < hitCount; i++) { RaycastHit hit = _lineOfFireHits[i]; if (hit.transform.IsChildOf(transform) || hit.distance >= closestDistance) continue; closestDistance = hit.distance; closest = hit.transform; } // Nothing hit: the ray stopped just before the player collider, the way is clear _state.HasLineOfFire = !closest || closest.IsChildOf(player.transform); return _state.HasLineOfFire; } private void MeleeAttack(Vector3 toPlayer) { StopAndFace(toPlayer); if (Time.time < _nextAttackTime) return; _nextAttackTime = Time.time + _settings.AttackInterval; _hitTime = Time.time + _settings.HitDelay; _references.CharacterAnimator.Attack(); } private void UpdatePendingHit() { if (_hitTime < 0f || Time.time < _hitTime) return; _hitTime = -1f; if (_references.MeleeHitbox) _references.MeleeHitbox.Explode(); } private void StopAndFace(Vector3 toPlayer) { _state.IsChasing = false; _state.IsAttacking = true; _references.Agent.isStopped = true; if (toPlayer.sqrMagnitude > 0.01f) transform.rotation = Quaternion.LookRotation(toPlayer); } private static bool IsWithin(Vector3 offset, float range) { return offset.sqrMagnitude <= range * range; } private void Stop() { _state.IsChasing = false; _state.IsAttacking = false; if (_references.Agent.isOnNavMesh) _references.Agent.isStopped = true; } private void WarnNoNavMesh() { // Once, not every frame: the console stays readable if (_hasWarnedNoNavMesh) return; _hasWarnedNoNavMesh = true; Debug.LogWarning($"{name} is not on a NavMesh: bake a NavMeshSurface or move the enemy onto it. It stays still meanwhile.", this); } private void SetGrounded(bool isGrounded) { if (_state.IsGrounded == isGrounded) return; _state.IsGrounded = isGrounded; _references.CharacterAnimator.SetGrounded(isGrounded); } private void HandleDied() { OnDied?.Invoke(this); // The body stays for the die animation, but no longer moves, attacks nor gets hit _hitTime = -1f; Stop(); _references.Agent.enabled = false; // Dropped first: once unparented, its colliders are not disabled with the body if (_references.Launcher) _references.Launcher.Drop(); foreach (Collider bodyCollider in GetComponentsInChildren()) bodyCollider.enabled = false; _references.CharacterAnimator.Die(); enabled = false; // A disabled component still runs its coroutines StartCoroutine(RemoveBody()); } private IEnumerator RemoveBody() { yield return new WaitForSeconds(_settings.DeathDuration); if (_settings.DestroyWhenOffScreen) { WaitForSeconds interval = new WaitForSeconds(_settings.OffScreenCheckInterval); while (IsOnScreen()) yield return interval; } Destroy(gameObject); } /// /// True if a renderer of the body is inside the view of the main camera. /// Renderer.isVisible is not used: it also counts the Scene view camera in the editor. /// private bool IsOnScreen() { Camera camera = Camera.main; if (!camera) return false; Plane[] planes = GeometryUtility.CalculateFrustumPlanes(camera); foreach (Renderer bodyRenderer in GetComponentsInChildren()) { if (bodyRenderer.enabled && GeometryUtility.TestPlanesAABB(planes, bodyRenderer.bounds)) return true; } return false; } private void HandleGameLost() { if (_health.IsDead) return; Stop(); _references.CharacterAnimator.Win(); enabled = false; } #if UNITY_EDITOR [ContextMenu("Debug/Take 25 Physical Damage")] private void DebugTakeDamage() { TakeDamage(new DamageInfo(25, DamageType.Physical, gameObject)); } [ContextMenu("Debug/Stun 2 s")] private void DebugStun() { Stun(2f); } #endif #endregion } }