From a27a59d176061b720063e1cae3704d29817f07f1 Mon Sep 17 00:00:00 2001 From: dal4segno Date: Wed, 25 Feb 2026 21:00:51 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=81=20AI=EC=97=90=20=EC=96=B4=EA=B7=B8?= =?UTF-8?q?=EB=A1=9C=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/Scripts/EnemyAIController.cs | 309 +++++++++++++++++++++++++++- Assets/Scripts/EnemyUnit.cs | 10 +- 2 files changed, 315 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/EnemyAIController.cs b/Assets/Scripts/EnemyAIController.cs index f5a5b62..5ef8b68 100644 --- a/Assets/Scripts/EnemyAIController.cs +++ b/Assets/Scripts/EnemyAIController.cs @@ -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 _aggroTable = new Dictionary(); + private float _lastTargetReevaluateTime; + private System.Action _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(); - 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 keysToRemove = new List(); + + 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(); + 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(); + 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(); + 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(); + if (damageable == null || damageable.IsDead()) continue; + + ITeamMember teamMember = col.GetComponentInParent(); + 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(); + 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, diff --git a/Assets/Scripts/EnemyUnit.cs b/Assets/Scripts/EnemyUnit.cs index 3b42bf4..9e20b62 100644 --- a/Assets/Scripts/EnemyUnit.cs +++ b/Assets/Scripts/EnemyUnit.cs @@ -41,6 +41,11 @@ namespace Northbound /// public event System.Action OnDeath; + /// + /// 데미지를 받았을 때 발생하는 이벤트 (매개변수: attackerId, damage) + /// + public event System.Action 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) {