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

399 lines
14 KiB
C#

using System.Collections;
using System.Text;
using UnityEngine;
using Colosseum.Passives;
using Colosseum.Player;
using Colosseum.Skills;
namespace Colosseum.Combat
{
/// <summary>
/// 허수아비 대상 DPS 벤치마크를 자동으로 실행합니다.
/// 지정한 빌드를 적용한 뒤 일정 시간 슬롯 순환 시전을 반복하고 결과를 로그로 남깁니다.
/// </summary>
[DisallowMultipleComponent]
public class DummyDpsBenchmarkRunner : MonoBehaviour
{
[Header("Targets")]
[Tooltip("측정 대상 허수아비 (비어 있으면 자동 검색)")]
[SerializeField] private TrainingDummyTarget targetDummy;
[Tooltip("측정할 플레이어 OwnerClientId")]
[Min(0)] [SerializeField] private int targetOwnerClientId = 0;
[Header("Build Override")]
[Tooltip("벤치마크 시작 시 적용할 로드아웃 프리셋 (비어 있으면 현재 빌드 유지)")]
[SerializeField] private PlayerLoadoutPreset loadoutPreset;
[Tooltip("벤치마크 시작 시 적용할 패시브 프리셋 (비어 있으면 현재 패시브 유지)")]
[SerializeField] private PassivePresetData passivePreset;
[Header("Benchmark")]
[Tooltip("측정 시간")]
[Min(1f)] [SerializeField] private float benchmarkDuration = 10f;
[Tooltip("허수아비와 유지할 기준 거리")]
[Min(0.5f)] [SerializeField] private float benchmarkDistance = 2.2f;
[Tooltip("시작 전에 플레이어를 기준 위치로 정렬할지 여부")]
[SerializeField] private bool snapPlayerToBenchmarkLane = true;
[Tooltip("측정 중 가능한 한 허수아비를 바라보도록 유지할지 여부")]
[SerializeField] private bool keepFacingDummy = true;
[Tooltip("측정 중 수동 입력을 잠시 막을지 여부")]
[SerializeField] private bool disableManualInputDuringBenchmark = true;
[Tooltip("측정 슬롯 순서 (0 기반 인덱스)")]
[SerializeField] private int[] rotationSlots = new[] { 0, 1, 2, 3, 4, 5 };
[Header("Session")]
[Tooltip("완료 후 로그를 자동 출력할지 여부")]
[SerializeField] private bool logSummaryOnComplete = true;
[Tooltip("현재 측정 진행 중 여부")]
[SerializeField] private bool isRunning;
[Tooltip("마지막 측정 요약")]
[SerializeField] private string lastSummary = string.Empty;
[Tooltip("마지막 측정 총 피해량")]
[SerializeField] private float lastTotalDamage;
[Tooltip("마지막 측정 DPS")]
[SerializeField] private float lastDps;
[Tooltip("마지막 빌드 라벨")]
[SerializeField] private string lastBuildLabel = string.Empty;
private Coroutine benchmarkRoutine;
public bool IsRunning => isRunning;
public string LastSummary => lastSummary;
public float LastTotalDamage => lastTotalDamage;
public float LastDps => lastDps;
public string LastBuildLabel => lastBuildLabel;
/// <summary>
/// 현재 설정으로 허수아비 벤치마크를 시작합니다.
/// </summary>
[ContextMenu("Start Benchmark")]
public void StartBenchmark()
{
if (!Application.isPlaying)
{
Debug.LogWarning("[DummyBenchmark] 플레이 모드에서만 측정할 수 있습니다.");
return;
}
if (isRunning)
{
Debug.LogWarning("[DummyBenchmark] 이미 측정이 진행 중입니다.");
return;
}
benchmarkRoutine = StartCoroutine(RunBenchmark());
}
/// <summary>
/// 진행 중인 허수아비 벤치마크를 중지합니다.
/// </summary>
[ContextMenu("Stop Benchmark")]
public void StopBenchmark()
{
if (benchmarkRoutine != null)
{
StopCoroutine(benchmarkRoutine);
benchmarkRoutine = null;
}
isRunning = false;
RestoreTargetDummyState();
}
private IEnumerator RunBenchmark()
{
PlayerSkillInput skillInput = FindTargetSkillInput();
PlayerNetworkController networkController = skillInput != null ? skillInput.GetComponent<PlayerNetworkController>() : null;
SkillController skillController = skillInput != null ? skillInput.GetComponent<SkillController>() : null;
targetDummy = ResolveDummy();
if (skillInput == null || networkController == null || skillController == null || targetDummy == null)
{
Debug.LogWarning("[DummyBenchmark] 플레이어/허수아비 참조를 찾지 못해 측정을 시작할 수 없습니다.");
yield break;
}
if (!networkController.IsServer)
{
Debug.LogWarning("[DummyBenchmark] 호스트/서버 권한에서만 자동 측정을 실행할 수 있습니다.");
yield break;
}
if (rotationSlots == null || rotationSlots.Length <= 0)
{
Debug.LogWarning("[DummyBenchmark] rotationSlots가 비어 있습니다.");
yield break;
}
isRunning = true;
lastSummary = string.Empty;
lastTotalDamage = 0f;
lastDps = 0f;
targetDummy.SetAutoResetSuppressed(true);
targetDummy.ResetDummy();
CombatBalanceTracker.Reset();
if (loadoutPreset != null)
{
skillInput.ApplyLoadoutPreset(loadoutPreset);
}
if (passivePreset != null)
{
networkController.DebugApplyPassivePreset(passivePreset, true);
}
else
{
networkController.Respawn();
}
if (disableManualInputDuringBenchmark)
{
skillInput.SetGameplayInputEnabled(false);
}
skillController.CancelSkill(SkillCancelReason.Manual);
if (snapPlayerToBenchmarkLane)
{
SnapPlayerToBenchmark(skillInput.transform);
}
yield return null;
float benchmarkStartTime = Time.time;
int rotationIndex = 0;
while (Time.time - benchmarkStartTime < benchmarkDuration)
{
if (!skillController.IsExecutingSkill && snapPlayerToBenchmarkLane)
{
SnapPlayerToBenchmark(skillInput.transform);
}
if (keepFacingDummy)
{
FaceDummy(skillInput.transform);
}
TryExecuteRotationStep(skillInput, ref rotationIndex);
yield return null;
}
lastTotalDamage = targetDummy.AccumulatedDamage;
lastDps = lastTotalDamage / Mathf.Max(0.01f, benchmarkDuration);
lastBuildLabel = BuildBuildLabel(skillInput, networkController);
lastSummary = BuildBenchmarkSummary(networkController, targetDummy);
if (logSummaryOnComplete)
{
Debug.Log(lastSummary);
}
if (disableManualInputDuringBenchmark)
{
skillInput.SetGameplayInputEnabled(true);
}
targetDummy.SetAutoResetSuppressed(false);
benchmarkRoutine = null;
isRunning = false;
}
private bool TryExecuteRotationStep(PlayerSkillInput skillInput, ref int rotationIndex)
{
if (skillInput == null || rotationSlots == null || rotationSlots.Length <= 0)
return false;
int slotIndex = rotationSlots[Mathf.Clamp(rotationIndex, 0, rotationSlots.Length - 1)];
bool executed = skillInput.DebugExecuteSkillAsServer(slotIndex);
if (executed)
{
rotationIndex = (rotationIndex + 1) % rotationSlots.Length;
}
return executed;
}
private string BuildBenchmarkSummary(PlayerNetworkController networkController, TrainingDummyTarget dummy)
{
StringBuilder builder = new StringBuilder();
builder.Append("[DummyBenchmark] ");
builder.Append(lastBuildLabel);
builder.Append(" | TotalDamage=");
builder.Append(lastTotalDamage.ToString("0.##"));
builder.Append(" | DPS=");
builder.Append(lastDps.ToString("0.##"));
builder.Append(" | Duration=");
builder.Append(benchmarkDuration.ToString("0.##"));
builder.Append("s");
if (networkController != null)
{
builder.Append(" | Passive=");
builder.Append(string.IsNullOrWhiteSpace(networkController.CurrentPassivePresetName)
? "미적용"
: networkController.CurrentPassivePresetName);
}
builder.AppendLine();
builder.Append(dummy != null ? dummy.BuildSummary() : "[TrainingDummy] 없음");
builder.AppendLine();
builder.Append(CombatBalanceTracker.BuildSummary());
return builder.ToString();
}
private string BuildBuildLabel(PlayerSkillInput skillInput, PlayerNetworkController networkController)
{
StringBuilder builder = new StringBuilder();
if (loadoutPreset != null && !string.IsNullOrWhiteSpace(loadoutPreset.PresetName))
{
builder.Append(loadoutPreset.PresetName);
}
else
{
builder.Append("현재 빌드");
}
if (passivePreset != null && !string.IsNullOrWhiteSpace(passivePreset.PresetName))
{
builder.Append(" + ");
builder.Append(passivePreset.PresetName);
}
else if (networkController != null && !string.IsNullOrWhiteSpace(networkController.CurrentPassivePresetName))
{
builder.Append(" + ");
builder.Append(networkController.CurrentPassivePresetName);
}
if (skillInput == null)
return builder.ToString();
builder.Append(" | Rotation=");
bool hasSlot = false;
for (int i = 0; i < rotationSlots.Length; i++)
{
int slotIndex = rotationSlots[i];
SkillLoadoutEntry loadoutEntry = skillInput.GetSkillLoadout(slotIndex);
SkillData skill = loadoutEntry != null ? loadoutEntry.BaseSkill : null;
if (skill == null)
continue;
if (hasSlot)
{
builder.Append(" -> ");
}
builder.Append(slotIndex + 1);
builder.Append(':');
builder.Append(skill.SkillName);
AppendGemSummary(builder, loadoutEntry);
hasSlot = true;
}
return builder.ToString();
}
private void SnapPlayerToBenchmark(Transform playerTransform)
{
if (playerTransform == null || targetDummy == null)
return;
Vector3 dummyForward = targetDummy.transform.forward;
dummyForward.y = 0f;
if (dummyForward.sqrMagnitude <= 0.0001f)
{
dummyForward = Vector3.back;
}
else
{
dummyForward.Normalize();
}
Vector3 targetPosition = targetDummy.transform.position + dummyForward * benchmarkDistance;
playerTransform.position = new Vector3(targetPosition.x, playerTransform.position.y, targetPosition.z);
FaceDummy(playerTransform);
}
private void FaceDummy(Transform playerTransform)
{
if (playerTransform == null || targetDummy == null)
return;
Vector3 lookDirection = targetDummy.transform.position - playerTransform.position;
lookDirection.y = 0f;
if (lookDirection.sqrMagnitude <= 0.0001f)
return;
playerTransform.rotation = Quaternion.LookRotation(lookDirection.normalized, Vector3.up);
}
private PlayerSkillInput FindTargetSkillInput()
{
PlayerSkillInput[] players = FindObjectsByType<PlayerSkillInput>(FindObjectsInactive.Exclude, FindObjectsSortMode.None);
for (int i = 0; i < players.Length; i++)
{
PlayerSkillInput skillInput = players[i];
if (skillInput != null && skillInput.OwnerClientId == (ulong)Mathf.Max(0, targetOwnerClientId))
return skillInput;
}
return null;
}
private TrainingDummyTarget ResolveDummy()
{
if (targetDummy != null)
return targetDummy;
return FindFirstObjectByType<TrainingDummyTarget>();
}
private void RestoreTargetDummyState()
{
if (targetDummy != null)
{
targetDummy.SetAutoResetSuppressed(false);
}
}
private static void AppendGemSummary(StringBuilder builder, SkillLoadoutEntry loadoutEntry)
{
if (builder == null || loadoutEntry == null || loadoutEntry.SocketedGems == null)
return;
bool hasGem = false;
for (int i = 0; i < loadoutEntry.SocketedGems.Count; i++)
{
SkillGemData gem = loadoutEntry.SocketedGems[i];
if (gem == null)
continue;
builder.Append(hasGem ? ", " : "[");
builder.Append(gem.GemName);
hasGem = true;
}
if (hasGem)
{
builder.Append(']');
}
}
}
}