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

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

View File

@@ -0,0 +1,44 @@
using UnityEngine;
namespace Colosseum.Debugging
{
/// <summary>
/// 씬 뷰에서 캐릭터의 정면 방향을 화살표로 표시하는 Gizmo 유틸리티입니다.
/// </summary>
public static class FacingDirectionGizmoUtility
{
/// <summary>
/// 지정한 Transform의 정면 방향 화살표를 그립니다.
/// </summary>
public static void DrawFacingArrow(Transform targetTransform, Color color, float length = 1.6f, float headLength = 0.35f, float headWidth = 0.22f, float heightOffset = 0.1f, float shaftThickness = 0.08f)
{
if (targetTransform == null)
return;
Vector3 origin = targetTransform.position + Vector3.up * heightOffset;
Vector3 forward = targetTransform.forward.normalized;
if (forward.sqrMagnitude <= 0.0001f)
return;
Vector3 tip = origin + forward * length;
Vector3 right = targetTransform.right.normalized;
Vector3 up = targetTransform.up.normalized;
Vector3 headBase = tip - forward * headLength;
Vector3 rightOffset = right * shaftThickness;
Vector3 upOffset = up * shaftThickness;
Gizmos.color = color;
Gizmos.DrawLine(origin, tip);
Gizmos.DrawLine(origin + rightOffset, tip + rightOffset);
Gizmos.DrawLine(origin - rightOffset, tip - rightOffset);
Gizmos.DrawLine(origin + upOffset, tip + upOffset);
Gizmos.DrawLine(origin - upOffset, tip - upOffset);
Gizmos.DrawLine(tip, headBase + right * headWidth);
Gizmos.DrawLine(tip, headBase - right * headWidth);
Gizmos.DrawLine(tip, headBase + up * headWidth * 0.7f);
Gizmos.DrawLine(tip, headBase - up * headWidth * 0.7f);
Gizmos.DrawSphere(origin, shaftThickness * 0.55f);
Gizmos.DrawSphere(tip, shaftThickness * 0.65f);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3481720c56d01e6dfa9327a4c386a1cd

View File

@@ -2,6 +2,7 @@ using Unity.Behavior;
using Unity.Netcode;
using UnityEngine;
using Colosseum.Debugging;
using Colosseum.Stats;
namespace Colosseum.Enemy
@@ -11,6 +12,8 @@ namespace Colosseum.Enemy
/// </summary>
public class BossEnemy : EnemyBase
{
private static readonly Color FacingGizmoColor = new Color(1f, 0.25f, 0.2f, 1f);
[Header("Boss Settings")]
[Tooltip("초기 Behavior Graph")]
[SerializeField] private BehaviorGraph initialBehaviorGraph;
@@ -76,5 +79,10 @@ namespace Colosseum.Enemy
base.HandleDeath();
}
private void OnDrawGizmos()
{
FacingDirectionGizmoUtility.DrawFacingArrow(transform, FacingGizmoColor, length: 2.2f, headLength: 0.45f, headWidth: 0.28f, heightOffset: 0.15f, shaftThickness: 0.14f);
}
}
}

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);

View File

@@ -8,6 +8,7 @@ using Unity.Netcode;
using Colosseum.Abnormalities;
using Colosseum.Combat;
using Colosseum.Debugging;
using Colosseum.Passives;
using Colosseum.Skills;
using Colosseum.Stats;
@@ -19,6 +20,8 @@ namespace Colosseum.Player
/// </summary>
public class PlayerNetworkController : NetworkBehaviour, IDamageable
{
private static readonly Color FacingGizmoColor = new Color(0.2f, 0.85f, 1f, 1f);
[Header("References")]
[Tooltip("CharacterStats 컴포넌트 (없으면 자동 검색)")]
[SerializeField] private CharacterStats characterStats;
@@ -219,6 +222,11 @@ namespace Colosseum.Player
SpendMana(amount);
}
private void OnDrawGizmos()
{
FacingDirectionGizmoUtility.DrawFacingArrow(transform, FacingGizmoColor, length: 1.7f, headLength: 0.35f, headWidth: 0.22f, heightOffset: 0.12f, shaftThickness: 0.11f);
}
/// <summary>
/// 체력 회복 (서버에서만 실행)
/// </summary>

View File

@@ -55,6 +55,8 @@ namespace Colosseum.Skills
private const string IdleStateName = "Idle";
private const string BossIdlePhase1StateName = "Idle_Phase1";
private const string BossIdlePhase3StateName = "Idle_Phase3";
private const int MaxDebugSkillStartPoints = 16;
private static readonly Color SkillStartPointGizmoColor = new Color(1f, 0.92f, 0.2f, 0.95f);
[Header("애니메이션")]
[SerializeField] private Animator animator;
@@ -122,6 +124,8 @@ namespace Colosseum.Skills
private bool shouldBlendIntoCurrentSkill = true;
private bool shouldRestoreToIdleAfterCurrentSkill = true;
private bool isBossPatternBoundarySkill = false;
private bool hasAppliedStartTargetFacing = false;
private bool shouldSuppressInitialSkillStartHorizontalRootMotion = false;
// 쿨타임 추적
private Dictionary<SkillData, float> cooldownTracker = new Dictionary<SkillData, float>();
@@ -131,12 +135,64 @@ namespace Colosseum.Skills
private float currentClipExpectedDuration = 0f;
private string currentClipDebugName = string.Empty;
private int currentClipStartFrame = -1;
private readonly List<Vector3> debugSkillStartPoints = new();
public bool IsExecutingSkill => currentSkill != null;
public bool IsPlayingAnimation => currentSkill != null;
public bool UsesRootMotion => currentSkill != null && currentSkill.UseRootMotion;
public bool IgnoreRootMotionY => currentSkill != null && currentSkill.IgnoreRootMotionY;
/// <summary>
/// 현재 스킬의 루트 위치를 실제 이동에 반영해도 되는지 반환합니다.
/// 스킬 시작 직후 아직 Animator가 Skill 상태로 전이되지 않은 프레임에서는
/// 이전 상태의 루트 XZ가 누적되지 않도록 위치 적용을 잠시 차단합니다.
/// </summary>
public bool ShouldApplyRootMotionPosition =>
currentSkill == null
|| !currentSkill.UseRootMotion
|| animator == null
|| IsCurrentStateSkillState(animator.GetCurrentAnimatorStateInfo(0));
/// <summary>
/// 현재 스킬의 루트 회전을 실제 Transform 회전에 반영해야 하는지 반환합니다.
/// 대상 추적이 코드 회전으로 이미 해결되는 FaceTarget 스킬은
/// 잘라 쓴 클립의 종료 루트 회전이 다음 스킬로 누적되지 않도록 애니메이션 루트 회전을 차단합니다.
/// </summary>
public bool ShouldApplyRootMotionRotation =>
currentSkill == null
|| !currentSkill.UseRootMotion
|| (currentSkill.CastTargetTrackingMode != SkillCastTargetTrackingMode.FaceTarget
&& currentSkill.CastTargetTrackingMode != SkillCastTargetTrackingMode.FaceTargetOnStart);
/// <summary>
/// 현재 스킬의 수평 루트모션을 항상 정면축 기준으로 투영해야 하는지 반환합니다.
/// 근접 추적형 보스 스킬은 클립 내부의 비스듬한 XZ 이동 대신 현재 정면 기준 전진만 사용합니다.
/// </summary>
public bool ShouldProjectHorizontalRootMotionToFacing =>
currentSkill != null
&& currentSkill.UseRootMotion
&& (currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.FaceTarget
|| currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.FaceTargetOnStart);
/// <summary>
/// 스킬 진입 첫 프레임의 비정상적인 수평 루트 스냅을 1회 차단해야 하는지 반환합니다.
/// Animator가 Skill 상태 normalizedTime 0으로 막 진입한 프레임에서만 true를 반환하고 곧바로 소비합니다.
/// </summary>
public bool ConsumeInitialSkillStartHorizontalRootMotionSuppression()
{
if (!shouldSuppressInitialSkillStartHorizontalRootMotion || currentSkill == null || !currentSkill.UseRootMotion || animator == null)
return false;
AnimatorStateInfo stateInfo = animator.GetCurrentAnimatorStateInfo(0);
if (!IsCurrentStateSkillState(stateInfo))
return false;
if (stateInfo.normalizedTime <= 0.001f)
{
shouldSuppressInitialSkillStartHorizontalRootMotion = false;
return true;
}
shouldSuppressInitialSkillStartHorizontalRootMotion = false;
return false;
}
public SkillData CurrentSkill => currentSkill;
public SkillLoadoutEntry CurrentLoadoutEntry => currentLoadoutEntry;
public Animator Animator => animator;
@@ -293,6 +349,9 @@ namespace Colosseum.Skills
var stateInfo = animator.GetCurrentAnimatorStateInfo(0);
if (!IsCurrentStateSkillState(stateInfo))
return;
// 애니메이션 종료 시 처리
if (stateInfo.normalizedTime >= 1f)
{
@@ -419,6 +478,7 @@ namespace Colosseum.Skills
currentRepeatCount = currentLoadoutEntry.GetResolvedRepeatCount();
currentIterationIndex = 0;
loopHoldRequested = skill.RequiresLoopHold;
RecordSkillStartPoint();
if (debugMode) Debug.Log($"[Skill] Cast: {skill.SkillName}");
@@ -485,6 +545,34 @@ namespace Colosseum.Skills
}
}
/// <summary>
/// 씬 뷰에서 스킬 시작 위치를 추적하기 위해 현재 루트 위치를 기록합니다.
/// </summary>
private void RecordSkillStartPoint()
{
debugSkillStartPoints.Add(transform.position);
if (debugSkillStartPoints.Count <= MaxDebugSkillStartPoints)
return;
debugSkillStartPoints.RemoveAt(0);
}
private void OnDrawGizmos()
{
if (debugSkillStartPoints.Count <= 0)
return;
Gizmos.color = SkillStartPointGizmoColor;
for (int i = 0; i < debugSkillStartPoints.Count; i++)
{
Vector3 point = debugSkillStartPoints[i] + Vector3.up * 0.06f;
float radius = i == debugSkillStartPoints.Count - 1 ? 0.14f : 0.1f;
Gizmos.DrawSphere(point, radius);
}
}
/// <summary>
/// 애니메이션 이벤트가 없는 자기 자신 대상 효과는 시전 즉시 발동합니다.
/// 버프/무적 같은 자기 강화 스킬이 이벤트 누락으로 동작하지 않는 상황을 막기 위한 보정입니다.
@@ -561,6 +649,8 @@ namespace Colosseum.Skills
currentIterationIndex++;
currentClipSequenceIndex = 0;
hasAppliedStartTargetFacing = false;
shouldSuppressInitialSkillStartHorizontalRootMotion = currentSkill.UseRootMotion;
isPlayingReleasePhase = false;
currentPhaseAnimationClips = currentSkill.AnimationClips;
@@ -570,6 +660,7 @@ namespace Colosseum.Skills
}
TriggerCastStartEffects();
ApplyImmediateCastTargetFacingBeforeSkillStart();
if (currentPhaseAnimationClips.Count > 0 && animator != null)
{
@@ -584,6 +675,37 @@ namespace Colosseum.Skills
TriggerImmediateSelfEffectsIfNeeded();
}
/// <summary>
/// 루트모션 근접 스킬은 첫 프레임 이동이 현재 정면 기준으로 바로 적용되므로
/// 실제 클립 재생 전에 대상 방향으로 한 번 즉시 정렬해 시작 방향이 틀어지지 않게 합니다.
/// </summary>
private void ApplyImmediateCastTargetFacingBeforeSkillStart()
{
if (currentSkill == null || currentTargetOverride == null || !currentTargetOverride.activeInHierarchy)
return;
if (IsSpawned && !IsServer)
return;
bool shouldSnapFacing =
currentSkill.UseRootMotion &&
(currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.FaceTarget
|| currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.FaceTargetOnStart
|| currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.MoveTowardTarget);
if (!shouldSnapFacing)
return;
Vector3 direction = TargetSurfaceUtility.GetHorizontalDirectionToSurface(transform.position, currentTargetOverride);
if (direction.sqrMagnitude < 0.0001f)
return;
transform.rotation = Quaternion.LookRotation(direction.normalized);
if (currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.FaceTargetOnStart)
hasAppliedStartTargetFacing = true;
}
/// <summary>
/// 시퀀스 내 다음 클립이 있으면 재생합니다.
/// </summary>
@@ -1297,6 +1419,8 @@ namespace Colosseum.Skills
currentClipStartFrame = -1;
shouldBlendIntoCurrentSkill = true;
shouldRestoreToIdleAfterCurrentSkill = true;
hasAppliedStartTargetFacing = false;
shouldSuppressInitialSkillStartHorizontalRootMotion = false;
}
/// <summary>
@@ -1373,7 +1497,15 @@ namespace Colosseum.Skills
&& currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.FaceTarget
&& enemyBase.IsTouchingPlayerContact;
if (currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.FaceTarget ||
if (currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.FaceTargetOnStart)
{
if (!hasAppliedStartTargetFacing)
{
transform.rotation = Quaternion.LookRotation(direction.normalized);
hasAppliedStartTargetFacing = true;
}
}
else if (currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.FaceTarget ||
currentSkill.CastTargetTrackingMode == SkillCastTargetTrackingMode.MoveTowardTarget)
{
if (!suppressRotationWhileContactingPlayer)
@@ -1598,6 +1730,15 @@ namespace Colosseum.Skills
return Animator.StringToHash($"{BaseLayerName}.{SKILL_STATE_NAME}");
}
/// <summary>
/// 현재 레이어가 실제 스킬 상태를 재생 중인지 확인합니다.
/// 스킬 시작 직후 한 프레임 동안 이전 상태 정보가 남아 조기 종료되는 것을 방지합니다.
/// </summary>
private static bool IsCurrentStateSkillState(AnimatorStateInfo stateInfo)
{
return stateInfo.fullPathHash == GetSkillStateHash();
}
/// <summary>
/// 스킬 종료 후 복귀할 상태 해시를 결정합니다.
/// </summary>

View File

@@ -58,6 +58,7 @@ namespace Colosseum.Skills
None,
FaceTarget,
MoveTowardTarget,
FaceTargetOnStart,
}
/// <summary>