fix: 드로그 기본기3 시작 루트모션과 추적 보정 정리

- 기본기3 시작 프레임의 수평 루트모션 스냅을 차단하고 정면 투영/접촉 정지 거리 보정을 정리
- 스킬 완료 판정과 시작 타깃 정렬 로직을 보강하고 드로그/플레이어 정면 및 시작점 디버그 gizmo를 추가
- 드로그 기본기3 관련 애니메이션·패턴·스킬 자산을 재정리하고 Blender 보조 스크립트를 추가
This commit is contained in:
2026-04-17 09:56:40 +09:00
parent 5ba543ed8c
commit 8c08e63c81
20 changed files with 173170 additions and 162503 deletions

View File

@@ -52,6 +52,8 @@ namespace Colosseum.Enemy
[Header("Player Separation")]
[Tooltip("적과 플레이어 사이에 추가로 유지할 수평 간격")]
[Min(0f)] [SerializeField] private float playerSeparationPadding = 0.1f;
[Tooltip("실제 겹치기 직전에도 근접 기본기 루트모션을 미리 차단할 추가 수평 여유 거리")]
[Min(0f)] [SerializeField] private float playerSurfaceLockPadding = 0.75f;
[Tooltip("플레이어와 닿아 있을 때 적의 수평 이동을 멈출지 여부")]
[SerializeField] private bool freezeHorizontalMotionOnPlayerContact = true;
@@ -75,6 +77,10 @@ namespace Colosseum.Enemy
private bool hasJumpTarget = false;
private Vector3 jumpStartXZ;
private Vector3 jumpTargetXZ;
private bool isHoldingSkillTransitionPosition;
private bool hasLastStableSkillPosition;
private Vector3 heldSkillTransitionPosition;
private Vector3 lastStableSkillPosition;
// 이벤트
public event Action<float, float> OnHealthChanged; // currentHealth, maxHealth
public event Action<float, float> OnShieldChanged; // oldShield, newShield
@@ -145,7 +151,15 @@ namespace Colosseum.Enemy
{
if (!IsServer || IsDead) return;
if (freezeHorizontalMotionOnPlayerContact)
{
ApplySkillTransitionContactPositionHold();
if (!isHoldingSkillTransitionPosition)
{
lastStableSkillPosition = transform.position;
hasLastStableSkillPosition = true;
}
return;
}
Vector3 separationOffset = ComputePlayerSeparationOffset();
if (separationOffset.sqrMagnitude <= 0.000001f)
@@ -189,6 +203,31 @@ namespace Colosseum.Enemy
&& !skillCtrl.IgnoreRootMotionY;
Vector3 deltaPosition = animator.deltaPosition;
bool shouldApplyRootPosition = skillCtrl == null || skillCtrl.ShouldApplyRootMotionPosition;
if (!shouldApplyRootPosition)
{
deltaPosition.x = 0f;
deltaPosition.z = 0f;
}
if (skillCtrl != null && skillCtrl.ConsumeInitialSkillStartHorizontalRootMotionSuppression())
{
deltaPosition.x = 0f;
deltaPosition.z = 0f;
}
bool shouldProjectRootMotionToFacing = skillCtrl != null && skillCtrl.ShouldProjectHorizontalRootMotionToFacing;
if (shouldProjectRootMotionToFacing)
deltaPosition = ProjectHorizontalDeltaOntoFacing(deltaPosition);
if (skillCtrl != null)
deltaPosition = ClampRootMotionAgainstTargetStopDistance(skillCtrl, deltaPosition);
bool isTouchingPlayerContact = freezeHorizontalMotionOnPlayerContact && IsTouchingPlayer();
bool shouldFreezeSkillHorizontalMotion =
isTouchingPlayerContact &&
skillCtrl != null &&
skillCtrl.IsPlayingAnimation;
if (needsYMotion)
{
@@ -209,16 +248,41 @@ namespace Colosseum.Enemy
newXZ.x - transform.position.x,
deltaPosition.y,
newXZ.z - transform.position.z);
if (!shouldApplyRootPosition)
{
desiredDelta.x = 0f;
desiredDelta.z = 0f;
}
if (shouldProjectRootMotionToFacing)
desiredDelta = ProjectHorizontalDeltaOntoFacing(desiredDelta);
if (shouldFreezeSkillHorizontalMotion)
{
desiredDelta.x = 0f;
desiredDelta.z = 0f;
}
if (freezeHorizontalMotionOnPlayerContact)
{
if (isTouchingPlayerContact && !shouldFreezeSkillHorizontalMotion)
desiredDelta = ProjectHorizontalDeltaOntoFacing(desiredDelta);
desiredDelta = LimitHorizontalDeltaAgainstPlayerContact(desiredDelta);
}
transform.position += desiredDelta;
}
else
{
// jumpToTarget 없으면 기존처럼 애니메이션 루트모션 그대로 적용
if (shouldFreezeSkillHorizontalMotion)
{
deltaPosition.x = 0f;
deltaPosition.z = 0f;
}
if (freezeHorizontalMotionOnPlayerContact)
{
if (isTouchingPlayerContact && !shouldFreezeSkillHorizontalMotion)
deltaPosition = ProjectHorizontalDeltaOntoFacing(deltaPosition);
deltaPosition = LimitHorizontalDeltaAgainstPlayerContact(deltaPosition);
}
transform.position += deltaPosition;
}
@@ -249,13 +313,21 @@ namespace Colosseum.Enemy
if (freezeHorizontalMotionOnPlayerContact)
{
ApplyPlayerContactMovementLock();
if (shouldFreezeSkillHorizontalMotion)
{
deltaPosition.x = 0f;
deltaPosition.z = 0f;
}
else if (isTouchingPlayerContact)
deltaPosition = ProjectHorizontalDeltaOntoFacing(deltaPosition);
deltaPosition = LimitHorizontalDeltaAgainstPlayerContact(deltaPosition);
}
navMeshAgent.Move(deltaPosition);
}
if (animator.deltaRotation != Quaternion.identity)
bool shouldApplyRootRotation = skillCtrl == null || skillCtrl.ShouldApplyRootMotionRotation;
if (shouldApplyRootRotation && animator.deltaRotation != Quaternion.identity)
transform.rotation *= animator.deltaRotation;
}
@@ -389,6 +461,42 @@ namespace Colosseum.Enemy
return separationOffset;
}
/// <summary>
/// 스킬 시작 직후 아직 Animator가 Skill 상태로 전이되지 않은 접촉 프레임에서는
/// 수평 좌표를 직전 안정 위치에 고정해 전환 구간 드리프트를 막습니다.
/// </summary>
private void ApplySkillTransitionContactPositionHold()
{
var skillCtrl = GetComponent<SkillController>();
bool shouldHoldPosition =
skillCtrl != null &&
skillCtrl.IsPlayingAnimation &&
!skillCtrl.ShouldApplyRootMotionPosition &&
IsTouchingPlayer();
if (!shouldHoldPosition)
{
isHoldingSkillTransitionPosition = false;
return;
}
if (!isHoldingSkillTransitionPosition)
{
heldSkillTransitionPosition = hasLastStableSkillPosition
? lastStableSkillPosition
: transform.position;
isHoldingSkillTransitionPosition = true;
}
Vector3 lockedPosition = transform.position;
lockedPosition.x = heldSkillTransitionPosition.x;
lockedPosition.z = heldSkillTransitionPosition.z;
transform.position = lockedPosition;
if (navMeshAgent != null && navMeshAgent.enabled)
navMeshAgent.Warp(transform.position);
}
private bool IsTouchingPlayer()
{
if (bodyCollider == null)
@@ -421,6 +529,14 @@ namespace Colosseum.Enemy
if (horizontalDelta.sqrMagnitude <= 0.000001f)
return deltaPosition;
if (TryGetTouchingPlayerDirection(out Vector3 touchingPlayerDirection) &&
IsBlockedByContactForwardHemisphere(horizontalDelta, touchingPlayerDirection))
{
deltaPosition.x = 0f;
deltaPosition.z = 0f;
return deltaPosition;
}
horizontalDelta = ClampHorizontalDeltaToPlayerSurface(horizontalDelta);
if (horizontalDelta.sqrMagnitude <= 0.000001f)
{
@@ -447,6 +563,78 @@ namespace Colosseum.Enemy
return deltaPosition;
}
/// <summary>
/// 접촉 중인 상태에서는 루트모션의 수평 이동을 적의 정면축으로만 투영합니다.
/// 플레이어 표면에 걸리면서 측면으로 미끄러지는 현상을 줄이기 위한 보정입니다.
/// </summary>
private Vector3 ProjectHorizontalDeltaOntoFacing(Vector3 deltaPosition)
{
Vector3 horizontalDelta = new Vector3(deltaPosition.x, 0f, deltaPosition.z);
if (horizontalDelta.sqrMagnitude <= 0.000001f)
return deltaPosition;
Vector3 forward = transform.forward;
forward.y = 0f;
if (forward.sqrMagnitude <= 0.000001f)
return deltaPosition;
forward.Normalize();
float forwardDistance = Vector3.Dot(horizontalDelta, forward);
Vector3 projected = forward * forwardDistance;
deltaPosition.x = projected.x;
deltaPosition.z = projected.z;
return deltaPosition;
}
/// <summary>
/// 현재 실제로 겹치고 있는 플레이어 중심 방향을 수평 기준으로 구합니다.
/// 접촉 중 전방 진입을 즉시 차단하기 위한 단순화된 방향 계산입니다.
/// </summary>
private bool TryGetTouchingPlayerDirection(out Vector3 touchingPlayerDirection)
{
touchingPlayerDirection = Vector3.zero;
if (bodyCollider == null)
return false;
float scanRadius = GetPlayerDetectionRadius();
Vector3 scanCenter = bodyCollider.bounds.center;
int count = Physics.OverlapSphereNonAlloc(scanCenter, scanRadius, overlapBuffer);
int overlapCount = 0;
for (int i = 0; i < count; i++)
{
if (!TryGetPlayerCharacterController(overlapBuffer[i], out CharacterController playerController))
continue;
if (!Physics.ComputePenetration(
bodyCollider, bodyCollider.transform.position, bodyCollider.transform.rotation,
playerController, playerController.transform.position, playerController.transform.rotation,
out _, out float separationDistance) ||
separationDistance <= 0.0001f)
{
continue;
}
Vector3 towardPlayer = playerController.bounds.center - scanCenter;
towardPlayer.y = 0f;
if (towardPlayer.sqrMagnitude <= 0.0001f)
continue;
touchingPlayerDirection += towardPlayer.normalized;
overlapCount++;
}
if (overlapCount <= 0 || touchingPlayerDirection.sqrMagnitude <= 0.0001f)
{
touchingPlayerDirection = Vector3.zero;
return false;
}
touchingPlayerDirection.Normalize();
return true;
}
private Vector3 ClampHorizontalDeltaToPlayerSurface(Vector3 horizontalDelta)
{
if (bodyCollider == null || horizontalDelta.sqrMagnitude <= 0.000001f)
@@ -606,13 +794,64 @@ namespace Colosseum.Enemy
return horizontalDelta * min;
}
/// <summary>
/// 루트모션 근접 스킬이 대상 표면 정지 거리 안으로 파고들지 않도록 수평 이동을 제한합니다.
/// 현재는 MoveTowardTarget에만 쓰이던 stopDistance를 루트모션 근접기에도 동일하게 적용합니다.
/// </summary>
private Vector3 ClampRootMotionAgainstTargetStopDistance(SkillController skillCtrl, Vector3 deltaPosition)
{
if (skillCtrl == null || !skillCtrl.UsesRootMotion)
return deltaPosition;
SkillData currentSkill = skillCtrl.CurrentSkill;
GameObject currentTarget = skillCtrl.CurrentTargetOverride;
if (currentSkill == null || currentTarget == null || !currentTarget.activeInHierarchy)
return deltaPosition;
float stopDistance = Mathf.Max(0f, currentSkill.CastTargetStopDistance);
if (stopDistance <= 0f)
return deltaPosition;
Vector3 horizontalDelta = new Vector3(deltaPosition.x, 0f, deltaPosition.z);
if (horizontalDelta.sqrMagnitude <= 0.000001f)
return deltaPosition;
Vector3 towardTarget = TargetSurfaceUtility.GetHorizontalDirectionToSurface(transform.position, currentTarget);
if (towardTarget.sqrMagnitude <= 0.0001f)
return deltaPosition;
towardTarget.Normalize();
float currentSurfaceDistance = TargetSurfaceUtility.GetHorizontalSurfaceDistance(transform.position, currentTarget);
if (currentSurfaceDistance <= stopDistance)
{
deltaPosition.x = 0f;
deltaPosition.z = 0f;
return deltaPosition;
}
float towardDistance = Vector3.Dot(horizontalDelta, towardTarget);
if (towardDistance <= 0f)
return deltaPosition;
float allowedTowardDistance = Mathf.Max(0f, currentSurfaceDistance - stopDistance);
if (towardDistance <= allowedTowardDistance)
return deltaPosition;
Vector3 lateralDelta = horizontalDelta - towardTarget * towardDistance;
Vector3 clampedHorizontalDelta = lateralDelta + towardTarget * allowedTowardDistance;
deltaPosition.x = clampedHorizontalDelta.x;
deltaPosition.z = clampedHorizontalDelta.z;
return deltaPosition;
}
private bool WouldCrossPlayerSurface(Vector3 horizontalOffset)
{
if (bodyCollider == null)
return false;
float enemyRadius = GetBodyHorizontalRadius();
float minimumSurfaceDistance = enemyRadius + playerSeparationPadding;
float minimumSurfaceDistance = enemyRadius + playerSeparationPadding + playerSurfaceLockPadding;
float scanRadius = GetPlayerDetectionRadius() + horizontalOffset.magnitude;
Vector3 scanCenter = bodyCollider.bounds.center + horizontalOffset;
int count = Physics.OverlapSphereNonAlloc(scanCenter, scanRadius, overlapBuffer);