422 lines
13 KiB
C#
422 lines
13 KiB
C#
using System;
|
|
using System.Collections;
|
|
using UnityEngine;
|
|
using UnityEngine.AI;
|
|
using Arena.Combat;
|
|
|
|
namespace Arena.Characters
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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;
|
|
|
|
/// <summary>
|
|
/// Raised once, when the enemy dies, just before it is destroyed.
|
|
/// </summary>
|
|
public event Action<Enemy> 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
|
|
|
|
/// <summary>
|
|
/// Enemies are always on the enemy side.
|
|
/// </summary>
|
|
public Team Team => Team.Enemy;
|
|
|
|
public void TakeDamage(DamageInfo damage)
|
|
{
|
|
_health.TakeDamage(damage);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stops the enemy for a duration, in seconds, and plays the stunned animation.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private bool HasLineOfFire(Player player)
|
|
{
|
|
if (Time.time < _nextLineOfFireTime)
|
|
return _state.HasLineOfFire;
|
|
|
|
_nextLineOfFireTime = Time.time + _settings.LineOfFireInterval;
|
|
|
|
if (!_playerCollider)
|
|
_playerCollider = player.GetComponent<Collider>();
|
|
|
|
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<Collider>())
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private bool IsOnScreen()
|
|
{
|
|
Camera camera = Camera.main;
|
|
|
|
if (!camera)
|
|
return false;
|
|
|
|
Plane[] planes = GeometryUtility.CalculateFrustumPlanes(camera);
|
|
|
|
foreach (Renderer bodyRenderer in GetComponentsInChildren<Renderer>())
|
|
{
|
|
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
|
|
}
|
|
}
|