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

@@ -9,6 +9,7 @@ using Colosseum.Abnormalities;
using Colosseum.Passives;
using Colosseum.Stats;
using Colosseum.Combat;
using Colosseum.Player;
using Colosseum.Skills;
namespace Colosseum.Enemy
@@ -28,6 +29,8 @@ namespace Colosseum.Enemy
[SerializeField] protected UnityEngine.AI.NavMeshAgent navMeshAgent;
[Tooltip("이상상태 관리자")]
[SerializeField] protected AbnormalityManager abnormalityManager;
[Tooltip("플레이어와의 물리 겹침을 계산할 본체 콜라이더")]
[SerializeField] private Collider bodyCollider;
[Header("Data")]
[SerializeField] protected EnemyData enemyData;
@@ -46,6 +49,12 @@ namespace Colosseum.Enemy
[Tooltip("보호막 타입이 지정되지 않았을 때 사용할 기본 보호막 이상상태 데이터")]
[SerializeField] private AbnormalityData shieldStateAbnormality;
[Header("Player Separation")]
[Tooltip("적과 플레이어 사이에 추가로 유지할 수평 간격")]
[Min(0f)] [SerializeField] private float playerSeparationPadding = 0.1f;
[Tooltip("플레이어와 닿아 있을 때 적의 수평 이동을 멈출지 여부")]
[SerializeField] private bool freezeHorizontalMotionOnPlayerContact = true;
// 네트워크 동기화 변수
protected NetworkVariable<float> currentHealth = new NetworkVariable<float>(100f);
@@ -95,6 +104,8 @@ namespace Colosseum.Enemy
navMeshAgent = GetComponent<UnityEngine.AI.NavMeshAgent>();
if (abnormalityManager == null)
abnormalityManager = GetComponent<AbnormalityManager>();
if (bodyCollider == null)
bodyCollider = GetComponent<Collider>();
// 서버에서 초기화
if (IsServer)
@@ -126,34 +137,27 @@ namespace Colosseum.Enemy
protected virtual void OnServerUpdate() { }
/// <summary>
/// 보스와 플레이어가 겹치면 플레이어를 밀어냅니다.
/// 보스와 플레이어가 겹치면 적 자신을 살짝 밀어내 겹침을 해소합니다.
/// 점프 착지 포함, 항상 실행됩니다.
/// </summary>
private void LateUpdate()
{
if (!IsServer || IsDead) return;
float separationDist = navMeshAgent != null
? Mathf.Max(navMeshAgent.stoppingDistance, navMeshAgent.radius + 0.5f)
: 1f;
Vector3 separationOffset = ComputePlayerSeparationOffset();
if (separationOffset.sqrMagnitude <= 0.000001f)
return;
int count = Physics.OverlapSphereNonAlloc(transform.position, separationDist, overlapBuffer);
for (int i = 0; i < count; i++)
if (navMeshAgent != null && !isAirborne && navMeshAgent.enabled)
{
if (!overlapBuffer[i].TryGetComponent<CharacterController>(out var cc)) continue;
Vector3 toPlayer = overlapBuffer[i].transform.position - transform.position;
toPlayer.y = 0f;
float dist = toPlayer.magnitude;
if (dist >= separationDist) continue;
// 플레이어를 보스 바깥으로 밀어냄
Vector3 pushDir = dist > 0.001f ? toPlayer.normalized : transform.forward;
cc.Move(pushDir * (separationDist - dist));
// 보스가 이동 중이었으면 정지 (플레이어 안으로 더 진입하지 않도록)
if (navMeshAgent != null && !isAirborne && navMeshAgent.velocity.sqrMagnitude > 0.01f)
if (navMeshAgent.velocity.sqrMagnitude > 0.01f)
navMeshAgent.isStopped = true;
navMeshAgent.Move(separationOffset);
}
else
{
transform.position += separationOffset;
}
}
@@ -222,23 +226,10 @@ namespace Colosseum.Enemy
navMeshAgent.Warp(transform.position);
}
// XZ 차단: 플레이어 방향으로의 이동 방지 (일반 이동 중에만)
float blockRadius = Mathf.Max(navMeshAgent.stoppingDistance, navMeshAgent.radius + 0.5f);
int count = Physics.OverlapSphereNonAlloc(transform.position, blockRadius, overlapBuffer);
for (int i = 0; i < count; i++)
if (freezeHorizontalMotionOnPlayerContact && IsTouchingPlayer())
{
if (!overlapBuffer[i].TryGetComponent<CharacterController>(out _)) continue;
Vector3 toPlayer = overlapBuffer[i].transform.position - transform.position;
toPlayer.y = 0f;
if (toPlayer.sqrMagnitude < 0.0001f) continue;
Vector3 deltaXZ = new Vector3(deltaPosition.x, 0f, deltaPosition.z);
if (Vector3.Dot(deltaXZ, toPlayer.normalized) > 0f)
{
deltaPosition.x = 0f;
deltaPosition.z = 0f;
}
deltaPosition.x = 0f;
deltaPosition.z = 0f;
}
navMeshAgent.Move(deltaPosition);
@@ -282,23 +273,36 @@ namespace Colosseum.Enemy
/// 대미지 적용 (서버에서 실행)
/// </summary>
public virtual float TakeDamage(float damage, object source = null)
{
return TakeDamage(new DamageContext(damage, source));
}
/// <summary>
/// 대미지 컨텍스트를 사용해 대미지를 적용합니다.
/// </summary>
public virtual float TakeDamage(DamageContext damageContext)
{
if (!IsServer || isDead.Value)
return 0f;
if (ShouldIgnoreIncomingDamage(damage, source))
float damage = damageContext.Amount;
if (damage <= 0f)
return 0f;
if (ShouldIgnoreIncomingDamage(damage, damageContext.Source))
return 0f;
float mitigatedDamage = ConsumeShield(damage);
float actualDamage = Mathf.Min(mitigatedDamage, currentHealth.Value);
currentHealth.Value = Mathf.Max(0f, currentHealth.Value - actualDamage);
CombatBalanceTracker.RecordDamage(source as GameObject, gameObject, actualDamage);
RegisterThreatFromDamage(actualDamage, source);
GameObject sourceObject = damageContext.SourceGameObject;
CombatBalanceTracker.RecordDamage(sourceObject, gameObject, actualDamage);
RegisterThreatFromDamage(actualDamage, sourceObject);
OnDamageTaken?.Invoke(actualDamage);
// 대미지 피드백 (애니메이션, 이펙트 등)
OnTakeDamageFeedback(actualDamage, source);
OnTakeDamageFeedback(actualDamage, damageContext.Source);
if (currentHealth.Value <= 0f)
{
@@ -327,6 +331,93 @@ namespace Colosseum.Enemy
}
}
private Vector3 ComputePlayerSeparationOffset()
{
if (bodyCollider == null)
return Vector3.zero;
float scanRadius = GetPlayerDetectionRadius();
int count = Physics.OverlapSphereNonAlloc(transform.position, scanRadius, overlapBuffer);
Vector3 separationOffset = Vector3.zero;
int overlapCount = 0;
for (int i = 0; i < count; i++)
{
if (!TryGetPlayerCharacterController(overlapBuffer[i], out CharacterController playerController))
continue;
if (!Physics.ComputePenetration(
bodyCollider, transform.position, transform.rotation,
playerController, playerController.transform.position, playerController.transform.rotation,
out Vector3 separationDirection, out float separationDistance))
{
continue;
}
separationDirection.y = 0f;
if (separationDirection.sqrMagnitude <= 0.0001f)
separationDirection = -transform.forward;
separationOffset += separationDirection.normalized * (separationDistance + playerSeparationPadding);
overlapCount++;
}
if (overlapCount <= 0)
return Vector3.zero;
separationOffset /= overlapCount;
separationOffset.y = 0f;
return separationOffset;
}
private bool IsTouchingPlayer()
{
float scanRadius = GetPlayerDetectionRadius();
int count = Physics.OverlapSphereNonAlloc(transform.position, scanRadius, overlapBuffer);
for (int i = 0; i < count; i++)
{
if (!TryGetPlayerCharacterController(overlapBuffer[i], out CharacterController playerController))
continue;
Vector3 toPlayer = playerController.transform.position - transform.position;
toPlayer.y = 0f;
if (toPlayer.magnitude < GetRequiredSeparationDistance(playerController))
return true;
}
return false;
}
private float GetPlayerDetectionRadius()
{
float enemyRadius = navMeshAgent != null ? navMeshAgent.radius : 0.5f;
return enemyRadius + 1f + playerSeparationPadding;
}
private float GetRequiredSeparationDistance(CharacterController playerController)
{
float enemyRadius = navMeshAgent != null ? navMeshAgent.radius : 0.5f;
float playerRadius = playerController != null ? playerController.radius : 0.5f;
return enemyRadius + playerRadius + playerSeparationPadding;
}
private static bool TryGetPlayerCharacterController(Collider overlapCollider, out CharacterController playerController)
{
playerController = null;
if (overlapCollider == null)
return false;
playerController = overlapCollider.GetComponent<CharacterController>();
if (playerController == null)
playerController = overlapCollider.GetComponentInParent<CharacterController>();
if (playerController == null)
return false;
return playerController.GetComponent<PlayerNetworkController>() != null
|| playerController.GetComponentInParent<PlayerNetworkController>() != null;
}
/// <summary>
/// 체력 회복 (서버에서 실행)
/// </summary>