using UnityEngine; using Unity.Netcode; namespace Colosseum.Combat { /// /// 밸런싱 테스트용 허수아비 타깃입니다. /// 누적 피해를 기록하고 일정 시간 후 자동으로 체력을 복구합니다. /// [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(); } } /// /// 대미지를 적용합니다. /// public float TakeDamage(float damage, object source = null) { return TakeDamage(new DamageContext(damage, source)); } /// /// 대미지 컨텍스트를 사용해 피해를 적용합니다. /// 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; } /// /// 체력을 회복합니다. /// 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; } /// /// 허수아비 상태를 초기화합니다. /// [ContextMenu("Reset Dummy")] public void ResetDummy() { if (Application.isPlaying && !IsServer) return; if (logSummaryOnReset && accumulatedDamage > 0f) { Debug.Log(BuildSummary()); } ResetState(false); } /// /// 현재 허수아비 전투 요약 문자열을 반환합니다. /// 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}"; } /// /// 외부 벤치마크가 진행되는 동안 자동 리셋을 일시 중지합니다. /// 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 } }