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