feat: add enemy threat targeting system

This commit is contained in:
2026-03-19 20:43:57 +09:00
parent 287ff4dc83
commit a65ba77931
4 changed files with 355 additions and 0 deletions

View File

@@ -4,6 +4,7 @@ using UnityEngine;
using Action = Unity.Behavior.Action; using Action = Unity.Behavior.Action;
using Unity.Properties; using Unity.Properties;
using Colosseum.Combat; using Colosseum.Combat;
using Colosseum.Enemy;
[Serializable, GeneratePropertyBag] [Serializable, GeneratePropertyBag]
[NodeDescription(name: "FindTarget", story: "[타겟] ", category: "Action", id: "bb947540549026f3c5625c6d19213311")] [NodeDescription(name: "FindTarget", story: "[타겟] ", category: "Action", id: "bb947540549026f3c5625c6d19213311")]
@@ -30,6 +31,17 @@ public partial class FindTargetAction : Action
return Status.Failure; 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) foreach (GameObject candidate in candidates)
{ {

View File

@@ -4,6 +4,7 @@ using UnityEngine;
using Action = Unity.Behavior.Action; using Action = Unity.Behavior.Action;
using Unity.Properties; using Unity.Properties;
using Colosseum.Combat; using Colosseum.Combat;
using Colosseum.Enemy;
[Serializable, GeneratePropertyBag] [Serializable, GeneratePropertyBag]
[NodeDescription(name: "SetTargetInRange", story: "[Range] [Tag] ", category: "Action", id: "93b7a5d823a58618d5371c01ef894948")] [NodeDescription(name: "SetTargetInRange", story: "[Range] [Tag] ", category: "Action", id: "93b7a5d823a58618d5371c01ef894948")]
@@ -33,6 +34,17 @@ public partial class SetTargetInRangeAction : Action
return Status.Failure; 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; GameObject nearestTarget = null;
float nearestDistance = Range.Value; // Range 내에서만 검색 float nearestDistance = Range.Value; // Range 내에서만 검색

View File

@@ -14,6 +14,7 @@ namespace Colosseum.Editor
{ {
private BossEnemy boss; private BossEnemy boss;
private bool showPhaseDetails = true; private bool showPhaseDetails = true;
private bool showThreatInfo = true;
private bool showDebugTools = true; private bool showDebugTools = true;
private int selectedPhaseIndex = 0; private int selectedPhaseIndex = 0;
@@ -45,6 +46,11 @@ namespace Colosseum.Editor
EditorGUILayout.Space(10); EditorGUILayout.Space(10);
// 위협 정보
DrawThreatInfo();
EditorGUILayout.Space(10);
// 디버그 도구 // 디버그 도구
DrawDebugTools(); DrawDebugTools();
} }
@@ -136,6 +142,36 @@ namespace Colosseum.Editor
EditorGUI.indentLevel--; 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>
/// 디버그 도구 표시 /// 디버그 도구 표시
/// </summary> /// </summary>

View File

@@ -1,6 +1,10 @@
using System; using System;
using System.Collections.Generic;
using System.Text;
using UnityEngine; using UnityEngine;
using Unity.Netcode; using Unity.Netcode;
using Colosseum.Stats; using Colosseum.Stats;
using Colosseum.Combat; using Colosseum.Combat;
@@ -23,6 +27,15 @@ namespace Colosseum.Enemy
[Header("Data")] [Header("Data")]
[SerializeField] protected EnemyData enemyData; [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로 식별) // 플레이어 분리용 (레이어 의존 없이 CharacterController로 식별)
private readonly Collider[] overlapBuffer = new Collider[8]; 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 비활성화 상태 추적 // 점프 등 Y 루트모션 스킬 중 NavMeshAgent 비활성화 상태 추적
private bool isAirborne = false; private bool isAirborne = false;
@@ -55,6 +70,7 @@ namespace Colosseum.Enemy
public CharacterStats Stats => characterStats; public CharacterStats Stats => characterStats;
public EnemyData Data => enemyData; public EnemyData Data => enemyData;
public Animator Animator => animator; public Animator Animator => animator;
public bool UseThreatSystem => useThreatSystem;
public override void OnNetworkSpawn() public override void OnNetworkSpawn()
{ {
@@ -80,6 +96,7 @@ namespace Colosseum.Enemy
{ {
if (!IsServer || IsDead) return; if (!IsServer || IsDead) return;
UpdateThreatState(Time.deltaTime);
OnServerUpdate(); OnServerUpdate();
} }
@@ -250,6 +267,7 @@ namespace Colosseum.Enemy
float actualDamage = Mathf.Min(damage, currentHealth.Value); float actualDamage = Mathf.Min(damage, currentHealth.Value);
currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage); currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage);
RegisterThreatFromDamage(actualDamage, source);
OnDamageTaken?.Invoke(actualDamage); OnDamageTaken?.Invoke(actualDamage);
// 대미지 피드백 (애니메이션, 이펙트 등) // 대미지 피드백 (애니메이션, 이펙트 등)
@@ -323,6 +341,7 @@ namespace Colosseum.Enemy
protected virtual void HandleDeath() protected virtual void HandleDeath()
{ {
isDead.Value = true; isDead.Value = true;
ClearAllThreat();
// 실행 중인 스킬 즉시 취소 // 실행 중인 스킬 즉시 취소
var skillController = GetComponent<Colosseum.Skills.SkillController>(); var skillController = GetComponent<Colosseum.Skills.SkillController>();
@@ -352,6 +371,7 @@ namespace Colosseum.Enemy
if (!IsServer) return; if (!IsServer) return;
isDead.Value = false; isDead.Value = false;
ClearAllThreat();
InitializeStats(); InitializeStats();
if (navMeshAgent != null) if (navMeshAgent != null)
@@ -370,5 +390,280 @@ namespace Colosseum.Enemy
{ {
OnHealthChanged?.Invoke(newValue, MaxHealth); 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,
};
}
} }
} }