feat: add enemy threat targeting system
This commit is contained in:
@@ -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<EnemyBase>();
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -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<EnemyBase>();
|
||||
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 내에서만 검색
|
||||
|
||||
@@ -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--;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 위협 정보 표시
|
||||
/// </summary>
|
||||
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--;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 디버그 도구 표시
|
||||
/// </summary>
|
||||
|
||||
@@ -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