- 애니메이션 이벤트 기반 방어/유지/해제 흐름과 HUD 피드백, 방어 디버그 로그를 추가했다. - 드로그 기본기1 테스트 패턴을 정리하고 공격 판정을 OnEffect 기반으로 옮기며 드로그 범위 효과의 타겟 레이어를 정상화했다. - 플레이어 퀵슬롯 테스트 세팅과 적-플레이어 겹침 방지 로직을 조정해 충돌 시 적이 수평 이동을 멈추고 최소 분리만 수행하게 했다.
268 lines
8.2 KiB
C#
268 lines
8.2 KiB
C#
using UnityEngine;
|
|
|
|
using Unity.Netcode;
|
|
|
|
namespace Colosseum.Combat
|
|
{
|
|
/// <summary>
|
|
/// 밸런싱 테스트용 허수아비 타깃입니다.
|
|
/// 누적 피해를 기록하고 일정 시간 후 자동으로 체력을 복구합니다.
|
|
/// </summary>
|
|
[DisallowMultipleComponent]
|
|
public class TrainingDummyTarget : NetworkBehaviour, IDamageable
|
|
{
|
|
[Header("Settings")]
|
|
[Tooltip("허수아비 최대 체력")]
|
|
[Min(1f)] [SerializeField] private float maxHealth = 50000f;
|
|
|
|
[Tooltip("마지막 피격 이후 자동 리셋까지 대기 시간")]
|
|
[Min(0f)] [SerializeField] private float autoResetDelay = 3f;
|
|
|
|
[Tooltip("체력이 0이 되면 즉시 최대 체력으로 복구할지 여부")]
|
|
[SerializeField] private bool resetImmediatelyOnDeath = true;
|
|
|
|
[Tooltip("피격 시 공격자 방향을 바라볼지 여부")]
|
|
[SerializeField] private bool faceAttacker = true;
|
|
|
|
[Tooltip("리셋 시 누적 피해 요약 로그를 출력할지 여부")]
|
|
[SerializeField] private bool logSummaryOnReset = true;
|
|
|
|
[Header("Debug")]
|
|
[Tooltip("현재 체력")]
|
|
[SerializeField] private float currentHealth;
|
|
|
|
[Tooltip("최근 전투 구간 누적 피해")]
|
|
[SerializeField] private float accumulatedDamage;
|
|
|
|
[Tooltip("최근 전투 구간 최고 단일 피해")]
|
|
[SerializeField] private float peakHitDamage;
|
|
|
|
[Tooltip("마지막 단일 피해량")]
|
|
[SerializeField] private float lastHitDamage;
|
|
|
|
[Tooltip("최근 전투 구간 평균 DPS")]
|
|
[SerializeField] private float averageDps;
|
|
|
|
[Tooltip("마지막 공격자 이름")]
|
|
[SerializeField] private string lastAttackerName = string.Empty;
|
|
|
|
[Tooltip("전투 중 여부")]
|
|
[SerializeField] private bool inCombat;
|
|
|
|
[Tooltip("사망 여부")]
|
|
[SerializeField] private bool isDead;
|
|
|
|
private float combatStartTime = -1f;
|
|
private float lastHitTime = -1f;
|
|
private bool autoResetSuppressed;
|
|
|
|
public float CurrentHealth => currentHealth;
|
|
public float MaxHealth => maxHealth;
|
|
public bool IsDead => isDead;
|
|
public float AccumulatedDamage => accumulatedDamage;
|
|
public float PeakHitDamage => peakHitDamage;
|
|
public float LastHitDamage => lastHitDamage;
|
|
public float AverageDps => averageDps;
|
|
public float CombatDuration => inCombat ? Mathf.Max(0f, Time.time - combatStartTime) : 0f;
|
|
|
|
private void Awake()
|
|
{
|
|
ResetState(true);
|
|
}
|
|
|
|
public override void OnNetworkSpawn()
|
|
{
|
|
if (IsServer)
|
|
{
|
|
ResetState(true);
|
|
}
|
|
}
|
|
|
|
private void Update()
|
|
{
|
|
if (!IsServer || !Application.isPlaying)
|
|
return;
|
|
|
|
if (!inCombat)
|
|
return;
|
|
|
|
float combatDuration = Mathf.Max(0.01f, Time.time - combatStartTime);
|
|
averageDps = accumulatedDamage / combatDuration;
|
|
|
|
if (autoResetSuppressed || autoResetDelay <= 0f)
|
|
return;
|
|
|
|
if (Time.time - lastHitTime >= autoResetDelay)
|
|
{
|
|
ResetDummy();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 대미지를 적용합니다.
|
|
/// </summary>
|
|
public float TakeDamage(float damage, object source = null)
|
|
{
|
|
return TakeDamage(new DamageContext(damage, source));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 대미지 컨텍스트를 사용해 피해를 적용합니다.
|
|
/// </summary>
|
|
public float TakeDamage(DamageContext damageContext)
|
|
{
|
|
float damage = damageContext.Amount;
|
|
if (!IsServer || damage <= 0f)
|
|
return 0f;
|
|
|
|
GameObject sourceObject = damageContext.SourceGameObject;
|
|
float actualDamage = Mathf.Min(damage, currentHealth);
|
|
currentHealth = Mathf.Max(0f, currentHealth - actualDamage);
|
|
|
|
BeginOrRefreshCombat(sourceObject, actualDamage);
|
|
CombatBalanceTracker.RecordDamage(sourceObject, gameObject, actualDamage);
|
|
|
|
if (faceAttacker && sourceObject != null)
|
|
{
|
|
FaceTowards(sourceObject.transform.position);
|
|
}
|
|
|
|
if (currentHealth <= 0f)
|
|
{
|
|
isDead = true;
|
|
|
|
if (resetImmediatelyOnDeath && !autoResetSuppressed)
|
|
{
|
|
ResetDummy();
|
|
}
|
|
}
|
|
|
|
return actualDamage;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 체력을 회복합니다.
|
|
/// </summary>
|
|
public float Heal(float amount)
|
|
{
|
|
if (!IsServer || amount <= 0f)
|
|
return 0f;
|
|
|
|
float missingHealth = Mathf.Max(0f, maxHealth - currentHealth);
|
|
float actualHeal = Mathf.Min(amount, missingHealth);
|
|
currentHealth += actualHeal;
|
|
|
|
if (currentHealth > 0f)
|
|
{
|
|
isDead = false;
|
|
}
|
|
|
|
return actualHeal;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 허수아비 상태를 초기화합니다.
|
|
/// </summary>
|
|
[ContextMenu("Reset Dummy")]
|
|
public void ResetDummy()
|
|
{
|
|
if (Application.isPlaying && !IsServer)
|
|
return;
|
|
|
|
if (logSummaryOnReset && accumulatedDamage > 0f)
|
|
{
|
|
Debug.Log(BuildSummary());
|
|
}
|
|
|
|
ResetState(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 현재 허수아비 전투 요약 문자열을 반환합니다.
|
|
/// </summary>
|
|
public string BuildSummary()
|
|
{
|
|
float combatDuration = inCombat ? Mathf.Max(0.01f, Time.time - combatStartTime) : 0f;
|
|
|
|
return $"[TrainingDummy] {name} | Damage={accumulatedDamage:0.##} | Peak={peakHitDamage:0.##} | DPS={averageDps:0.##} | Duration={combatDuration:0.##}s | LastAttacker={lastAttackerName}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// 외부 벤치마크가 진행되는 동안 자동 리셋을 일시 중지합니다.
|
|
/// </summary>
|
|
public void SetAutoResetSuppressed(bool suppressed)
|
|
{
|
|
autoResetSuppressed = suppressed;
|
|
}
|
|
|
|
private void BeginOrRefreshCombat(GameObject sourceObject, float actualDamage)
|
|
{
|
|
if (!inCombat)
|
|
{
|
|
inCombat = true;
|
|
combatStartTime = Time.time;
|
|
accumulatedDamage = 0f;
|
|
peakHitDamage = 0f;
|
|
averageDps = 0f;
|
|
}
|
|
|
|
accumulatedDamage += actualDamage;
|
|
peakHitDamage = Mathf.Max(peakHitDamage, actualDamage);
|
|
lastHitDamage = actualDamage;
|
|
lastHitTime = Time.time;
|
|
lastAttackerName = sourceObject != null ? sourceObject.name : "Unknown";
|
|
}
|
|
|
|
private void ResetState(bool preserveEditorValues)
|
|
{
|
|
currentHealth = Mathf.Max(1f, maxHealth);
|
|
accumulatedDamage = 0f;
|
|
peakHitDamage = 0f;
|
|
lastHitDamage = 0f;
|
|
averageDps = 0f;
|
|
inCombat = false;
|
|
isDead = false;
|
|
combatStartTime = -1f;
|
|
lastHitTime = -1f;
|
|
|
|
if (!preserveEditorValues)
|
|
{
|
|
lastAttackerName = string.Empty;
|
|
}
|
|
}
|
|
|
|
private void FaceTowards(Vector3 worldPosition)
|
|
{
|
|
Vector3 lookDirection = worldPosition - transform.position;
|
|
lookDirection.y = 0f;
|
|
|
|
if (lookDirection.sqrMagnitude <= 0.0001f)
|
|
return;
|
|
|
|
transform.rotation = Quaternion.LookRotation(lookDirection.normalized, Vector3.up);
|
|
}
|
|
|
|
private static GameObject ResolveSource(object source)
|
|
{
|
|
return source switch
|
|
{
|
|
GameObject gameObject => gameObject,
|
|
Component component => component.gameObject,
|
|
_ => null,
|
|
};
|
|
}
|
|
|
|
#if UNITY_EDITOR
|
|
private void OnValidate()
|
|
{
|
|
maxHealth = Mathf.Max(1f, maxHealth);
|
|
|
|
if (!Application.isPlaying)
|
|
{
|
|
currentHealth = Mathf.Clamp(currentHealth <= 0f ? maxHealth : currentHealth, 0f, maxHealth);
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
}
|