404 lines
15 KiB
C#
404 lines
15 KiB
C#
using Northbound.Core;
|
|
using Northbound.Gameplay.Player;
|
|
using Northbound.Gameplay.Presentation;
|
|
using Northbound.Gameplay.World;
|
|
using UnityEngine;
|
|
|
|
namespace Northbound.Gameplay.Enemy
|
|
{
|
|
public enum EnemyRole
|
|
{
|
|
Grunt,
|
|
Raider,
|
|
Elite,
|
|
PortalGuard
|
|
}
|
|
|
|
public sealed class EnemyAgent : MonoBehaviour, IDamageable
|
|
{
|
|
private static int nextAvoidanceSide;
|
|
|
|
[SerializeField] private EnemyRole role;
|
|
[SerializeField] private float maximumHealth = 75f;
|
|
[SerializeField] private float moveSpeed = 2.7f;
|
|
[SerializeField] private float attackDamage = 12f;
|
|
[SerializeField] private float attackRange = 2f;
|
|
[SerializeField] private float attackInterval = 1.25f;
|
|
[SerializeField] private EnemyPursuitDefinition pursuitDefinition;
|
|
[SerializeField] private bool affectedByResonance = true;
|
|
|
|
private float currentHealth;
|
|
private float readyAt;
|
|
private Vector3 guardPosition;
|
|
private CharacterController movementController;
|
|
private float avoidanceDirection;
|
|
private bool isPursuingPlayer;
|
|
private bool isPlayerAggroSuppressed;
|
|
private float unansweredPursuitTime;
|
|
private Vector3 playerPursuitOrigin;
|
|
private MonoBehaviour strategicTarget;
|
|
private bool isStructureEngagementLocked;
|
|
|
|
public EnemyRole Role => role;
|
|
public Team Team => Team.Enemy;
|
|
public bool IsAlive => currentHealth > 0f;
|
|
public float CurrentHealth => currentHealth;
|
|
public float MaximumHealth => maximumHealth;
|
|
|
|
private void Awake()
|
|
{
|
|
currentHealth = maximumHealth;
|
|
guardPosition = transform.position;
|
|
avoidanceDirection = (nextAvoidanceSide++ & 1) == 0 ? 1f : -1f;
|
|
PrepareMovementController();
|
|
}
|
|
|
|
private void Update()
|
|
{
|
|
if (!IsAlive || SharedGameState.Instance is { IsDefeated: true })
|
|
{
|
|
return;
|
|
}
|
|
|
|
var target = SelectTarget();
|
|
if (target == null)
|
|
{
|
|
ReturnToGuardPosition();
|
|
return;
|
|
}
|
|
|
|
var targetPosition = GetClosestTargetPoint(target);
|
|
var planar = targetPosition - transform.position;
|
|
planar.y = 0f;
|
|
if (planar.sqrMagnitude > attackRange * attackRange)
|
|
{
|
|
MoveTowardsTarget(planar.normalized);
|
|
return;
|
|
}
|
|
|
|
var damageable = ResolveDamageReceiver(target);
|
|
if (Time.time >= readyAt && damageable != null)
|
|
{
|
|
readyAt = Time.time + attackInterval;
|
|
if (IsValidStructureTarget(target))
|
|
{
|
|
// Selecting a march destination does not commit us; the first attack does.
|
|
strategicTarget = target;
|
|
isStructureEngagementLocked = true;
|
|
EndPlayerPursuit(true);
|
|
}
|
|
if (isPursuingPlayer)
|
|
{
|
|
// Only an enemy attack opportunity replenishes pursuit, not incoming damage.
|
|
unansweredPursuitTime = 0f;
|
|
playerPursuitOrigin = transform.position;
|
|
}
|
|
AttackTracer.Spawn(transform.position, targetPosition, new Color(1f, 0.18f, 0.08f, 0.95f), 0.22f, 0.2f);
|
|
damageable.TakeDamage(attackDamage, Team.Enemy);
|
|
}
|
|
}
|
|
|
|
public void Configure(EnemyRole enemyRole, int resonance, Material material, EnemyPursuitDefinition pursuit)
|
|
{
|
|
role = enemyRole;
|
|
pursuitDefinition = pursuit;
|
|
affectedByResonance = enemyRole != EnemyRole.PortalGuard;
|
|
guardPosition = transform.position;
|
|
|
|
var healthMultiplier = affectedByResonance ? 1f + Mathf.Min(3, resonance) * 0.1f : 1f;
|
|
var damageMultiplier = affectedByResonance ? 1f + Mathf.Min(3, resonance) * 0.06f : 1f;
|
|
var roleHealth = enemyRole == EnemyRole.Elite ? 150f : enemyRole == EnemyRole.PortalGuard ? 105f : 75f;
|
|
var roleDamage = enemyRole == EnemyRole.Elite ? 22f : enemyRole == EnemyRole.PortalGuard ? 15f : 12f;
|
|
|
|
maximumHealth = roleHealth * healthMultiplier;
|
|
attackDamage = roleDamage * damageMultiplier;
|
|
moveSpeed = enemyRole == EnemyRole.Elite ? 2.2f : enemyRole == EnemyRole.PortalGuard ? 2.5f : 2.7f;
|
|
currentHealth = maximumHealth;
|
|
if (TryGetComponent<Renderer>(out var renderer))
|
|
{
|
|
renderer.sharedMaterial = material;
|
|
}
|
|
}
|
|
|
|
public void TakeDamage(float amount, Team sourceTeam)
|
|
{
|
|
if (!IsAlive || !TeamRules.CanDamage(sourceTeam, Team))
|
|
{
|
|
return;
|
|
}
|
|
|
|
currentHealth = Mathf.Max(0f, currentHealth - Mathf.Max(0f, amount));
|
|
if (!IsAlive)
|
|
{
|
|
Destroy(gameObject);
|
|
}
|
|
}
|
|
|
|
private void PrepareMovementController()
|
|
{
|
|
foreach (var collider in GetComponents<Collider>())
|
|
{
|
|
if (collider is CharacterController)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
collider.enabled = false;
|
|
Destroy(collider);
|
|
}
|
|
|
|
movementController = GetComponent<CharacterController>();
|
|
if (movementController == null)
|
|
{
|
|
movementController = gameObject.AddComponent<CharacterController>();
|
|
}
|
|
|
|
movementController.center = Vector3.zero;
|
|
movementController.height = 2f;
|
|
movementController.radius = 0.45f;
|
|
movementController.stepOffset = 0.3f;
|
|
movementController.skinWidth = 0.08f;
|
|
movementController.minMoveDistance = 0f;
|
|
movementController.detectCollisions = true;
|
|
movementController.enableOverlapRecovery = true;
|
|
}
|
|
|
|
private Vector3 GetClosestTargetPoint(MonoBehaviour target)
|
|
{
|
|
var targetCollider = target.GetComponent<Collider>();
|
|
if (targetCollider == null)
|
|
{
|
|
targetCollider = target.GetComponentInChildren<Collider>();
|
|
}
|
|
|
|
var targetPoint = targetCollider == null
|
|
? target.transform.position
|
|
: targetCollider.ClosestPoint(transform.position);
|
|
targetPoint.y = transform.position.y;
|
|
return targetPoint;
|
|
}
|
|
|
|
private void MoveTowardsTarget(Vector3 direction)
|
|
{
|
|
if (movementController == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var step = moveSpeed * Time.deltaTime;
|
|
var collision = movementController.Move(direction * step);
|
|
if ((collision & CollisionFlags.Sides) != 0)
|
|
{
|
|
var sideways = Vector3.Cross(Vector3.up, direction) * avoidanceDirection;
|
|
movementController.Move(sideways * (step * 0.85f));
|
|
}
|
|
|
|
transform.rotation = Quaternion.LookRotation(direction);
|
|
}
|
|
|
|
private MonoBehaviour SelectTarget()
|
|
{
|
|
if (isStructureEngagementLocked)
|
|
{
|
|
if (IsValidStructureTarget(strategicTarget))
|
|
{
|
|
// Neither nearby players nor newly built structures interrupt this engagement.
|
|
return strategicTarget;
|
|
}
|
|
|
|
isStructureEngagementLocked = false;
|
|
strategicTarget = null;
|
|
// A destroyed/lost structure opens a new engagement, including player detection.
|
|
EndPlayerPursuit(false);
|
|
}
|
|
|
|
if (role != EnemyRole.PortalGuard)
|
|
{
|
|
var northernTarget = SelectNorthernStructureTarget();
|
|
if (!IsValidStructureTarget(strategicTarget)
|
|
|| northernTarget != null && northernTarget.transform.position.z > strategicTarget.transform.position.z + 0.01f)
|
|
{
|
|
strategicTarget = northernTarget;
|
|
}
|
|
}
|
|
|
|
var player = FindAnyObjectByType<PlayerController>();
|
|
var playerHealth = player == null ? null : player.GetComponent<Combat.Health>();
|
|
var aggroOrigin = role == EnemyRole.PortalGuard ? guardPosition : transform.position;
|
|
if (TrySelectPlayer(player, playerHealth, aggroOrigin, out var selectedPlayer))
|
|
{
|
|
return selectedPlayer;
|
|
}
|
|
|
|
if (role == EnemyRole.PortalGuard)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return strategicTarget;
|
|
}
|
|
|
|
private MonoBehaviour SelectNorthernStructureTarget()
|
|
{
|
|
MonoBehaviour selected = null;
|
|
var selectedZ = float.NegativeInfinity;
|
|
var selectedDistance = float.MaxValue;
|
|
var backtrackDistance = pursuitDefinition == null
|
|
? 0f
|
|
: pursuitDefinition.MaximumTargetBacktrackDistance;
|
|
var northernLimit = transform.position.z + backtrackDistance;
|
|
|
|
foreach (var behaviour in FindObjectsByType<MonoBehaviour>())
|
|
{
|
|
if (!behaviour.isActiveAndEnabled
|
|
|| behaviour is not IEnemyStructureTarget structure
|
|
|| !structure.CanBeTargetedByEnemies)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var receiver = structure.DamageReceiver;
|
|
if (receiver == null || !receiver.IsAlive || receiver.Team != Team.Player)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var candidateZ = behaviour.transform.position.z;
|
|
if (candidateZ > northernLimit)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var distance = PlanarSqrDistance(behaviour.transform.position, transform.position);
|
|
var sameFrontline = Mathf.Abs(candidateZ - selectedZ) <= 0.01f;
|
|
var raiderPrefersOutpost = role == EnemyRole.Raider
|
|
&& sameFrontline
|
|
&& behaviour is CrystalOutpost
|
|
&& selected is not CrystalOutpost;
|
|
var raiderKeepsOutpost = role == EnemyRole.Raider
|
|
&& sameFrontline
|
|
&& selected is CrystalOutpost
|
|
&& behaviour is not CrystalOutpost;
|
|
if (candidateZ > selectedZ + 0.01f
|
|
|| raiderPrefersOutpost
|
|
|| sameFrontline && !raiderKeepsOutpost && distance < selectedDistance)
|
|
{
|
|
selected = behaviour;
|
|
selectedZ = candidateZ;
|
|
selectedDistance = distance;
|
|
}
|
|
}
|
|
|
|
return selected;
|
|
}
|
|
|
|
private static bool IsValidStructureTarget(MonoBehaviour target)
|
|
{
|
|
if (target == null || !target.isActiveAndEnabled
|
|
|| target is not IEnemyStructureTarget structure || !structure.CanBeTargetedByEnemies)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
var receiver = structure.DamageReceiver;
|
|
return receiver != null && receiver.IsAlive && receiver.Team == Team.Player;
|
|
}
|
|
|
|
private static IDamageable ResolveDamageReceiver(MonoBehaviour target)
|
|
{
|
|
return target is IEnemyStructureTarget structure
|
|
? structure.DamageReceiver
|
|
: target as IDamageable;
|
|
}
|
|
|
|
private bool TrySelectPlayer(
|
|
PlayerController player,
|
|
Combat.Health playerHealth,
|
|
Vector3 aggroOrigin,
|
|
out MonoBehaviour selectedPlayer)
|
|
{
|
|
selectedPlayer = null;
|
|
if (pursuitDefinition == null || player == null || playerHealth == null || !playerHealth.IsAlive)
|
|
{
|
|
EndPlayerPursuit(false);
|
|
return false;
|
|
}
|
|
|
|
var distanceFromAggroOrigin = PlanarSqrDistance(player.transform.position, aggroOrigin);
|
|
if (isPursuingPlayer)
|
|
{
|
|
// Use the same collider-surface range check as the attack itself.
|
|
// Cooldown time in reach preserves engagement but does not refill it.
|
|
var canReachPlayer = PlanarSqrDistance(GetClosestTargetPoint(playerHealth), transform.position)
|
|
<= attackRange * attackRange;
|
|
if (!canReachPlayer)
|
|
{
|
|
unansweredPursuitTime += Time.deltaTime;
|
|
var exceededDuration = unansweredPursuitTime >= pursuitDefinition.DisengageDelay;
|
|
var exceededDistance = PlanarSqrDistance(transform.position, playerPursuitOrigin)
|
|
>= pursuitDefinition.MaximumUnansweredPursuitDistance * pursuitDefinition.MaximumUnansweredPursuitDistance;
|
|
if (exceededDuration || exceededDistance)
|
|
{
|
|
EndPlayerPursuit(true);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
selectedPlayer = playerHealth;
|
|
return true;
|
|
}
|
|
|
|
if (isPlayerAggroSuppressed)
|
|
{
|
|
if (distanceFromAggroOrigin <= pursuitDefinition.RearmDistance * pursuitDefinition.RearmDistance)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
isPlayerAggroSuppressed = false;
|
|
}
|
|
|
|
if (PlanarSqrDistance(player.transform.position, aggroOrigin)
|
|
> pursuitDefinition.AggroRange * pursuitDefinition.AggroRange)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
isPursuingPlayer = true;
|
|
playerPursuitOrigin = transform.position;
|
|
unansweredPursuitTime = 0f;
|
|
selectedPlayer = playerHealth;
|
|
return true;
|
|
}
|
|
|
|
private void EndPlayerPursuit(bool suppressAggro)
|
|
{
|
|
isPursuingPlayer = false;
|
|
isPlayerAggroSuppressed = suppressAggro;
|
|
unansweredPursuitTime = 0f;
|
|
}
|
|
|
|
private void ReturnToGuardPosition()
|
|
{
|
|
if (role != EnemyRole.PortalGuard)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var planar = guardPosition - transform.position;
|
|
planar.y = 0f;
|
|
if (planar.sqrMagnitude > 0.1f)
|
|
{
|
|
MoveTowardsTarget(planar.normalized);
|
|
}
|
|
}
|
|
|
|
private static float PlanarSqrDistance(Vector3 a, Vector3 b)
|
|
{
|
|
a.y = 0f;
|
|
b.y = 0f;
|
|
return Vector3.SqrMagnitude(a - b);
|
|
}
|
|
}
|
|
}
|