feat: Enemies

This commit is contained in:
2026-11-05 08:59:08 +01:00
parent cd65f702ae
commit 743703c6bd
109 changed files with 75286 additions and 15238 deletions

View File

@@ -54,6 +54,7 @@ namespace Arena.Characters
private static readonly int IsStunnedParameter = Animator.StringToHash("IsStunned");
private static readonly int JumpTrigger = Animator.StringToHash("Jump");
private static readonly int DashTrigger = Animator.StringToHash("Dash");
private static readonly int AttackTrigger = Animator.StringToHash("Attack");
private static readonly int DieTrigger = Animator.StringToHash("Die");
private static readonly int WinTrigger = Animator.StringToHash("Win");
private static readonly int LoseTrigger = Animator.StringToHash("Lose");
@@ -124,6 +125,11 @@ namespace Arena.Characters
SetTrigger(DashTrigger);
}
public void Attack()
{
SetTrigger(AttackTrigger);
}
/// <summary>
/// Sets IsStunned for a duration, in seconds.
/// </summary>

View 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
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 067d71d286c5dc64fb0fc717ef086ef9

View File

@@ -164,6 +164,7 @@ namespace Arena.Characters
GetInputs();
UpdateStun();
Move(t);
UpdateLauncher();
// A stunned player only falls: no aiming, shooting nor interacting
if (_state.IsStunned)
@@ -178,6 +179,11 @@ namespace Arena.Characters
#region PUBLIC METHODS
/// <summary>
/// The player is always on the allied side.
/// </summary>
public Team Team => Team.Allied;
public void TakeDamage(DamageInfo damage)
{
_health.TakeDamage(damage);
@@ -350,6 +356,9 @@ namespace Arena.Characters
private void HandleDied()
{
_references.CharacterAnimator.Die();
if (_references.Launcher)
_references.Launcher.Drop();
}
@@ -404,11 +413,16 @@ namespace Arena.Characters
private void Attack()
{
// No shooting while running
if (_isAttacking && !_state.IsRunning)
if (_isAttacking)
_references.Launcher.TryFire();
}
private void UpdateLauncher()
{
// No shooting while running, dashing or stunned
_references.Launcher.SetDisabled(_state.IsRunning || _state.IsDashing || _state.IsStunned);
}
private void Interact()
{
if (_isInteracting)

View File

@@ -3,7 +3,7 @@ using UnityEngine;
namespace Arena.Combat
{
/// <summary>
/// Everything a hit carries: amount, type and source. Passed to IDamageable.TakeDamage.
/// Everything a hit carries: amount, type, source and targeted team. Passed to IDamageable.TakeDamage.
/// </summary>
public readonly struct DamageInfo
{
@@ -16,11 +16,33 @@ namespace Arena.Combat
/// </summary>
public readonly GameObject Source;
public DamageInfo(int amount, DamageType type, GameObject source = null)
/// <summary>
/// Team hurt by this damage: Allied for enemy shots, Enemy for player shots, All for everyone.
/// </summary>
public readonly Team Target;
public DamageInfo(int amount, DamageType type, GameObject source = null, Team target = Team.All)
{
Amount = amount;
Type = type;
Source = source;
Target = target;
}
/// <summary>
/// True if this damage can hurt an IDamageable of the given team.
/// </summary>
public bool CanHit(Team team)
{
return CanHit(Target, team);
}
/// <summary>
/// True if a damage targeting a team can hurt an IDamageable of another team.
/// </summary>
public static bool CanHit(Team target, Team team)
{
return target == Team.All || team == Team.All || team == target;
}
}
}

View File

@@ -28,6 +28,9 @@ namespace Arena.Combat
public DamageType DamageType;
[Tooltip("Team hurt by the zone: Allied for an enemy attack, Enemy for a player attack, All for everyone.")]
public Team Target = Team.All;
[Tooltip("Damage multiplier by distance to the center. 0: center, 1: edge of the sphere.")]
public AnimationCurve Falloff = AnimationCurve.Linear(0f, 1f, 1f, 0f);
@@ -133,7 +136,7 @@ namespace Arena.Combat
{
target = other.GetComponentInParent<IDamageable>();
if (target == null)
if (target == null || !CanHit(target))
return false;
// The owner never hurts itself
@@ -150,7 +153,12 @@ namespace Arena.Combat
return;
GameObject source = _references.Owner ? _references.Owner : gameObject;
target.TakeDamage(new DamageInfo(amount, _settings.DamageType, source));
target.TakeDamage(new DamageInfo(amount, _settings.DamageType, source, _settings.Target));
}
private bool CanHit(IDamageable target)
{
return DamageInfo.CanHit(_settings.Target, target.Team);
}
private Vector3 GetCenter()

View File

@@ -14,6 +14,9 @@ namespace Arena.Combat
{
[Tooltip("Optional prefab spawned in place of the object: debris, explosion...")]
public GameObject SpawnOnDestroyed;
[Tooltip("All: hurt by everyone. Allied: only by enemy damage. Enemy: only by player damage.")]
public Team Team = Team.All;
}
#region FIELDS
@@ -47,6 +50,8 @@ namespace Arena.Combat
#region PUBLIC METHODS
public Team Team => _settings.Team;
public void TakeDamage(DamageInfo damage)
{
_health.TakeDamage(damage);

View File

@@ -5,6 +5,11 @@ namespace Arena.Combat
/// </summary>
public interface IDamageable
{
/// <summary>
/// Side of this object. A damage only hurts the team it targets (see DamageInfo.CanHit).
/// </summary>
Team Team { get; }
void TakeDamage(DamageInfo damage);
}
}

View File

@@ -3,7 +3,8 @@ using UnityEngine;
namespace Arena.Combat
{
/// <summary>
/// Moves straight ahead and damages the first IDamageable it touches.
/// Moves straight ahead and damages the first IDamageable of the targeted team it touches.
/// It goes through the other teams: no friendly fire.
/// Needs a trigger collider and a kinematic Rigidbody.
/// </summary>
[RequireComponent(typeof(Rigidbody))]
@@ -36,12 +37,21 @@ namespace Arena.Combat
private void OnTriggerEnter(Collider other)
{
// Ignore the shooter and the other trigger zones
if (other.isTrigger || other.gameObject == _damage.Source)
// Ignore the other trigger zones, and the shooter with all its children
if (other.isTrigger || IsFromShooter(other))
return;
if (other.TryGetComponent(out IDamageable target))
// The collider can be on a child of the IDamageable: arm, head, model...
IDamageable target = other.GetComponentInParent<IDamageable>();
if (target != null)
{
// A friend is crossed, not hit
if (!_damage.CanHit(target.Team))
return;
target.TakeDamage(_damage);
}
Destroy(gameObject);
}
@@ -61,5 +71,15 @@ namespace Arena.Combat
}
#endregion
#region PRIVATE METHODS
private bool IsFromShooter(Collider other)
{
GameObject shooter = _damage.Source;
return shooter && other.transform.IsChildOf(shooter.transform);
}
#endregion
}
}

View File

@@ -4,8 +4,10 @@ using UnityEngine;
namespace Arena.Combat
{
/// <summary>
/// Fires projectiles from a muzzle, limited by the fire interval and the ammo count.
/// Placed on the root of the shooter, which the projectiles ignore.
/// Weapon that fires projectiles from a muzzle, limited by the fire interval and the ammo count.
/// Held by a Handle (usually a hand): snaps to it when enabled, then follows the orientation of the Owner
/// while the Owner is active, otherwise the orientation of the Handle. Can be dropped with physics.
/// The projectiles ignore the shooter and all its children.
/// </summary>
public class ProjectileLauncher : MonoBehaviour
{
@@ -14,6 +16,9 @@ namespace Arena.Combat
{
[Tooltip("Weapon data: damage, fire interval, ammo, projectile.")]
public WeaponData Weapon;
[Tooltip("Team hurt by the projectiles: Enemy for the player, Allied for an enemy, All for everyone.")]
public Team Target = Team.All;
}
[System.Serializable]
@@ -21,12 +26,26 @@ namespace Arena.Combat
{
[Tooltip("Spawn point of the projectiles. Its forward axis gives the direction.")]
public Transform Muzzle;
[Tooltip("Shooter: the projectiles never hit it nor its children, and the weapon follows its orientation while it is active. Empty: the GameObject of this launcher.")]
public GameObject Owner;
[Tooltip("Transform that holds the weapon, usually a hand. The weapon takes its position and orientation when enabled.")]
public Transform Handle;
[Tooltip("Rigidbody of the weapon, kinematic while held. Drop makes it fall.")]
public Rigidbody Body;
}
[System.Serializable]
public class StateContainer
{
public int Ammo;
[Tooltip("True while an action blocks the shots: running, dash, stun... Set by the owner.")]
public bool Disabled;
public bool IsDropped;
}
#region FIELDS
@@ -41,6 +60,7 @@ namespace Arena.Combat
public event Action<int, int> OnAmmoChange;
public StateContainer State => _state;
public Transform Muzzle => _references.Muzzle;
public int MaxAmmo => _settings.Weapon.MaxAmmo;
private float _nextFireTime;
@@ -54,6 +74,28 @@ namespace Arena.Combat
_state.Ammo = MaxAmmo;
}
void OnEnable()
{
Transform handle = _references.Handle;
if (handle && !_state.IsDropped)
transform.SetPositionAndRotation(handle.position, handle.rotation);
}
void LateUpdate()
{
// LateUpdate: after the animation has moved the hand
if (_state.IsDropped)
return;
GameObject owner = _references.Owner;
if (owner && owner.activeInHierarchy)
transform.rotation = owner.transform.rotation;
else if (_references.Handle)
transform.rotation = _references.Handle.rotation;
}
#endregion
#region PUBLIC METHODS
@@ -63,7 +105,7 @@ namespace Arena.Combat
/// </summary>
public bool TryFire()
{
if (Time.time < _nextFireTime || _state.Ammo <= 0)
if (_state.Disabled || _state.IsDropped || Time.time < _nextFireTime || _state.Ammo <= 0)
return false;
_nextFireTime = Time.time + _settings.Weapon.FireInterval;
@@ -72,10 +114,43 @@ namespace Arena.Combat
Transform muzzle = _references.Muzzle;
Projectile projectile = Instantiate(_settings.Weapon.ProjectilePrefab, muzzle.position, muzzle.rotation);
projectile.Launch(new DamageInfo(_settings.Weapon.Damage, _settings.Weapon.DamageType, gameObject));
GameObject owner = _references.Owner ? _references.Owner : gameObject;
projectile.Launch(new DamageInfo(_settings.Weapon.Damage, _settings.Weapon.DamageType, owner, _settings.Target));
return true;
}
/// <summary>
/// Blocks or allows the shots. Called by the owner when an action prevents shooting.
/// </summary>
public void SetDisabled(bool isDisabled)
{
_state.Disabled = isDisabled;
}
/// <summary>
/// Lets go of the weapon: it leaves its handle and falls with physics. It can no longer fire.
/// </summary>
public void Drop()
{
if (_state.IsDropped)
return;
_state.IsDropped = true;
_state.Disabled = true;
transform.SetParent(null, true);
Rigidbody body = _references.Body;
if (!body)
{
Debug.LogWarning($"{name}: no Rigidbody referenced, the weapon cannot fall.", this);
return;
}
body.isKinematic = false;
body.useGravity = true;
}
/// <summary>
/// Adds ammo without exceeding the weapon maximum.
/// </summary>

View File

@@ -0,0 +1,13 @@
namespace Arena.Combat
{
/// <summary>
/// Side of an IDamageable, and side targeted by a damage.
/// A damage hits an IDamageable when one of the two is All, or when both are the same.
/// </summary>
public enum Team
{
All,
Allied,
Enemy
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 270a21a512abe8c45b7fa4900f5ef45a

View File

@@ -67,7 +67,7 @@ namespace Arena.UI
private void HandleAmmoChange(int current, int max)
{
_references.Label.SetText("{0} / {1}", current, max);
_references.Label.text = string.Format("{0} / {1}", current, max);
}
#endregion

View File

@@ -61,7 +61,7 @@ namespace Arena.UI
private void HandleHealthChange(int current, int max)
{
_references.Label.SetText("{0} / {1}", current, max);
_references.Label.text = string.Format("{0} / {1}", current, max);
}
#endregion