- BalanceDummy 씬과 TrainingDummy 프리팹을 추가해 밸런싱용 허수아비 전투 공간을 구성 - TrainingDummyTarget과 DummyDpsBenchmarkRunner를 구현해 일정 시간 자동 시전 기반 DPS 측정을 지원 - 디버그 메뉴, 빌드 설정, 네트워크 프리팹 목록을 연결해 플레이 모드 검증 경로를 정리
259 lines
7.9 KiB
C#
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
|
|
}
|
|
}
|