feat: Enemies
This commit is contained in:
421
Assets/_Content/Scripts/Characters/Enemy.cs
Normal file
421
Assets/_Content/Scripts/Characters/Enemy.cs
Normal file
@@ -0,0 +1,421 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user