diff --git a/Assets/_Game/Scripts/AI/BehaviorActions/Actions/FindTargetAction.cs b/Assets/_Game/Scripts/AI/BehaviorActions/Actions/FindTargetAction.cs index de614bed..401661a4 100644 --- a/Assets/_Game/Scripts/AI/BehaviorActions/Actions/FindTargetAction.cs +++ b/Assets/_Game/Scripts/AI/BehaviorActions/Actions/FindTargetAction.cs @@ -4,6 +4,7 @@ using UnityEngine; using Action = Unity.Behavior.Action; using Unity.Properties; using Colosseum.Combat; +using Colosseum.Enemy; [Serializable, GeneratePropertyBag] [NodeDescription(name: "FindTarget", story: "[타겟] 탐색", category: "Action", id: "bb947540549026f3c5625c6d19213311")] @@ -30,6 +31,17 @@ public partial class FindTargetAction : Action return Status.Failure; } + EnemyBase enemy = GameObject.GetComponent(); + if (enemy != null && enemy.UseThreatSystem) + { + GameObject threatTarget = enemy.GetHighestThreatTarget(Target?.Value, Tag.Value); + if (threatTarget != null) + { + Target.Value = threatTarget; + return Status.Success; + } + } + // 사망하지 않은 타겟 찾기 foreach (GameObject candidate in candidates) { diff --git a/Assets/_Game/Scripts/AI/BehaviorActions/Actions/SetTargetInRangeAction.cs b/Assets/_Game/Scripts/AI/BehaviorActions/Actions/SetTargetInRangeAction.cs index 18aa4d19..88dea309 100644 --- a/Assets/_Game/Scripts/AI/BehaviorActions/Actions/SetTargetInRangeAction.cs +++ b/Assets/_Game/Scripts/AI/BehaviorActions/Actions/SetTargetInRangeAction.cs @@ -4,6 +4,7 @@ using UnityEngine; using Action = Unity.Behavior.Action; using Unity.Properties; using Colosseum.Combat; +using Colosseum.Enemy; [Serializable, GeneratePropertyBag] [NodeDescription(name: "SetTargetInRange", story: "[Range] 내에 [Tag] 있는지 확인", category: "Action", id: "93b7a5d823a58618d5371c01ef894948")] @@ -33,6 +34,17 @@ public partial class SetTargetInRangeAction : Action return Status.Failure; } + EnemyBase enemy = GameObject.GetComponent(); + if (enemy != null && enemy.UseThreatSystem) + { + GameObject threatTarget = enemy.GetHighestThreatTarget(Target?.Value, Tag.Value, Range.Value); + if (threatTarget != null) + { + Target.Value = threatTarget; + return Status.Success; + } + } + // 가장 가까운 살아있는 타겟 찾기 GameObject nearestTarget = null; float nearestDistance = Range.Value; // Range 내에서만 검색 diff --git a/Assets/_Game/Scripts/Editor/BossEnemyEditor.cs b/Assets/_Game/Scripts/Editor/BossEnemyEditor.cs index 42b8e458..c1ccfe4d 100644 --- a/Assets/_Game/Scripts/Editor/BossEnemyEditor.cs +++ b/Assets/_Game/Scripts/Editor/BossEnemyEditor.cs @@ -14,6 +14,7 @@ namespace Colosseum.Editor { private BossEnemy boss; private bool showPhaseDetails = true; + private bool showThreatInfo = true; private bool showDebugTools = true; private int selectedPhaseIndex = 0; @@ -45,6 +46,11 @@ namespace Colosseum.Editor EditorGUILayout.Space(10); + // 위협 정보 + DrawThreatInfo(); + + EditorGUILayout.Space(10); + // 디버그 도구 DrawDebugTools(); } @@ -136,6 +142,36 @@ namespace Colosseum.Editor EditorGUI.indentLevel--; } + /// + /// 위협 정보 표시 + /// + private void DrawThreatInfo() + { + showThreatInfo = EditorGUILayout.Foldout(showThreatInfo, "위협 정보", true); + + if (!showThreatInfo) + return; + + EditorGUI.indentLevel++; + + if (!boss.UseThreatSystem) + { + EditorGUILayout.HelpBox("위협 시스템이 비활성화되어 있습니다.", MessageType.Info); + EditorGUI.indentLevel--; + return; + } + + EditorGUILayout.LabelField("위협 테이블", EditorStyles.boldLabel); + EditorGUILayout.TextArea(boss.GetThreatDebugSummary(), GUILayout.MinHeight(70f)); + + if (GUILayout.Button("위협 초기화")) + { + boss.ClearAllThreat(); + } + + EditorGUI.indentLevel--; + } + /// /// 디버그 도구 표시 /// diff --git a/Assets/_Game/Scripts/Enemy/EnemyBase.cs b/Assets/_Game/Scripts/Enemy/EnemyBase.cs index 387503f3..fc8ad0e4 100644 --- a/Assets/_Game/Scripts/Enemy/EnemyBase.cs +++ b/Assets/_Game/Scripts/Enemy/EnemyBase.cs @@ -1,6 +1,10 @@ using System; +using System.Collections.Generic; +using System.Text; + using UnityEngine; using Unity.Netcode; + using Colosseum.Stats; using Colosseum.Combat; @@ -23,6 +27,15 @@ namespace Colosseum.Enemy [Header("Data")] [SerializeField] protected EnemyData enemyData; + [Header("Threat Settings")] + [Tooltip("피격 시 공격자 기준 위협 수치를 누적할지 여부")] + [SerializeField] private bool useThreatSystem = true; + [Tooltip("실제 적용된 피해량에 곱해지는 위협 배율")] + [Min(0f)] [SerializeField] private float damageThreatMultiplier = 1f; + [Tooltip("초당 감소하는 위협 수치")] + [Min(0f)] [SerializeField] private float threatDecayPerSecond = 0f; + [Tooltip("현재 타겟보다 이 값 이상 높을 때만 새 타겟으로 전환합니다.")] + [Min(0f)] [SerializeField] private float retargetThreshold = 0f; // 네트워크 동기화 변수 @@ -32,6 +45,8 @@ namespace Colosseum.Enemy // 플레이어 분리용 (레이어 의존 없이 CharacterController로 식별) private readonly Collider[] overlapBuffer = new Collider[8]; + private readonly Dictionary threatTable = new Dictionary(); + private readonly List threatTargetBuffer = new List(); // 점프 등 Y 루트모션 스킬 중 NavMeshAgent 비활성화 상태 추적 private bool isAirborne = false; @@ -55,6 +70,7 @@ namespace Colosseum.Enemy public CharacterStats Stats => characterStats; public EnemyData Data => enemyData; public Animator Animator => animator; + public bool UseThreatSystem => useThreatSystem; public override void OnNetworkSpawn() { @@ -80,6 +96,7 @@ namespace Colosseum.Enemy { if (!IsServer || IsDead) return; + UpdateThreatState(Time.deltaTime); OnServerUpdate(); } @@ -250,6 +267,7 @@ namespace Colosseum.Enemy float actualDamage = Mathf.Min(damage, currentHealth.Value); currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage); + RegisterThreatFromDamage(actualDamage, source); OnDamageTaken?.Invoke(actualDamage); // 대미지 피드백 (애니메이션, 이펙트 등) @@ -323,6 +341,7 @@ namespace Colosseum.Enemy protected virtual void HandleDeath() { isDead.Value = true; + ClearAllThreat(); // 실행 중인 스킬 즉시 취소 var skillController = GetComponent(); @@ -352,6 +371,7 @@ namespace Colosseum.Enemy if (!IsServer) return; isDead.Value = false; + ClearAllThreat(); InitializeStats(); if (navMeshAgent != null) @@ -370,5 +390,280 @@ namespace Colosseum.Enemy { OnHealthChanged?.Invoke(newValue, MaxHealth); } + + /// + /// 공격자 기준 위협 수치를 누적합니다. + /// + public virtual void AddThreat(GameObject source, float amount) + { + if (!IsServer || !useThreatSystem || amount <= 0f) + return; + + if (!IsValidThreatTarget(source)) + return; + + threatTable.TryGetValue(source, out float currentThreat); + threatTable[source] = currentThreat + amount; + } + + /// + /// 특정 대상의 위협 수치를 강제로 설정합니다. + /// + public virtual void SetThreat(GameObject source, float amount) + { + if (!IsServer || !useThreatSystem) + return; + + if (!IsValidThreatTarget(source) || amount <= 0f) + { + ClearThreat(source); + return; + } + + threatTable[source] = amount; + } + + /// + /// 특정 대상의 위협 수치를 제거합니다. + /// + public virtual void ClearThreat(GameObject source) + { + if (source == null) + return; + + threatTable.Remove(source); + } + + /// + /// 모든 위협 수치를 초기화합니다. + /// + public virtual void ClearAllThreat() + { + threatTable.Clear(); + } + + /// + /// 가장 높은 위협 수치를 가진 타겟을 반환합니다. + /// + public virtual GameObject GetHighestThreatTarget(GameObject currentTarget = null, string requiredTag = null, float maxDistance = Mathf.Infinity) + { + if (!useThreatSystem) + return null; + + CleanupThreatTable(); + + GameObject highestThreatTarget = null; + float highestThreat = float.MinValue; + float currentThreat = -1f; + + if (currentTarget != null + && threatTable.TryGetValue(currentTarget, out float cachedCurrentThreat) + && IsSelectableThreatTarget(currentTarget, requiredTag, maxDistance)) + { + currentThreat = cachedCurrentThreat; + } + + foreach (var pair in threatTable) + { + if (!IsSelectableThreatTarget(pair.Key, requiredTag, maxDistance)) + continue; + + if (highestThreatTarget == null || pair.Value > highestThreat) + { + highestThreatTarget = pair.Key; + highestThreat = pair.Value; + } + } + + if (highestThreatTarget == null) + return null; + + if (currentThreat >= 0f + && currentTarget != null + && currentTarget != highestThreatTarget + && highestThreat < currentThreat + retargetThreshold) + { + return currentTarget; + } + + return highestThreatTarget; + } + + /// + /// 특정 대상의 현재 위협 수치를 반환합니다. + /// + public float GetThreat(GameObject source) + { + if (source == null) + return 0f; + + return threatTable.TryGetValue(source, out float threat) ? threat : 0f; + } + + /// + /// 런타임 디버그용 위협 요약 문자열을 반환합니다. + /// + public string GetThreatDebugSummary() + { + if (!useThreatSystem) + return "위협 시스템이 비활성화되어 있습니다."; + + CleanupThreatTable(); + + if (threatTable.Count == 0) + return "등록된 위협 대상이 없습니다."; + + threatTargetBuffer.Clear(); + foreach (var pair in threatTable) + { + threatTargetBuffer.Add(pair.Key); + } + + threatTargetBuffer.Sort((a, b) => GetThreat(b).CompareTo(GetThreat(a))); + + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < threatTargetBuffer.Count; i++) + { + GameObject target = threatTargetBuffer[i]; + if (target == null) + continue; + + if (builder.Length > 0) + { + builder.AppendLine(); + } + + builder.Append(i + 1); + builder.Append(". "); + builder.Append(target.name); + builder.Append(" : "); + builder.Append(GetThreat(target).ToString("F1")); + } + + return builder.Length > 0 ? builder.ToString() : "등록된 위협 대상이 없습니다."; + } + + /// + /// 피격 정보를 위협 수치로 변환합니다. + /// + protected virtual void RegisterThreatFromDamage(float damage, object source) + { + if (!useThreatSystem || damage <= 0f) + return; + + GameObject sourceObject = ResolveThreatSource(source); + if (sourceObject == null) + return; + + AddThreat(sourceObject, damage * damageThreatMultiplier); + } + + /// + /// 위협 테이블의 무효 대상을 정리하고 자연 감소를 적용합니다. + /// + private void UpdateThreatState(float deltaTime) + { + if (!useThreatSystem || threatTable.Count == 0) + return; + + CleanupThreatTable(); + + if (threatDecayPerSecond <= 0f || threatTable.Count == 0) + return; + + threatTargetBuffer.Clear(); + foreach (var pair in threatTable) + { + threatTargetBuffer.Add(pair.Key); + } + + for (int i = 0; i < threatTargetBuffer.Count; i++) + { + GameObject target = threatTargetBuffer[i]; + if (target == null || !threatTable.TryGetValue(target, out float currentThreat)) + continue; + + float nextThreat = Mathf.Max(0f, currentThreat - (threatDecayPerSecond * deltaTime)); + if (nextThreat <= 0f) + { + threatTable.Remove(target); + continue; + } + + threatTable[target] = nextThreat; + } + } + + /// + /// 위협 대상이 유효한 선택 후보인지 확인합니다. + /// + private bool IsSelectableThreatTarget(GameObject target, string requiredTag, float maxDistance) + { + if (!IsValidThreatTarget(target)) + return false; + + if (!string.IsNullOrEmpty(requiredTag) && !target.CompareTag(requiredTag)) + return false; + + if (!float.IsInfinity(maxDistance)) + { + float distance = Vector3.Distance(transform.position, target.transform.position); + if (distance > maxDistance) + return false; + } + + return true; + } + + /// + /// 위협 누적 대상이 유효한지 확인합니다. + /// + private bool IsValidThreatTarget(GameObject target) + { + if (target == null || !target.activeInHierarchy) + return false; + + if (Colosseum.Team.IsSameTeam(gameObject, target)) + return false; + + IDamageable damageable = target.GetComponent(); + return damageable != null && !damageable.IsDead; + } + + /// + /// 위협 테이블에서 무효한 대상을 제거합니다. + /// + private void CleanupThreatTable() + { + if (threatTable.Count == 0) + return; + + threatTargetBuffer.Clear(); + foreach (var pair in threatTable) + { + if (!IsValidThreatTarget(pair.Key)) + { + threatTargetBuffer.Add(pair.Key); + } + } + + for (int i = 0; i < threatTargetBuffer.Count; i++) + { + threatTable.Remove(threatTargetBuffer[i]); + } + } + + /// + /// 다양한 source 타입에서 실제 GameObject를 추출합니다. + /// + private static GameObject ResolveThreatSource(object source) + { + return source switch + { + GameObject sourceObject => sourceObject, + Component component => component.gameObject, + _ => null, + }; + } } }