feat: 허수아비 DPS 벤치마크 씬 추가

- BalanceDummy 씬과 TrainingDummy 프리팹을 추가해 밸런싱용 허수아비 전투 공간을 구성
- TrainingDummyTarget과 DummyDpsBenchmarkRunner를 구현해 일정 시간 자동 시전 기반 DPS 측정을 지원
- 디버그 메뉴, 빌드 설정, 네트워크 프리팹 목록을 연결해 플레이 모드 검증 경로를 정리
This commit is contained in:
2026-03-27 17:18:11 +09:00
parent d78e0edabd
commit 29cb132d5d
12 changed files with 14565 additions and 0 deletions

View File

@@ -34,3 +34,8 @@ MonoBehaviour:
SourcePrefabToOverride: {fileID: 0}
SourceHashToOverride: 0
OverridingTargetPrefab: {fileID: 0}
- Override: 0
Prefab: {fileID: 3054181739271998841, guid: b2b53d2d232562a47995ca503090670e, type: 3}
SourcePrefabToOverride: {fileID: 0}
SourceHashToOverride: 0
OverridingTargetPrefab: {fileID: 0}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 90d10d723d37d174c94429aff781504d
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 769f8f2e72bd0d1419c1692789e7a251
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,225 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &3054181739271998841
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 3603188469848606439}
- component: {fileID: 2004420943758656828}
- component: {fileID: 5182453241883187424}
- component: {fileID: 1285472408817410619}
- component: {fileID: 4754826070226246169}
- component: {fileID: 4771767181016103934}
- component: {fileID: 4198820995877550832}
- component: {fileID: 703472655594990954}
- component: {fileID: 2489901850308028945}
m_Layer: 6
m_Name: Prefab_TrainingDummy
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &3603188469848606439
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3054181739271998841}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 1, z: 0, w: -0.00000004371139}
m_LocalPosition: {x: 0, y: 1, z: 10}
m_LocalScale: {x: 1.5, y: 2.5, z: 1.5}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!33 &2004420943758656828
MeshFilter:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3054181739271998841}
m_Mesh: {fileID: 10208, guid: 0000000000000000e000000000000000, type: 0}
--- !u!136 &5182453241883187424
CapsuleCollider:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3054181739271998841}
m_Material: {fileID: 0}
m_IncludeLayers:
serializedVersion: 2
m_Bits: 0
m_ExcludeLayers:
serializedVersion: 2
m_Bits: 0
m_LayerOverridePriority: 0
m_IsTrigger: 0
m_ProvidesContacts: 0
m_Enabled: 1
serializedVersion: 2
m_Radius: 0.5
m_Height: 2
m_Direction: 1
m_Center: {x: 0, y: 0, z: 0}
--- !u!23 &1285472408817410619
MeshRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3054181739271998841}
m_Enabled: 1
m_CastShadows: 1
m_ReceiveShadows: 1
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 1
m_LightProbeUsage: 1
m_ReflectionProbeUsage: 1
m_RayTracingMode: 2
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_ForceMeshLod: -1
m_MeshLodSelectionBias: 0
m_RenderingLayerMask: 1
m_RendererPriority: 0
m_Materials:
- {fileID: 2100000, guid: 31321ba15b8f8eb4c954353edc038b1d, type: 2}
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 1
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 0
m_SortingLayer: 0
m_SortingOrder: 0
m_MaskInteraction: 0
m_AdditionalVertexStreams: {fileID: 0}
--- !u!114 &4754826070226246169
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3054181739271998841}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d5a57f767e5e46a458fc5d3c628d0cbb, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.Netcode.Runtime::Unity.Netcode.NetworkObject
GlobalObjectIdHash: 3664463253
InScenePlacedSourceGlobalObjectIdHash: 0
DeferredDespawnTick: 0
Ownership: 1
AlwaysReplicateAsRoot: 0
SynchronizeTransform: 1
ActiveSceneSynchronization: 0
SceneMigrationSynchronization: 1
SpawnWithObservers: 1
DontDestroyWithOwner: 0
AutoObjectParentSync: 1
SyncOwnerTransformWhenParented: 1
AllowOwnerToParent: 0
--- !u!114 &4771767181016103934
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3054181739271998841}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d1f7d13276f272b428bddd4d9aa5b3d8, type: 3}
m_Name:
m_EditorClassIdentifier: Colosseum.Game::Colosseum.Team
teamType: 2
--- !u!114 &4198820995877550832
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3054181739271998841}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 234ffc560cedd8b4293c262e735a86b8, type: 3}
m_Name:
m_EditorClassIdentifier: Colosseum.Game::Colosseum.Combat.TrainingDummyTarget
ShowTopMostFoldoutHeaderGroup: 1
maxHealth: 50000
autoResetDelay: 3
resetImmediatelyOnDeath: 1
faceAttacker: 1
logSummaryOnReset: 1
currentHealth: 50000
accumulatedDamage: 0
peakHitDamage: 0
lastHitDamage: 0
averageDps: 0
lastAttackerName:
inCombat: 0
isDead: 0
--- !u!114 &703472655594990954
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3054181739271998841}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fae0149926eea244dad932b67ee76f7b, type: 3}
m_Name:
m_EditorClassIdentifier: Colosseum.Game::Colosseum.Stats.CharacterStats
strength:
baseValue: 10
dexterity:
baseValue: 10
intelligence:
baseValue: 10
vitality:
baseValue: 10
wisdom:
baseValue: 10
spirit:
baseValue: 10
--- !u!114 &2489901850308028945
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3054181739271998841}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 7a766b6ab825c1445a3385079bb32cc5, type: 3}
m_Name:
m_EditorClassIdentifier: Colosseum.Game::Colosseum.Abnormalities.AbnormalityManager
ShowTopMostFoldoutHeaderGroup: 1
characterStats: {fileID: 0}
networkController: {fileID: 0}
skillController: {fileID: 0}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: b2b53d2d232562a47995ca503090670e
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,398 @@
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(']');
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: bdd1385e2099b5c4b86889e3a2b5ac1b

View 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
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 234ffc560cedd8b4293c262e735a86b8

View File

@@ -251,6 +251,44 @@ namespace Colosseum.Editor
Debug.Log(summary);
}
[MenuItem("Tools/Colosseum/Debug/Start Dummy DPS Benchmark")]
private static void StartDummyDpsBenchmark()
{
if (!EditorApplication.isPlaying)
{
Debug.LogWarning("[Debug] 플레이 모드에서만 사용할 수 있습니다.");
return;
}
DummyDpsBenchmarkRunner benchmarkRunner = Object.FindFirstObjectByType<DummyDpsBenchmarkRunner>();
if (benchmarkRunner == null)
{
Debug.LogWarning("[Debug] DummyDpsBenchmarkRunner를 찾지 못했습니다.");
return;
}
benchmarkRunner.StartBenchmark();
}
[MenuItem("Tools/Colosseum/Debug/Log Last Dummy DPS Benchmark")]
private static void LogLastDummyDpsBenchmark()
{
DummyDpsBenchmarkRunner benchmarkRunner = Object.FindFirstObjectByType<DummyDpsBenchmarkRunner>();
if (benchmarkRunner == null)
{
Debug.LogWarning("[Debug] DummyDpsBenchmarkRunner를 찾지 못했습니다.");
return;
}
if (string.IsNullOrWhiteSpace(benchmarkRunner.LastSummary))
{
Debug.LogWarning("[Debug] 아직 완료된 허수아비 DPS 측정 결과가 없습니다.");
return;
}
Debug.Log(benchmarkRunner.LastSummary);
}
[MenuItem("Tools/Colosseum/Debug/Apply Local Stun")]
private static void ApplyLocalStun()
{

View File

@@ -14,6 +14,9 @@ EditorBuildSettings:
- enabled: 1
path: Assets/Scenes/Test.unity
guid: f727fa008df302a4f839260c2d345287
- enabled: 1
path: Assets/Scenes/BalanceDummy.unity
guid: 90d10d723d37d174c94429aff781504d
m_configObjects:
com.unity.dt.app-ui: {fileID: 11400000, guid: 99f9c9493070a9d4c979a8fec7c5a8d3, type: 2}
com.unity.input.settings.actions: {fileID: -944628639613478452, guid: 052faaac586de48259a63d0c4782560b, type: 3}