Files
Northbound/Assets/Northbound/Gameplay/Enemy/EnemyAgent.cs
T

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);
}
}
}