Files
Colosseum/Assets/_Game/Scripts/Combat/TrainingDummyTarget.cs
dal4segno 29cb132d5d feat: 허수아비 DPS 벤치마크 씬 추가
- BalanceDummy 씬과 TrainingDummy 프리팹을 추가해 밸런싱용 허수아비 전투 공간을 구성
- TrainingDummyTarget과 DummyDpsBenchmarkRunner를 구현해 일정 시간 자동 시전 기반 DPS 측정을 지원
- 디버그 메뉴, 빌드 설정, 네트워크 프리팹 목록을 연결해 플레이 모드 검증 경로를 정리
2026-03-27 17:18:11 +09:00

259 lines
7.9 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)
{
if (!IsServer || damage <= 0f)
return 0f;
GameObject sourceObject = ResolveSource(source);
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
}
}