2026-05-18 애니메이션 설계 수정

This commit is contained in:
2026-05-18 12:44:09 +09:00
parent adcd69c537
commit f2445c30c4
30 changed files with 476 additions and 56 deletions

View File

@@ -28,10 +28,13 @@ public class PlayerController : MonoBehaviour
private int _wallDirection;
private float _inputLockTimer;
private float _facingLockTimer;
private ActionData _movementLockAction;
private ActionData _facingLockAction;
[Header("Motion")]
[SerializeField] private ComboNode _dashRootNode;
[SerializeField] private ComboNode _rollRootNode;
[SerializeField] private ComboNode _backDashRootNode;
private readonly Dictionary<ActionData, float> _motionCooldownTimers = new();
private readonly List<ActionData> _motionCooldownKeys = new();
@@ -90,6 +93,7 @@ private void Start()
InputManager.Instance.OnKick_Event += OnKickInput;
InputManager.Instance.OnDash_Event += OnDashInput;
InputManager.Instance.OnRoll_Event += OnRollInput;
InputManager.Instance.OnBackDash_Event += OnBackDashInput;
}
private void OnDestroy()
@@ -102,6 +106,7 @@ private void OnDestroy()
InputManager.Instance.OnKick_Event -= OnKickInput;
InputManager.Instance.OnDash_Event -= OnDashInput;
InputManager.Instance.OnRoll_Event -= OnRollInput;
InputManager.Instance.OnBackDash_Event -= OnBackDashInput;
}
_attackCts?.Cancel();
@@ -127,14 +132,10 @@ private void FixedUpdate()
ExecuteBufferedInputIfReady();
TickComboWindow();
if (_inputLockTimer > 0f)
_inputLockTimer -= Time.fixedDeltaTime;
else
if (!IsMovementLocked())
_rb.linearVelocity = new Vector2(_moveInputX * _moveSpeed, _rb.linearVelocity.y);
if (_facingLockTimer > 0f)
_facingLockTimer -= Time.fixedDeltaTime;
else
if (!IsFacingLocked())
UpdateFacingFromMoveInput();
ApplyGravity();
@@ -196,6 +197,7 @@ private void OnJumpInput()
private void OnKickInput() => HandleComboInput(ComboInputType.Kick);
private void OnDashInput() => ExecuteMotionNode(_dashRootNode);
private void OnRollInput() => ExecuteMotionNode(_rollRootNode);
private void OnBackDashInput() => ExecuteMotionNode(_backDashRootNode);
private void HandleComboInput(ComboInputType input)
{
@@ -296,12 +298,13 @@ private async void PerformAttack(ActionData data, bool preserveHorizontalVelocit
_attackStartTime = Time.time;
_hitFired = false;
ClearActionLocks();
PlayActionAnimation(data);
if (data.HasMotion)
ApplyActionVelocity(data);
LockMovementIfNeeded(data, preserveHorizontalVelocity);
LockMovementIfNeeded(data, preserveHorizontalVelocity, true);
LockFacingIfNeeded(data);
try
@@ -326,6 +329,7 @@ private async void PerformMotion(ActionData data)
CancellationToken token = _motionCts.Token;
SetMotionCooldown(data);
ClearActionLocks();
FaceMotionDirection(data);
PlayActionAnimation(data);
LockMovementIfNeeded(data);
@@ -342,6 +346,7 @@ private async void PerformMotion(ActionData data)
if (completed)
{
StopActionVelocity(data);
ClearActionLocks();
PlayIdleAnimation();
}
}
@@ -349,17 +354,14 @@ private async void PerformMotion(ActionData data)
private async Awaitable MotionRoutine(ActionData data, CancellationToken token)
{
float elapsed = 0f;
float duration = Mathf.Max(data.MotionDuration, 0.01f);
float duration = GetActionDuration(data);
while (elapsed < duration)
while (ShouldKeepActionPlaying(data, elapsed, duration))
{
token.ThrowIfCancellationRequested();
float normalizedTime = Mathf.Clamp01(elapsed / duration);
float normalizedTime = GetActionNormalizedTime(data, elapsed, duration);
ApplyActionVelocity(data, normalizedTime);
if (data.ReturnToIdleOnAnimationComplete && IsActionAnimationComplete(data))
return;
await Awaitable.NextFrameAsync(token);
elapsed += Time.deltaTime;
}
@@ -371,6 +373,7 @@ private void CancelAttack()
_attackCooldownTimer = 0f;
_pendingInput = null;
_attackHitbox?.Deactivate();
ClearActionLocks();
}
private async Awaitable HitRoutine(ActionData data, CancellationToken token)
@@ -379,8 +382,7 @@ private async Awaitable HitRoutine(ActionData data, CancellationToken token)
if (!data.HasHit)
{
if (data.MotionDuration > 0f)
await Awaitable.WaitForSecondsAsync(data.MotionDuration, token);
await WaitForActionEnd(data, token);
return;
}
@@ -394,10 +396,9 @@ private async Awaitable HitRoutine(ActionData data, CancellationToken token)
await Awaitable.WaitForSecondsAsync(activeTime, token);
_attackHitbox.Deactivate();
float remaining = data.MotionDuration - (Time.time - attackStartTime);
if (remaining > 0f)
await Awaitable.WaitForSecondsAsync(remaining, token);
await WaitForActionEnd(data, token, Time.time - attackStartTime);
ClearActionLocks();
PlayIdleAnimation();
}
@@ -405,13 +406,15 @@ private void ActivateAttackHitbox(ActionData data)
{
Vector2 localPosition = GetAttackLocalPosition(data.Offset);
Vector2 hitVelocity = GetHitVelocity(data.HitVelocity);
Vector2? hitTargetPosition = GetHitTargetPosition(data);
_lastHitData = data;
_lastHitCenter = transform.TransformPoint(localPosition);
_lastHitTime = Time.time;
_hitFired = true;
_attackHitbox.Activate(data, localPosition, hitVelocity, _enemyLayer);
Vector2 sourcePosition = _rb != null ? _rb.position : (Vector2)transform.position;
_attackHitbox.Activate(data, localPosition, hitVelocity, sourcePosition, hitTargetPosition, data.CorrectHitTargetY, _groundLayer.value, _enemyLayer);
}
private void EnsureAttackHitbox()
@@ -467,21 +470,23 @@ private void StopActionVelocity(ActionData data)
_rb.linearVelocity = new Vector2(0f, _rb.linearVelocity.y);
}
private void LockMovementIfNeeded(ActionData data, bool preserveHorizontalVelocity = false)
private void LockMovementIfNeeded(ActionData data, bool preserveHorizontalVelocity = false, bool forceLock = false)
{
if (data.CanMoveDuringAction) return;
if (!forceLock && data.CanMoveDuringAction) return;
// 이동 없는 공격은 직전 프레임의 걷기 속도를 이어받지 않게 한다.
if (!preserveHorizontalVelocity && !data.HasMotion)
_rb.linearVelocity = new Vector2(0f, _rb.linearVelocity.y);
_inputLockTimer = Mathf.Max(data.MotionDuration, 0.02f);
_inputLockTimer = GetActionLockDuration(data);
_movementLockAction = ShouldUseAnimationLength(data) ? data : null;
}
private void LockFacingIfNeeded(ActionData data)
{
if (data.CanTurnDuringAction) return;
_facingLockTimer = Mathf.Max(data.MotionDuration, 0.02f);
_facingLockTimer = GetActionLockDuration(data);
_facingLockAction = ShouldUseAnimationLength(data) ? data : null;
}
private void PlayIdleAnimation()
@@ -504,22 +509,25 @@ private void PlayActionAnimation(ActionData data)
_anim.speed = GetAnimationSpeed(data, 0f);
if (!string.IsNullOrEmpty(data.AnimationState))
{
_anim.Play(data.AnimationState);
_anim.Update(0f);
}
ApplyAnimationSpeedCurve(data, _animationSpeedCts.Token);
}
private async void ApplyAnimationSpeedCurve(ActionData data, CancellationToken token)
{
float duration = Mathf.Max(data.MotionDuration, data.HitTiming + data.HitDuration, 0.01f);
float duration = GetActionDuration(data);
float elapsed = 0f;
try
{
while (elapsed < duration)
while (ShouldKeepActionPlaying(data, elapsed, duration))
{
token.ThrowIfCancellationRequested();
float normalizedTime = Mathf.Clamp01(elapsed / duration);
float normalizedTime = GetActionNormalizedTime(data, elapsed, duration);
_anim.speed = GetAnimationSpeed(data, normalizedTime);
await Awaitable.NextFrameAsync(token);
@@ -569,6 +577,17 @@ private Vector2 GetAttackLocalPosition(Vector2 offset)
return offset;
}
private Vector2? GetHitTargetPosition(ActionData data)
{
if (!data.UseHitPositionCorrection) return null;
float facing = _spriteRenderer != null && _spriteRenderer.flipX ? -1f : 1f;
Vector2 playerPosition = _rb != null ? _rb.position : (Vector2)transform.position;
Vector2 offset = data.HitTargetOffset;
offset.x *= facing;
return playerPosition + offset;
}
private string GetActionName(ActionData data)
{
if (data == null) return string.Empty;
@@ -585,16 +604,151 @@ private void ApplyForwardStep(float distance, float duration)
_rb.linearVelocity = new Vector2(vx, _rb.linearVelocity.y);
_inputLockTimer = safeDuration;
_movementLockAction = null;
}
private bool IsActionAnimationComplete(ActionData data)
{
if (_anim == null || string.IsNullOrEmpty(data.AnimationState)) return false;
if (!HasActionAnimation(data)) return false;
AnimatorStateInfo stateInfo = _anim.GetCurrentAnimatorStateInfo(0);
return stateInfo.IsName(data.AnimationState) && stateInfo.normalizedTime >= 1f;
}
private bool IsActionAnimationLooping(ActionData data)
{
if (!HasActionAnimation(data)) return false;
AnimatorStateInfo stateInfo = _anim.GetCurrentAnimatorStateInfo(0);
if (!stateInfo.IsName(data.AnimationState)) return false;
if (stateInfo.loop) return true;
AnimatorClipInfo[] clipInfos = _anim.GetCurrentAnimatorClipInfo(0);
for (int i = 0; i < clipInfos.Length; i++)
{
AnimationClip clip = clipInfos[i].clip;
if (clip != null && clip.isLooping)
return true;
}
return false;
}
private bool HasActionAnimation(ActionData data)
{
return _anim != null && data != null && !string.IsNullOrEmpty(data.AnimationState);
}
private bool ShouldUseAnimationLength(ActionData data)
{
return HasActionAnimation(data) && !IsActionAnimationLooping(data);
}
private bool IsMovementLocked()
{
return IsTimerOrActionLockActive(ref _inputLockTimer, ref _movementLockAction);
}
private bool IsFacingLocked()
{
return IsTimerOrActionLockActive(ref _facingLockTimer, ref _facingLockAction);
}
private bool IsTimerOrActionLockActive(ref float timer, ref ActionData action)
{
if (timer > 0f)
{
timer -= Time.fixedDeltaTime;
return true;
}
if (action == null)
return false;
if (!HasActionAnimation(action))
{
action = null;
return false;
}
if (!IsActionAnimationActive(action))
{
action = null;
return false;
}
if (IsActionAnimationComplete(action))
{
action = null;
return false;
}
return true;
}
private bool IsActionAnimationActive(ActionData data)
{
if (!HasActionAnimation(data)) return false;
AnimatorStateInfo stateInfo = _anim.GetCurrentAnimatorStateInfo(0);
return stateInfo.IsName(data.AnimationState);
}
private void ClearActionLocks()
{
_inputLockTimer = 0f;
_facingLockTimer = 0f;
_movementLockAction = null;
_facingLockAction = null;
}
private float GetActionDuration(ActionData data)
{
return Mathf.Max(data.MotionDuration, data.HitTiming + data.HitDuration, 0.01f);
}
private float GetActionLockDuration(ActionData data)
{
return ShouldUseAnimationLength(data) ? 0f : Mathf.Max(data.MotionDuration, 0.02f);
}
private bool ShouldKeepActionPlaying(ActionData data, float elapsed, float duration)
{
if (!ShouldUseAnimationLength(data))
return elapsed < duration;
return !IsActionAnimationComplete(data);
}
private float GetActionNormalizedTime(ActionData data, float elapsed, float duration)
{
if (!HasActionAnimation(data))
return Mathf.Clamp01(elapsed / duration);
AnimatorStateInfo stateInfo = _anim.GetCurrentAnimatorStateInfo(0);
if (!stateInfo.IsName(data.AnimationState))
return 0f;
return Mathf.Clamp01(stateInfo.normalizedTime);
}
private async Awaitable WaitForActionEnd(ActionData data, CancellationToken token, float alreadyElapsed = 0f)
{
if (!ShouldUseAnimationLength(data))
{
float remaining = GetActionDuration(data) - alreadyElapsed;
if (remaining > 0f)
await Awaitable.WaitForSecondsAsync(remaining, token);
return;
}
while (!IsActionAnimationComplete(data))
{
token.ThrowIfCancellationRequested();
await Awaitable.NextFrameAsync(token);
}
}
private void ApplyGravity()
{
float vy = _rb.linearVelocity.y;