2026-05-18 부자연스러운 모션
This commit is contained in:
@@ -35,6 +35,7 @@ public class PlayerController : MonoBehaviour
|
||||
[SerializeField] private ComboNode _dashRootNode;
|
||||
[SerializeField] private ComboNode _rollRootNode;
|
||||
[SerializeField] private ComboNode _backDashRootNode;
|
||||
[SerializeField] private ComboNode _grabSmashRootNode;
|
||||
private readonly Dictionary<ActionData, float> _motionCooldownTimers = new();
|
||||
private readonly List<ActionData> _motionCooldownKeys = new();
|
||||
|
||||
@@ -59,11 +60,14 @@ public class PlayerController : MonoBehaviour
|
||||
private CancellationTokenSource _attackCts;
|
||||
private CancellationTokenSource _motionCts;
|
||||
private CancellationTokenSource _animationSpeedCts;
|
||||
private CancellationTokenSource _actionVelocityCts;
|
||||
private bool _isMotionActive;
|
||||
private ActionData _lastAttackGizmoData;
|
||||
[SerializeField] private float _hitGizmoFadeDuration = 0.5f;
|
||||
private ActionData _lastHitData;
|
||||
private Vector2 _lastHitCenter;
|
||||
private float _lastHitTime = -1f;
|
||||
private Enemy _lastHitEnemy;
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] private bool _showAttackDebug = true;
|
||||
@@ -83,6 +87,7 @@ private void Awake()
|
||||
_spriteRenderer = GetComponentInChildren<SpriteRenderer>();
|
||||
_bodyColliders = GetComponentsInChildren<Collider2D>();
|
||||
EnsureAttackHitbox();
|
||||
_attackHitbox.OnHit += OnAttackHit;
|
||||
}
|
||||
|
||||
private void Start()
|
||||
@@ -94,6 +99,7 @@ private void Start()
|
||||
InputManager.Instance.OnDash_Event += OnDashInput;
|
||||
InputManager.Instance.OnRoll_Event += OnRollInput;
|
||||
InputManager.Instance.OnBackDash_Event += OnBackDashInput;
|
||||
InputManager.Instance.OnGrabSmash_Event += OnGrabSmashInput;
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
@@ -107,14 +113,16 @@ private void OnDestroy()
|
||||
InputManager.Instance.OnDash_Event -= OnDashInput;
|
||||
InputManager.Instance.OnRoll_Event -= OnRollInput;
|
||||
InputManager.Instance.OnBackDash_Event -= OnBackDashInput;
|
||||
InputManager.Instance.OnGrabSmash_Event -= OnGrabSmashInput;
|
||||
}
|
||||
|
||||
_attackCts?.Cancel();
|
||||
_attackCts?.Dispose();
|
||||
_motionCts?.Cancel();
|
||||
_motionCts?.Dispose();
|
||||
_animationSpeedCts?.Cancel();
|
||||
_animationSpeedCts?.Dispose();
|
||||
CancelAndDispose(ref _attackCts);
|
||||
CancelAndDispose(ref _motionCts);
|
||||
CancelAndDispose(ref _animationSpeedCts);
|
||||
CancelAndDispose(ref _actionVelocityCts);
|
||||
|
||||
if (_attackHitbox != null)
|
||||
_attackHitbox.OnHit -= OnAttackHit;
|
||||
}
|
||||
|
||||
private void FixedUpdate()
|
||||
@@ -198,9 +206,13 @@ private void OnJumpInput()
|
||||
private void OnDashInput() => ExecuteMotionNode(_dashRootNode);
|
||||
private void OnRollInput() => ExecuteMotionNode(_rollRootNode);
|
||||
private void OnBackDashInput() => ExecuteMotionNode(_backDashRootNode);
|
||||
private void OnGrabSmashInput() => ExecuteAttackNode(_grabSmashRootNode);
|
||||
|
||||
private void HandleComboInput(ComboInputType input)
|
||||
{
|
||||
if (_isMotionActive && !CanTransitionFromCurrentNode(input))
|
||||
return;
|
||||
|
||||
if (_attackCooldownTimer > 0f)
|
||||
{
|
||||
float elapsed = Time.time - _attackStartTime;
|
||||
@@ -257,6 +269,16 @@ private void ExecuteMotionNode(ComboNode root)
|
||||
_comboWindowTimer = root.ComboWindow;
|
||||
}
|
||||
|
||||
private void ExecuteAttackNode(ComboNode root)
|
||||
{
|
||||
if (root == null || root.Action == null) return;
|
||||
if (_attackCooldownTimer > 0f) return;
|
||||
|
||||
PerformAttack(root.Action);
|
||||
_currentNode = root;
|
||||
_comboWindowTimer = root.ComboWindow;
|
||||
}
|
||||
|
||||
private void UpdateMotionCooldowns()
|
||||
{
|
||||
if (_motionCooldownTimers.Count == 0) return;
|
||||
@@ -288,8 +310,8 @@ private void SetMotionCooldown(ActionData data)
|
||||
|
||||
private async void PerformAttack(ActionData data, bool preserveHorizontalVelocity = false)
|
||||
{
|
||||
_attackCts?.Cancel();
|
||||
_attackCts?.Dispose();
|
||||
CancelMotion();
|
||||
CancelAndDispose(ref _attackCts);
|
||||
_attackCts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
|
||||
CancellationToken token = _attackCts.Token;
|
||||
|
||||
@@ -301,15 +323,17 @@ private async void PerformAttack(ActionData data, bool preserveHorizontalVelocit
|
||||
ClearActionLocks();
|
||||
PlayActionAnimation(data);
|
||||
|
||||
if (data.HasMotion)
|
||||
ApplyActionVelocity(data);
|
||||
PlayActionVelocity(data);
|
||||
|
||||
LockMovementIfNeeded(data, preserveHorizontalVelocity, true);
|
||||
LockFacingIfNeeded(data);
|
||||
|
||||
try
|
||||
{
|
||||
await HitRoutine(data, token);
|
||||
if (data.IsGrab)
|
||||
await GrabRoutine(data, token);
|
||||
else
|
||||
await HitRoutine(data, token);
|
||||
}
|
||||
catch (System.OperationCanceledException)
|
||||
{
|
||||
@@ -323,15 +347,16 @@ private async void PerformMotion(ActionData data)
|
||||
|
||||
// 대시/구르기 같은 모션은 공격을 끊고 새로운 콤보 노드가 된다.
|
||||
CancelAttack();
|
||||
_motionCts?.Cancel();
|
||||
_motionCts?.Dispose();
|
||||
CancelAndDispose(ref _motionCts);
|
||||
_motionCts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
|
||||
CancellationToken token = _motionCts.Token;
|
||||
|
||||
SetMotionCooldown(data);
|
||||
_isMotionActive = true;
|
||||
ClearActionLocks();
|
||||
FaceMotionDirection(data);
|
||||
PlayActionAnimation(data);
|
||||
PlayActionVelocity(data);
|
||||
LockMovementIfNeeded(data);
|
||||
LockFacingIfNeeded(data);
|
||||
|
||||
@@ -341,11 +366,15 @@ private async void PerformMotion(ActionData data)
|
||||
await MotionRoutine(data, token);
|
||||
completed = true;
|
||||
}
|
||||
catch (System.OperationCanceledException) { }
|
||||
catch (System.OperationCanceledException)
|
||||
{
|
||||
_isMotionActive = false;
|
||||
}
|
||||
|
||||
if (completed)
|
||||
{
|
||||
StopActionVelocity(data);
|
||||
_isMotionActive = false;
|
||||
ClearActionLocks();
|
||||
PlayIdleAnimation();
|
||||
}
|
||||
@@ -359,9 +388,6 @@ private async Awaitable MotionRoutine(ActionData data, CancellationToken token)
|
||||
while (ShouldKeepActionPlaying(data, elapsed, duration))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
float normalizedTime = GetActionNormalizedTime(data, elapsed, duration);
|
||||
ApplyActionVelocity(data, normalizedTime);
|
||||
|
||||
await Awaitable.NextFrameAsync(token);
|
||||
elapsed += Time.deltaTime;
|
||||
}
|
||||
@@ -369,13 +395,34 @@ private async Awaitable MotionRoutine(ActionData data, CancellationToken token)
|
||||
|
||||
private void CancelAttack()
|
||||
{
|
||||
_attackCts?.Cancel();
|
||||
CancelAndDispose(ref _attackCts);
|
||||
CancelAndDispose(ref _actionVelocityCts);
|
||||
_attackCooldownTimer = 0f;
|
||||
_pendingInput = null;
|
||||
_attackHitbox?.Deactivate();
|
||||
ClearActionLocks();
|
||||
}
|
||||
|
||||
private void CancelMotion()
|
||||
{
|
||||
_isMotionActive = false;
|
||||
CancelAndDispose(ref _motionCts);
|
||||
CancelAndDispose(ref _actionVelocityCts);
|
||||
}
|
||||
|
||||
private bool CanTransitionFromCurrentNode(ComboInputType input)
|
||||
{
|
||||
if (_comboWindowTimer <= 0f || _currentNode == null) return false;
|
||||
|
||||
foreach (var transition in _currentNode.Transitions)
|
||||
{
|
||||
if (transition.Trigger == input && transition.Next != null && transition.Next.Action != null)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Awaitable HitRoutine(ActionData data, CancellationToken token)
|
||||
{
|
||||
float attackStartTime = Time.time;
|
||||
@@ -402,6 +449,101 @@ private async Awaitable HitRoutine(ActionData data, CancellationToken token)
|
||||
PlayIdleAnimation();
|
||||
}
|
||||
|
||||
private async Awaitable GrabRoutine(ActionData data, CancellationToken token)
|
||||
{
|
||||
Enemy target = FindGrabTarget(data);
|
||||
if (target == null)
|
||||
{
|
||||
await WaitForActionEnd(data, token);
|
||||
ClearActionLocks();
|
||||
PlayIdleAnimation();
|
||||
return;
|
||||
}
|
||||
|
||||
target.BeginGrab(data.GrabbedAnimationState, _groundLayer.value);
|
||||
|
||||
try
|
||||
{
|
||||
float elapsed = 0f;
|
||||
float duration = GetActionDuration(data);
|
||||
while (ShouldKeepActionPlaying(data, elapsed, duration))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
float normalizedTime = GetActionNormalizedTime(data, elapsed, duration);
|
||||
target.UpdateGrabPosition(GetGrabWorldPosition(data, normalizedTime));
|
||||
|
||||
await Awaitable.NextFrameAsync(token);
|
||||
elapsed += Time.deltaTime;
|
||||
}
|
||||
|
||||
target.EndGrab();
|
||||
target.TakeDamage(data.Damage, GetHitVelocity(data.HitVelocity), data.HitReactionAnimationState);
|
||||
}
|
||||
catch (System.OperationCanceledException)
|
||||
{
|
||||
target.EndGrab();
|
||||
throw;
|
||||
}
|
||||
|
||||
ClearActionLocks();
|
||||
PlayIdleAnimation();
|
||||
}
|
||||
|
||||
private Enemy FindGrabTarget(ActionData data)
|
||||
{
|
||||
Vector2 playerPosition = _rb != null ? _rb.position : (Vector2)transform.position;
|
||||
float grabRangeSqr = data.GrabRange * data.GrabRange;
|
||||
|
||||
if (_lastHitEnemy != null && _lastHitEnemy.isActiveAndEnabled &&
|
||||
GetEnemySqrDistance(_lastHitEnemy, playerPosition) <= grabRangeSqr)
|
||||
{
|
||||
return _lastHitEnemy;
|
||||
}
|
||||
|
||||
Collider2D[] hits = Physics2D.OverlapCircleAll(transform.position, data.GrabSearchRadius, _enemyLayer);
|
||||
Enemy closest = null;
|
||||
float closestSqrDistance = float.PositiveInfinity;
|
||||
|
||||
foreach (var hit in hits)
|
||||
{
|
||||
Enemy enemy = hit.GetComponentInParent<Enemy>();
|
||||
if (enemy == null) continue;
|
||||
|
||||
float sqrDistance = GetEnemySqrDistance(enemy, playerPosition);
|
||||
if (sqrDistance > grabRangeSqr) continue;
|
||||
if (sqrDistance >= closestSqrDistance) continue;
|
||||
|
||||
closest = enemy;
|
||||
closestSqrDistance = sqrDistance;
|
||||
}
|
||||
|
||||
return closest;
|
||||
}
|
||||
|
||||
private float GetEnemySqrDistance(Enemy enemy, Vector2 playerPosition)
|
||||
{
|
||||
if (enemy == null) return float.PositiveInfinity;
|
||||
return ((Vector2)enemy.transform.position - playerPosition).sqrMagnitude;
|
||||
}
|
||||
|
||||
private Vector2 GetGrabWorldPosition(ActionData data, float normalizedTime)
|
||||
{
|
||||
float facing = _spriteRenderer != null && _spriteRenderer.flipX ? -1f : 1f;
|
||||
Vector2 playerPosition = _rb != null ? _rb.position : (Vector2)transform.position;
|
||||
Vector2 offset = data.GrabOffset;
|
||||
float offsetXMultiplier = data.GrabOffsetXCurve != null ? data.GrabOffsetXCurve.Evaluate(normalizedTime) : 1f;
|
||||
float offsetYMultiplier = data.GrabOffsetYCurve != null ? data.GrabOffsetYCurve.Evaluate(normalizedTime) : 1f;
|
||||
offset.x *= facing * offsetXMultiplier;
|
||||
offset.y *= offsetYMultiplier;
|
||||
return playerPosition + offset;
|
||||
}
|
||||
|
||||
private void OnAttackHit(IDamageable target)
|
||||
{
|
||||
if (target is Enemy enemy)
|
||||
_lastHitEnemy = enemy;
|
||||
}
|
||||
|
||||
private void ActivateAttackHitbox(ActionData data)
|
||||
{
|
||||
Vector2 localPosition = GetAttackLocalPosition(data.Offset);
|
||||
@@ -475,7 +617,7 @@ private void LockMovementIfNeeded(ActionData data, bool preserveHorizontalVeloci
|
||||
if (!forceLock && data.CanMoveDuringAction) return;
|
||||
|
||||
// 이동 없는 공격은 직전 프레임의 걷기 속도를 이어받지 않게 한다.
|
||||
if (!preserveHorizontalVelocity && !data.HasMotion)
|
||||
if (!preserveHorizontalVelocity && data.Velocity == Vector2.zero)
|
||||
_rb.linearVelocity = new Vector2(0f, _rb.linearVelocity.y);
|
||||
|
||||
_inputLockTimer = GetActionLockDuration(data);
|
||||
@@ -493,7 +635,8 @@ private void PlayIdleAnimation()
|
||||
{
|
||||
if (_anim == null) return;
|
||||
|
||||
_animationSpeedCts?.Cancel();
|
||||
CancelAndDispose(ref _animationSpeedCts);
|
||||
CancelAndDispose(ref _actionVelocityCts);
|
||||
_anim.speed = 1f;
|
||||
if (!string.IsNullOrEmpty(_idleAnimationState))
|
||||
_anim.Play(_idleAnimationState);
|
||||
@@ -503,8 +646,7 @@ private void PlayActionAnimation(ActionData data)
|
||||
{
|
||||
if (_anim == null) return;
|
||||
|
||||
_animationSpeedCts?.Cancel();
|
||||
_animationSpeedCts?.Dispose();
|
||||
CancelAndDispose(ref _animationSpeedCts);
|
||||
_animationSpeedCts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
|
||||
|
||||
_anim.speed = GetAnimationSpeed(data, 0f);
|
||||
@@ -517,6 +659,36 @@ private void PlayActionAnimation(ActionData data)
|
||||
ApplyAnimationSpeedCurve(data, _animationSpeedCts.Token);
|
||||
}
|
||||
|
||||
private void PlayActionVelocity(ActionData data)
|
||||
{
|
||||
CancelAndDispose(ref _actionVelocityCts);
|
||||
|
||||
if (data.Velocity == Vector2.zero) return;
|
||||
|
||||
_actionVelocityCts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
|
||||
ApplyActionVelocityCurve(data, _actionVelocityCts.Token);
|
||||
}
|
||||
|
||||
private async void ApplyActionVelocityCurve(ActionData data, CancellationToken token)
|
||||
{
|
||||
float elapsed = 0f;
|
||||
float duration = GetActionDuration(data);
|
||||
|
||||
try
|
||||
{
|
||||
while (ShouldKeepActionPlaying(data, elapsed, duration))
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
float normalizedTime = GetActionNormalizedTime(data, elapsed, duration);
|
||||
ApplyActionVelocity(data, normalizedTime);
|
||||
|
||||
await Awaitable.NextFrameAsync(token);
|
||||
elapsed += Time.deltaTime;
|
||||
}
|
||||
}
|
||||
catch (System.OperationCanceledException) { }
|
||||
}
|
||||
|
||||
private async void ApplyAnimationSpeedCurve(ActionData data, CancellationToken token)
|
||||
{
|
||||
float duration = GetActionDuration(data);
|
||||
@@ -702,6 +874,20 @@ private void ClearActionLocks()
|
||||
_facingLockAction = null;
|
||||
}
|
||||
|
||||
private void CancelAndDispose(ref CancellationTokenSource cts)
|
||||
{
|
||||
if (cts == null) return;
|
||||
|
||||
try
|
||||
{
|
||||
cts.Cancel();
|
||||
}
|
||||
catch (System.ObjectDisposedException) { }
|
||||
|
||||
cts.Dispose();
|
||||
cts = null;
|
||||
}
|
||||
|
||||
private float GetActionDuration(ActionData data)
|
||||
{
|
||||
return Mathf.Max(data.MotionDuration, data.HitTiming + data.HitDuration, 0.01f);
|
||||
@@ -722,7 +908,7 @@ private bool ShouldKeepActionPlaying(ActionData data, float elapsed, float durat
|
||||
|
||||
private float GetActionNormalizedTime(ActionData data, float elapsed, float duration)
|
||||
{
|
||||
if (!HasActionAnimation(data))
|
||||
if (!ShouldUseAnimationLength(data))
|
||||
return Mathf.Clamp01(elapsed / duration);
|
||||
|
||||
AnimatorStateInfo stateInfo = _anim.GetCurrentAnimatorStateInfo(0);
|
||||
|
||||
Reference in New Issue
Block a user