feat: add enemy threat targeting system
This commit is contained in:
@@ -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<GameObject, float> threatTable = new Dictionary<GameObject, float>();
|
||||
private readonly List<GameObject> threatTargetBuffer = new List<GameObject>();
|
||||
|
||||
// 점프 등 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<Colosseum.Skills.SkillController>();
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 공격자 기준 위협 수치를 누적합니다.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 특정 대상의 위협 수치를 강제로 설정합니다.
|
||||
/// </summary>
|
||||
public virtual void SetThreat(GameObject source, float amount)
|
||||
{
|
||||
if (!IsServer || !useThreatSystem)
|
||||
return;
|
||||
|
||||
if (!IsValidThreatTarget(source) || amount <= 0f)
|
||||
{
|
||||
ClearThreat(source);
|
||||
return;
|
||||
}
|
||||
|
||||
threatTable[source] = amount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 특정 대상의 위협 수치를 제거합니다.
|
||||
/// </summary>
|
||||
public virtual void ClearThreat(GameObject source)
|
||||
{
|
||||
if (source == null)
|
||||
return;
|
||||
|
||||
threatTable.Remove(source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 모든 위협 수치를 초기화합니다.
|
||||
/// </summary>
|
||||
public virtual void ClearAllThreat()
|
||||
{
|
||||
threatTable.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 가장 높은 위협 수치를 가진 타겟을 반환합니다.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 특정 대상의 현재 위협 수치를 반환합니다.
|
||||
/// </summary>
|
||||
public float GetThreat(GameObject source)
|
||||
{
|
||||
if (source == null)
|
||||
return 0f;
|
||||
|
||||
return threatTable.TryGetValue(source, out float threat) ? threat : 0f;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 런타임 디버그용 위협 요약 문자열을 반환합니다.
|
||||
/// </summary>
|
||||
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() : "등록된 위협 대상이 없습니다.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 피격 정보를 위협 수치로 변환합니다.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 위협 테이블의 무효 대상을 정리하고 자연 감소를 적용합니다.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 위협 대상이 유효한 선택 후보인지 확인합니다.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 위협 누적 대상이 유효한지 확인합니다.
|
||||
/// </summary>
|
||||
private bool IsValidThreatTarget(GameObject target)
|
||||
{
|
||||
if (target == null || !target.activeInHierarchy)
|
||||
return false;
|
||||
|
||||
if (Colosseum.Team.IsSameTeam(gameObject, target))
|
||||
return false;
|
||||
|
||||
IDamageable damageable = target.GetComponent<IDamageable>();
|
||||
return damageable != null && !damageable.IsDead;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 위협 테이블에서 무효한 대상을 제거합니다.
|
||||
/// </summary>
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 다양한 source 타입에서 실제 GameObject를 추출합니다.
|
||||
/// </summary>
|
||||
private static GameObject ResolveThreatSource(object source)
|
||||
{
|
||||
return source switch
|
||||
{
|
||||
GameObject sourceObject => sourceObject,
|
||||
Component component => component.gameObject,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user