적 AI에 어그로 시스템 추가

This commit is contained in:
2026-02-25 21:00:51 +09:00
parent 6ecf799d18
commit a27a59d176
2 changed files with 315 additions and 4 deletions

View File

@@ -2,6 +2,7 @@ using Unity.Netcode;
using UnityEngine;
using UnityEngine.AI;
using System.Collections;
using System.Collections.Generic;
namespace Northbound
{
@@ -65,6 +66,25 @@ namespace Northbound
[Tooltip("이 크립이 속한 캠프 (null이면 독립 크립)")]
public CreepCamp creepCamp;
[Header("Aggro System")]
[Tooltip("어그로(위협도) 시스템 활성화")]
public bool enableAggroSystem = true;
[Tooltip("데미지당 어그로 증가량")]
public float damageAggroMultiplier = 2f;
[Tooltip("어그로 감쇠율 (초당)")]
public float aggroDecayRate = 5f;
[Tooltip("새 타겟 전환에 필요한 최소 어그로 차이")]
public float aggroSwitchThreshold = 20f;
[Tooltip("타겟 재평가 주기 (초)")]
public float targetReevaluateInterval = 1f;
[Tooltip("거리 기반 타겟 전환 허용 거리차")]
public float distanceSwitchThreshold = 5f;
private NavMeshAgent _agent;
private EnemyUnit _enemyUnit;
private Core _core;
@@ -96,6 +116,11 @@ namespace Northbound
private float _attackStartTime;
private const float ATTACK_TIMEOUT = 1f; // 애니메이션 이벤트 미발생 시 타임아웃
// 어그로 시스템
private Dictionary<ulong, float> _aggroTable = new Dictionary<ulong, float>();
private float _lastTargetReevaluateTime;
private System.Action<ulong, int> _damageTakenHandler;
public override void OnNetworkSpawn()
{
base.OnNetworkSpawn();
@@ -131,6 +156,13 @@ namespace Northbound
if (_enemyUnit != null)
{
_enemyUnit.OnDeath += HandleDeath;
// 데미지 받음 이벤트 구독 (어그로 시스템)
if (enableAggroSystem)
{
_damageTakenHandler = (attackerId, damage) => HandleDamageTaken(attackerId, damage);
_enemyUnit.OnDamageTaken += _damageTakenHandler;
}
}
}
}
@@ -140,6 +172,11 @@ namespace Northbound
if (_enemyUnit != null)
{
_enemyUnit.OnDeath -= HandleDeath;
if (_damageTakenHandler != null)
{
_enemyUnit.OnDamageTaken -= _damageTakenHandler;
}
}
base.OnNetworkDespawn();
}
@@ -149,6 +186,12 @@ namespace Northbound
if (!IsServer) return;
if (!_agent.isOnNavMesh) return;
// 어그로 시스템 업데이트
if (enableAggroSystem)
{
ReevaluateTarget();
}
switch (_currentState.Value)
{
case EnemyAIState.Idle: UpdateIdle(); break;
@@ -425,8 +468,20 @@ namespace Northbound
_cachedTargetPlayer = target;
var networkObject = target.GetComponentInParent<NetworkObject>();
if (networkObject != null) _targetPlayerId.Value = networkObject.NetworkObjectId;
else _targetPlayerId.Value = 0;
if (networkObject != null)
{
_targetPlayerId.Value = networkObject.NetworkObjectId;
// 타겟 설정 시 기본 어그로 부여 (최초 감지)
if (enableAggroSystem && !_aggroTable.ContainsKey(networkObject.NetworkObjectId))
{
_aggroTable[networkObject.NetworkObjectId] = 10f; // 기본 어그로
}
}
else
{
_targetPlayerId.Value = 0;
}
}
private void ClearTargetPlayer()
@@ -587,6 +642,242 @@ namespace Northbound
#endregion
#region Aggro System
private void HandleDamageTaken(ulong attackerId, int damage)
{
if (!IsServer) return;
if (!enableAggroSystem) return;
// 공격자에게 어그로 추가
AddAggro(attackerId, damage * damageAggroMultiplier);
// 즉시 타겟 전환 고려
ConsiderTargetSwitch(attackerId);
}
private void AddAggro(ulong attackerId, float amount)
{
if (_aggroTable.ContainsKey(attackerId))
{
_aggroTable[attackerId] += amount;
}
else
{
_aggroTable[attackerId] = amount;
}
}
private void DecayAggro()
{
List<ulong> keysToRemove = new List<ulong>();
foreach (var kvp in _aggroTable)
{
float newAggro = kvp.Value - aggroDecayRate * Time.deltaTime;
if (newAggro <= 0)
{
keysToRemove.Add(kvp.Key);
}
else
{
_aggroTable[kvp.Key] = newAggro;
}
}
// 어그로가 0 이하인 항목 제거
foreach (var key in keysToRemove)
{
_aggroTable.Remove(key);
}
}
private void ConsiderTargetSwitch(ulong attackerId)
{
// 현재 ChasePlayer 또는 Attack 상태가 아니면 무시
if (_currentState.Value != EnemyAIState.ChasePlayer &&
_currentState.Value != EnemyAIState.Attack)
{
return;
}
// 현재 타겟의 어그로 확인
ulong currentTargetId = _targetPlayerId.Value;
float currentAggro = 0f;
if (currentTargetId != 0 && _aggroTable.TryGetValue(currentTargetId, out float currAgg))
{
currentAggro = currAgg;
}
// 공격자의 어그로 확인
if (!_aggroTable.TryGetValue(attackerId, out float attackerAggro))
{
return;
}
// 어그로 차이가 임계값 이상이면 타겟 전환
if (attackerAggro >= currentAggro + aggroSwitchThreshold)
{
// 공격자를 NetworkObject로 찾기
if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(attackerId, out NetworkObject attackerObj))
{
GameObject attacker = attackerObj.gameObject;
// 유효한 타겟인지 확인 (사망하지 않음, 거리 내에 있음)
IDamageable damageable = attacker.GetComponentInParent<IDamageable>();
if (damageable != null && !damageable.IsDead())
{
float distance = GetDistanceToTarget(attacker);
if (distance <= maxChaseDistance)
{
SetTargetPlayer(attacker);
// 캠프에 알림
if (creepCamp != null)
{
creepCamp.AlertAllCreeps(attacker, this);
}
if (_currentState.Value != EnemyAIState.ChasePlayer)
{
TransitionToState(EnemyAIState.ChasePlayer);
}
}
}
}
}
}
private void ReevaluateTarget()
{
if (!enableAggroSystem) return;
if (Time.time - _lastTargetReevaluateTime < targetReevaluateInterval) return;
_lastTargetReevaluateTime = Time.time;
// 어그로 감쇠
DecayAggro();
// 현재 추적 중이 아니면 감지 로직에 맡김
if (_currentState.Value != EnemyAIState.ChasePlayer &&
_currentState.Value != EnemyAIState.Attack)
{
return;
}
GameObject currentTarget = GetTargetPlayer();
if (currentTarget == null) return;
// 최고 어그로 타겟 찾기
ulong highestAggroId = 0;
float highestAggro = 0f;
foreach (var kvp in _aggroTable)
{
if (kvp.Value > highestAggro)
{
// 유효한 타겟인지 확인
if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(kvp.Key, out NetworkObject netObj))
{
IDamageable damageable = netObj.GetComponentInParent<IDamageable>();
if (damageable != null && !damageable.IsDead())
{
float distance = GetDistanceToTarget(netObj.gameObject);
if (distance <= maxChaseDistance)
{
highestAggroId = kvp.Key;
highestAggro = kvp.Value;
}
}
}
}
}
// 현재 타겟이 최고 어그로가 아니면 전환 고려
ulong currentTargetNetId = 0;
var currentNetObj = currentTarget.GetComponentInParent<NetworkObject>();
if (currentNetObj != null)
{
currentTargetNetId = currentNetObj.NetworkObjectId;
}
if (highestAggroId != 0 && highestAggroId != currentTargetNetId)
{
float currentAggro = 0f;
_aggroTable.TryGetValue(currentTargetNetId, out currentAggro);
if (highestAggro >= currentAggro + aggroSwitchThreshold)
{
if (NetworkManager.Singleton.SpawnManager.SpawnedObjects.TryGetValue(highestAggroId, out NetworkObject newTargetObj))
{
SetTargetPlayer(newTargetObj.gameObject);
}
}
}
// 거리 기반 타겟 전환 (어그로가 비슷하면 더 가까운 타겟으로)
ConsiderDistanceBasedSwitch();
}
private void ConsiderDistanceBasedSwitch()
{
GameObject currentTarget = GetTargetPlayer();
if (currentTarget == null) return;
float currentDistance = GetDistanceToTarget(currentTarget);
// 주변 플레이어 검색
Collider[] colliders = Physics.OverlapSphere(transform.position, detectionRange, playerLayer);
foreach (Collider col in colliders)
{
if (col.transform.root == transform.root) continue;
if (col.gameObject == currentTarget) continue;
IDamageable damageable = col.GetComponentInParent<IDamageable>();
if (damageable == null || damageable.IsDead()) continue;
ITeamMember teamMember = col.GetComponentInParent<ITeamMember>();
if (teamMember != null && teamMember.GetTeam() != TeamType.Player) continue;
float newDistance = GetDistanceToTarget(col.gameObject);
// 현재 타겟보다 충분히 가까우면 전환 고려
if (currentDistance - newDistance >= distanceSwitchThreshold)
{
// 현재 타겟의 어그로 가져오기
ulong currentTargetId = _targetPlayerId.Value;
float currentAggro = 0f;
if (currentTargetId != 0)
{
_aggroTable.TryGetValue(currentTargetId, out currentAggro);
}
// 새 타겟이 어그로가 비슷하거나 높으면 전환
var newNetObj = col.GetComponentInParent<NetworkObject>();
if (newNetObj != null)
{
float newAggro = 0f;
_aggroTable.TryGetValue(newNetObj.NetworkObjectId, out newAggro);
// 거리 우선: 어그로가 크게 차이나지 않으면 더 가까운 타겟으로
if (newAggro >= currentAggro - aggroSwitchThreshold * 0.5f)
{
SetTargetPlayer(col.gameObject);
if (creepCamp != null)
{
creepCamp.AlertAllCreeps(col.gameObject, this);
}
return; // 한 번에 하나만 전환
}
}
}
}
}
#endregion
#region Utilities & Distance
private float GetDistanceToTarget(GameObject target)
@@ -818,7 +1109,19 @@ namespace Northbound
distanceInfo = $"\nDistToCore: {distToCore:F1} (Range: {attackRange})";
}
string fullInfo = $"{gameObject.name}\n{stateInfo}\n{targetInfo}{attackInfo}{distanceInfo}";
// 어그로 정보 표시
string aggroInfo = "";
if (enableAggroSystem && _aggroTable.Count > 0)
{
float currentAggro = 0f;
if (_targetPlayerId.Value != 0 && _aggroTable.TryGetValue(_targetPlayerId.Value, out float agg))
{
currentAggro = agg;
}
aggroInfo = $"\nAggro: {currentAggro:F0} (Targets: {_aggroTable.Count})";
}
string fullInfo = $"{gameObject.name}\n{stateInfo}\n{targetInfo}{attackInfo}{distanceInfo}{aggroInfo}";
UnityEditor.Handles.Label(
transform.position + Vector3.up * 3f,

View File

@@ -41,6 +41,11 @@ namespace Northbound
/// </summary>
public event System.Action<ulong> OnDeath;
/// <summary>
/// 데미지를 받았을 때 발생하는 이벤트 (매개변수: attackerId, damage)
/// </summary>
public event System.Action<ulong, int> OnDamageTaken;
private UnitHealthBar _healthBar;
public override void OnNetworkSpawn()
@@ -116,10 +121,13 @@ namespace Northbound
int actualDamage = Mathf.Min(damage, _currentHealth.Value);
_currentHealth.Value -= actualDamage;
// 데미지 이펙트
ShowDamageEffectClientRpc();
// 데미지 받음 이벤트 발생 (AI 어그로 시스템용)
OnDamageTaken?.Invoke(attackerId, actualDamage);
// 체력이 0이 되면 파괴
if (_currentHealth.Value <= 0)
{