feat: 방어 시스템과 드로그 검증 경로 정리

- 애니메이션 이벤트 기반 방어/유지/해제 흐름과 HUD 피드백, 방어 디버그 로그를 추가했다.
- 드로그 기본기1 테스트 패턴을 정리하고 공격 판정을 OnEffect 기반으로 옮기며 드로그 범위 효과의 타겟 레이어를 정상화했다.
- 플레이어 퀵슬롯 테스트 세팅과 적-플레이어 겹침 방지 로직을 조정해 충돌 시 적이 수평 이동을 멈추고 최소 분리만 수행하게 했다.
This commit is contained in:
2026-04-07 21:28:52 +09:00
parent 147e9baa25
commit 0c9967d131
72 changed files with 231096 additions and 698 deletions

View File

@@ -25,6 +25,9 @@ namespace Colosseum.Player
[Tooltip("피격 제어 관리자")]
[SerializeField] private HitReactionController hitReactionController;
[Tooltip("방어 상태 관리자")]
[SerializeField] private PlayerDefenseController defenseController;
[Tooltip("관전 관리자")]
[SerializeField] private PlayerSpectator spectator;
@@ -145,7 +148,9 @@ namespace Colosseum.Player
if (!CanReceiveInput || IsStunned || IsStaggered || IsKnockbackActive || IsDowned)
return 0f;
return abnormalityManager != null ? abnormalityManager.MoveSpeedMultiplier : 1f;
float abnormalityMultiplier = abnormalityManager != null ? abnormalityManager.MoveSpeedMultiplier : 1f;
float defenseMultiplier = defenseController != null ? defenseController.MoveSpeedMultiplier : 1f;
return abnormalityMultiplier * defenseMultiplier;
}
}
@@ -159,6 +164,8 @@ namespace Colosseum.Player
skillController = GetComponent<SkillController>();
if (hitReactionController == null)
hitReactionController = GetOrCreateHitReactionController();
if (defenseController == null)
defenseController = GetOrCreateDefenseController();
if (spectator == null)
spectator = GetComponentInChildren<PlayerSpectator>();
}
@@ -195,5 +202,14 @@ namespace Colosseum.Player
return gameObject.AddComponent<HitReactionController>();
}
private PlayerDefenseController GetOrCreateDefenseController()
{
PlayerDefenseController foundController = GetComponent<PlayerDefenseController>();
if (foundController != null)
return foundController;
return gameObject.AddComponent<PlayerDefenseController>();
}
}
}

View File

@@ -0,0 +1,225 @@
using System;
using UnityEngine;
using Colosseum.Combat;
using Colosseum.Skills;
namespace Colosseum.Player
{
/// <summary>
/// 플레이어의 방어 판정 상태를 관리합니다.
/// 마나 유지나 이동 감속 없이 순수하게 방어 가능 여부와 피해 감쇠만 처리합니다.
/// </summary>
[DisallowMultipleComponent]
public class PlayerDefenseController : MonoBehaviour
{
[Header("References")]
[Tooltip("완벽한 방어 보상과 이동 감속을 처리하는 유지 컨트롤러")]
[SerializeField] private PlayerDefenseSustainController sustainController;
[Header("Defense Settings")]
[Tooltip("정면 판정을 사용할지 여부")]
[SerializeField] private bool useFrontGuardArc = true;
[Tooltip("방어가 유효한 정면 반각입니다.")]
[Range(1f, 89f)] [SerializeField] private float guardHalfAngle = 75f;
[Tooltip("일반 방어 성공 시 남는 피해 배율입니다.")]
[Range(0f, 1f)] [SerializeField] private float guardDamageMultiplier = 0.65f;
[Tooltip("방어 시작 후 완벽한 방어로 인정하는 시간 창입니다.")]
[Min(0f)] [SerializeField] private float perfectGuardWindow = 0.5f;
[Tooltip("방어 판정 상세 로그 출력 여부")]
[SerializeField] private bool enableDefenseDebugLogs = true;
[Header("Debug")]
[Tooltip("현재 방어 판정 활성 여부")]
[SerializeField] private bool isDefenseStateActive;
[Tooltip("현재 방어 판정 유지 시간")]
[Min(0f)] [SerializeField] private float defenseStateElapsed;
[Tooltip("마지막 방어 판정으로 막은 피해량")]
[Min(0f)] [SerializeField] private float lastPreventedDamage;
[Tooltip("마지막 방어 판정이 완벽한 방어였는지 여부")]
[SerializeField] private bool lastWasPerfectGuard;
private bool perfectGuardAvailable;
/// <summary>
/// 방어 시작 시 완벽한 방어 유효 시간을 전달합니다.
/// </summary>
public event Action<float> OnDefenseStateEntered;
/// <summary>
/// 방어 성공 시 일반/완벽 여부와 막은 피해량을 전달합니다.
/// </summary>
public event Action<bool, float> OnDefenseResolved;
/// <summary>
/// 현재 방어 판정 활성 여부입니다.
/// </summary>
public bool IsDefenseStateActive => isDefenseStateActive;
/// <summary>
/// 방어 유지 시스템이 제공하는 이동 속도 배율입니다.
/// </summary>
public float MoveSpeedMultiplier
{
get
{
EnsureReferences();
return sustainController != null ? sustainController.MoveSpeedMultiplier : 1f;
}
}
private void Awake()
{
EnsureReferences();
}
private void OnDisable()
{
ClearDefenseState();
}
private void Update()
{
if (!isDefenseStateActive)
return;
defenseStateElapsed += Time.deltaTime;
}
/// <summary>
/// 애니메이션 이벤트로 방어 판정을 시작합니다.
/// </summary>
public void EnterDefenseState()
{
EnsureReferences();
isDefenseStateActive = true;
defenseStateElapsed = 0f;
lastPreventedDamage = 0f;
lastWasPerfectGuard = false;
perfectGuardAvailable = true;
OnDefenseStateEntered?.Invoke(perfectGuardWindow);
if (enableDefenseDebugLogs)
{
Debug.Log($"[Defense] 상태 시작 | owner={gameObject.name} | perfectWindow={perfectGuardWindow:F2}s");
}
}
/// <summary>
/// 애니메이션 이벤트로 방어 판정을 종료합니다.
/// </summary>
public void ExitDefenseState()
{
ClearDefenseState();
}
/// <summary>
/// 스킬 종료/취소 시 방어 판정을 정리합니다.
/// </summary>
public void HandleSkillExecutionEnded()
{
ClearDefenseState();
}
/// <summary>
/// 현재 방어 판정이 유효하다면 피해를 감쇠합니다.
/// </summary>
public float ResolveIncomingDamage(DamageContext damageContext)
{
EnsureReferences();
if (!isDefenseStateActive || !damageContext.CanBeGuarded)
return damageContext.Amount;
if (useFrontGuardArc && !IsWithinGuardArc(damageContext.SourceGameObject))
return damageContext.Amount;
bool isPerfectGuard = perfectGuardAvailable && defenseStateElapsed <= perfectGuardWindow;
perfectGuardAvailable = false;
lastWasPerfectGuard = isPerfectGuard;
if (isPerfectGuard)
{
sustainController?.RefundStartManaOnPerfectGuard();
lastPreventedDamage = damageContext.Amount;
LogDefenseResolution(damageContext, true, 0f);
OnDefenseResolved?.Invoke(true, lastPreventedDamage);
return 0f;
}
float resolvedDamage = damageContext.Amount * guardDamageMultiplier;
lastPreventedDamage = Mathf.Max(0f, damageContext.Amount - resolvedDamage);
LogDefenseResolution(damageContext, false, resolvedDamage);
OnDefenseResolved?.Invoke(false, lastPreventedDamage);
return resolvedDamage;
}
private void EnsureReferences()
{
if (sustainController == null)
sustainController = GetComponent<PlayerDefenseSustainController>();
}
private bool IsWithinGuardArc(GameObject sourceObject)
{
if (sourceObject == null)
return true;
Vector3 toSource = sourceObject.transform.position - transform.position;
toSource.y = 0f;
if (toSource.sqrMagnitude <= 0.0001f)
return true;
float dot = Vector3.Dot(transform.forward.normalized, toSource.normalized);
float threshold = Mathf.Cos(guardHalfAngle * Mathf.Deg2Rad);
return dot >= threshold;
}
private void ClearDefenseState()
{
isDefenseStateActive = false;
defenseStateElapsed = 0f;
lastPreventedDamage = 0f;
lastWasPerfectGuard = false;
perfectGuardAvailable = false;
}
private void LogDefenseResolution(DamageContext damageContext, bool isPerfectGuard, float resolvedDamage)
{
if (!enableDefenseDebugLogs)
return;
GameObject sourceObject = damageContext.SourceGameObject;
string sourceName = sourceObject != null ? sourceObject.name : "Unknown";
string sourceSkillName = ResolveSourceSkillName(sourceObject);
string guardType = isPerfectGuard ? "완벽 방어" : "방어";
Debug.Log(
$"[Defense] {guardType} 성공 | owner={gameObject.name} | source={sourceName} | skill={sourceSkillName} | incoming={damageContext.Amount:F2} | prevented={lastPreventedDamage:F2} | resolved={resolvedDamage:F2} | elapsed={defenseStateElapsed:F3}s | mitigation={damageContext.MitigationTier}");
}
private static string ResolveSourceSkillName(GameObject sourceObject)
{
if (sourceObject == null)
return "None";
SkillController skillController = sourceObject.GetComponent<SkillController>();
if (skillController == null)
skillController = sourceObject.GetComponentInParent<SkillController>();
if (skillController?.CurrentSkill == null)
return "None";
return skillController.CurrentSkill.SkillName;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 64a105def0eba753fba29d2e8ef03638

View File

@@ -0,0 +1,171 @@
using UnityEngine;
using Colosseum.Passives;
using Colosseum.Skills;
using Colosseum.Weapons;
namespace Colosseum.Player
{
/// <summary>
/// 플레이어의 방어 유지 자원 소모를 관리합니다.
/// 방어 판정과 분리되어 있으며, 애니메이션 이벤트로 시작/종료됩니다.
/// </summary>
[DisallowMultipleComponent]
public class PlayerDefenseSustainController : MonoBehaviour
{
[Header("References")]
[Tooltip("플레이어 자원 관리자")]
[SerializeField] private PlayerNetworkController networkController;
[Tooltip("현재 실행 중인 스킬 정보 참조용")]
[SerializeField] private SkillController skillController;
[Tooltip("무기 마나 배율 참조용")]
[SerializeField] private WeaponEquipment weaponEquipment;
[Header("Sustain Settings")]
[Tooltip("방어 유지의 초당 기본 마나 소모량입니다.")]
[Min(0f)] [SerializeField] private float sustainManaPerSecond = 4f;
[Tooltip("방어 유지 시간이 길어질수록 추가되는 초당 마나 소모량입니다.")]
[Min(0f)] [SerializeField] private float sustainManaRampPerSecond = 4f;
[Tooltip("완벽한 방어 성공 시 환급할 시작 마나 비율입니다.")]
[Range(0f, 1f)] [SerializeField] private float perfectGuardStartManaRefundRatio = 1f;
[Tooltip("방어 유지 중 이동 속도 배율입니다.")]
[Range(0f, 1f)] [SerializeField] private float sustainMoveSpeedMultiplier = 0.45f;
[Header("Debug")]
[Tooltip("현재 방어 유지 활성 여부")]
[SerializeField] private bool isSustainActive;
[Tooltip("현재 방어 유지 시간")]
[Min(0f)] [SerializeField] private float sustainElapsed;
[Tooltip("현재 프레임에 계산된 초당 유지 마나")]
[Min(0f)] [SerializeField] private float currentSustainManaPerSecond;
[Tooltip("마지막으로 캡처한 시작 마나 소모량")]
[Min(0f)] [SerializeField] private float capturedStartManaCost;
/// <summary>
/// 현재 방어 유지 활성 여부입니다.
/// </summary>
public bool IsSustainActive => isSustainActive;
/// <summary>
/// 방어 유지 중 이동 속도 배율입니다.
/// </summary>
public float MoveSpeedMultiplier => isSustainActive ? sustainMoveSpeedMultiplier : 1f;
private void Awake()
{
EnsureReferences();
}
private void OnDisable()
{
ClearSustainState();
}
private void Update()
{
if (!isSustainActive)
return;
sustainElapsed += Time.deltaTime;
currentSustainManaPerSecond = sustainManaPerSecond + (sustainManaRampPerSecond * sustainElapsed);
if (networkController == null || !networkController.IsServer)
return;
float requiredMana = currentSustainManaPerSecond * Time.deltaTime;
float actualSpentMana = networkController.SpendMana(requiredMana);
if (actualSpentMana + 0.001f < requiredMana)
{
skillController?.CancelSkillFromServer(SkillCancelReason.ResourceExhausted);
ClearSustainState();
}
}
/// <summary>
/// 애니메이션 이벤트로 방어 유지 자원 소모를 시작합니다.
/// </summary>
public void BeginSustain()
{
EnsureReferences();
isSustainActive = true;
sustainElapsed = 0f;
currentSustainManaPerSecond = sustainManaPerSecond;
capturedStartManaCost = ResolveCurrentSkillManaCost();
}
/// <summary>
/// 애니메이션 이벤트로 방어 유지 자원 소모를 종료합니다.
/// </summary>
public void EndSustain()
{
ClearSustainState();
}
/// <summary>
/// 스킬 종료/취소 시 방어 유지 상태를 정리합니다.
/// </summary>
public void HandleSkillExecutionEnded()
{
ClearSustainState();
}
/// <summary>
/// 완벽한 방어 성공 시 시작 마나를 환급합니다.
/// </summary>
public void RefundStartManaOnPerfectGuard()
{
EnsureReferences();
if (!isSustainActive || networkController == null || !networkController.IsServer)
return;
float refundAmount = capturedStartManaCost * perfectGuardStartManaRefundRatio;
if (refundAmount <= 0f)
return;
networkController.RestoreMana(refundAmount);
}
private void EnsureReferences()
{
if (networkController == null)
networkController = GetComponent<PlayerNetworkController>();
if (skillController == null)
skillController = GetComponent<SkillController>();
if (weaponEquipment == null)
weaponEquipment = GetComponent<WeaponEquipment>();
}
private float ResolveCurrentSkillManaCost()
{
SkillLoadoutEntry loadoutEntry = skillController != null ? skillController.CurrentLoadoutEntry : null;
SkillData skill = loadoutEntry != null ? loadoutEntry.BaseSkill : skillController != null ? skillController.CurrentSkill : null;
if (skill == null)
return 0f;
float baseManaCost = loadoutEntry != null ? loadoutEntry.GetResolvedManaCost() : skill.ManaCost;
float weaponMultiplier = weaponEquipment != null ? weaponEquipment.ManaCostMultiplier : 1f;
float passiveMultiplier = PassiveRuntimeModifierUtility.GetManaCostMultiplier(gameObject, skill);
return baseManaCost * weaponMultiplier * passiveMultiplier;
}
private void ClearSustainState()
{
isSustainActive = false;
sustainElapsed = 0f;
currentSustainManaPerSecond = 0f;
capturedStartManaCost = 0f;
}
}
}

View File

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

View File

@@ -26,6 +26,9 @@ namespace Colosseum.Player
[Tooltip("이상상태 관리자 (없으면 자동 검색)")]
[SerializeField] private AbnormalityManager abnormalityManager;
[Tooltip("방어 상태 관리자 (없으면 자동 검색)")]
[SerializeField] private PlayerDefenseController defenseController;
[Header("Shield")]
[Tooltip("보호막 타입이 지정되지 않았을 때 사용할 기본 보호막 이상상태 데이터")]
[SerializeField] private AbnormalityData shieldStateAbnormality;
@@ -101,6 +104,13 @@ namespace Colosseum.Player
abnormalityManager = GetComponent<AbnormalityManager>();
}
if (defenseController == null)
{
defenseController = GetComponent<PlayerDefenseController>();
if (defenseController == null)
defenseController = gameObject.AddComponent<PlayerDefenseController>();
}
EnsurePassiveRuntimeReferences();
currentHealth.OnValueChanged += HandleHealthChanged;
@@ -197,7 +207,7 @@ namespace Colosseum.Player
[Rpc(SendTo.Server)]
public void TakeDamageRpc(float damage)
{
ApplyDamageInternal(damage, null);
ApplyDamageInternal(new DamageContext(damage));
}
/// <summary>
@@ -206,10 +216,7 @@ namespace Colosseum.Player
[Rpc(SendTo.Server)]
public void UseManaRpc(float amount)
{
if (isDead.Value)
return;
currentMana.Value = Mathf.Max(0f, currentMana.Value - amount);
SpendMana(amount);
}
/// <summary>
@@ -230,10 +237,7 @@ namespace Colosseum.Player
[Rpc(SendTo.Server)]
public void RestoreManaRpc(float amount)
{
if (isDead.Value)
return;
currentMana.Value = Mathf.Min(MaxMana, currentMana.Value + amount);
RestoreMana(amount);
}
/// <summary>
@@ -665,7 +669,15 @@ namespace Colosseum.Player
/// </summary>
public float TakeDamage(float damage, object source = null)
{
return ApplyDamageInternal(damage, source);
return ApplyDamageInternal(new DamageContext(damage, source));
}
/// <summary>
/// 대미지 컨텍스트를 사용해 대미지를 적용합니다.
/// </summary>
public float TakeDamage(DamageContext damageContext)
{
return ApplyDamageInternal(damageContext);
}
/// <summary>
@@ -682,6 +694,32 @@ namespace Colosseum.Player
return actualHeal;
}
/// <summary>
/// 마나를 소모하고 실제 소모량을 반환합니다.
/// </summary>
public float SpendMana(float amount)
{
if (!IsServer || isDead.Value || amount <= 0f)
return 0f;
float actualSpent = Mathf.Min(amount, currentMana.Value);
currentMana.Value = Mathf.Max(0f, currentMana.Value - actualSpent);
return actualSpent;
}
/// <summary>
/// 마나를 회복하고 실제 회복량을 반환합니다.
/// </summary>
public float RestoreMana(float amount)
{
if (!IsServer || isDead.Value || amount <= 0f)
return 0f;
float actualRestore = Mathf.Min(amount, MaxMana - currentMana.Value);
currentMana.Value = Mathf.Min(MaxMana, currentMana.Value + actualRestore);
return actualRestore;
}
/// <summary>
/// 보호막을 적용합니다.
/// </summary>
@@ -724,17 +762,29 @@ namespace Colosseum.Player
return remainingDamage;
}
private float ApplyDamageInternal(float damage, object source)
private float ApplyDamageInternal(DamageContext damageContext)
{
if (!IsServer || isDead.Value || IsDamageImmune())
return 0f;
float finalDamage = damage * GetIncomingDamageMultiplier();
if (defenseController == null)
defenseController = GetComponent<PlayerDefenseController>();
float rawDamage = damageContext.Amount;
if (rawDamage <= 0f)
return 0f;
if (defenseController != null)
{
rawDamage = defenseController.ResolveIncomingDamage(damageContext.WithAmount(rawDamage));
}
float finalDamage = rawDamage * GetIncomingDamageMultiplier();
float mitigatedDamage = ConsumeShield(finalDamage);
float actualDamage = Mathf.Min(mitigatedDamage, currentHealth.Value);
currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage);
CombatBalanceTracker.RecordDamage(source as GameObject, gameObject, actualDamage);
CombatBalanceTracker.RecordDamage(damageContext.SourceGameObject, gameObject, actualDamage);
if (currentHealth.Value <= 0f)
{

View File

@@ -872,16 +872,43 @@ namespace Colosseum.Player
private void OnSkill6Canceled(InputAction.CallbackContext context) => OnSkillCanceled();
/// <summary>
/// 스킬 버튼 해제 시 채널링 중단을 알립니다.
/// 스킬 버튼 해제 시 반복 유지 단계 중단을 알립니다.
/// </summary>
private void OnSkillCanceled()
{
if (skillController != null && skillController.IsChannelingActive)
if (skillController != null && skillController.CurrentSkill != null && skillController.CurrentSkill.RequiresLoopHold)
{
skillController.NotifyChannelHoldReleased();
skillController.NotifyLoopHoldReleased();
if (IsOwner)
RequestChannelHoldReleaseRpc();
}
}
/// <summary>
/// 반복 유지 입력 해제를 서버에 알리고, 다른 클라이언트에도 종료를 동기화합니다.
/// </summary>
[Rpc(SendTo.Server)]
private void RequestChannelHoldReleaseRpc()
{
if (skillController == null || skillController.CurrentSkill == null || !skillController.CurrentSkill.RequiresLoopHold)
return;
skillController.NotifyLoopHoldReleased();
SyncChannelHoldReleaseRpc();
}
/// <summary>
/// 서버에서 확정된 반복 유지 종료를 클라이언트에 전파합니다.
/// </summary>
[Rpc(SendTo.NotServer)]
private void SyncChannelHoldReleaseRpc()
{
if (skillController == null || skillController.CurrentSkill == null || !skillController.CurrentSkill.RequiresLoopHold)
return;
skillController.NotifyLoopHoldReleased();
}
private PlayerActionState GetOrCreateActionState()
{
var foundState = GetComponent<PlayerActionState>();