feat: Enemies
This commit is contained in:
@@ -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>
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
2
Assets/_Content/Scripts/Characters/Enemy.cs.meta
Normal file
2
Assets/_Content/Scripts/Characters/Enemy.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 067d71d286c5dc64fb0fc717ef086ef9
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
13
Assets/_Content/Scripts/Combat/Team.cs
Normal file
13
Assets/_Content/Scripts/Combat/Team.cs
Normal 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
|
||||
}
|
||||
}
|
||||
2
Assets/_Content/Scripts/Combat/Team.cs.meta
Normal file
2
Assets/_Content/Scripts/Combat/Team.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 270a21a512abe8c45b7fa4900f5ef45a
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user