feat: 허수아비 DPS 벤치마크 씬 추가
- BalanceDummy 씬과 TrainingDummy 프리팹을 추가해 밸런싱용 허수아비 전투 공간을 구성 - TrainingDummyTarget과 DummyDpsBenchmarkRunner를 구현해 일정 시간 자동 시전 기반 DPS 측정을 지원 - 디버그 메뉴, 빌드 설정, 네트워크 프리팹 목록을 연결해 플레이 모드 검증 경로를 정리
This commit is contained in:
258
Assets/_Game/Scripts/Combat/TrainingDummyTarget.cs
Normal file
258
Assets/_Game/Scripts/Combat/TrainingDummyTarget.cs
Normal file
@@ -0,0 +1,258 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user