Files
WhiteMan_Unity2D/Assets/02_Scripts/Player/PlayerController.cs

1347 lines
45 KiB
C#

using System.Collections.Generic;
using System.Threading;
using UnityEngine;
public class PlayerController : MonoBehaviour
{
[Header("Movement")]
[SerializeField] private float _moveSpeed = 5f;
[SerializeField] private string _walkAnimationState = "Run";
private float _moveInputX = 0f;
private string _activeBaseState;
private bool _isInActionAnimation;
[Header("Jump Animation")]
[SerializeField] private string _jumpRiseAnimationState = "JumpRise";
[SerializeField] private string _jumpMidAnimationState = "JumpMid";
[SerializeField] private string _jumpFallAnimationState = "JumpFall";
[SerializeField] private string _landAnimationState = "Land";
[SerializeField] private float _jumpMidThreshold = 2f;
[SerializeField] private float _landAnimationDuration = 0.15f;
private bool _wasGroundedLastFrame = true;
private float _landTimer;
[Header("Jump")]
[SerializeField] private float _jumpForce = 8f;
[SerializeField] private Transform _groundCheck;
[SerializeField] private float _groundCheckRadius = 0.1f;
[SerializeField] private LayerMask _groundLayer;
private bool _isGrounded;
[Header("WallSlide")]
[SerializeField] private Transform _wallCheckLeft;
[SerializeField] private Transform _wallCheckRight;
[SerializeField] private float _wallCheckRadius = 0.1f;
[SerializeField] private float _wallSlideSpeed = 2f;
[SerializeField] private Vector2 _wallJumpForce = new Vector2(4f, 5f);
[SerializeField] private float _wallJumpInputLockDuration = 0.15f;
private bool _isTouchingLeftWall;
private bool _isTouchingRightWall;
private bool IsTouchingWall => _isTouchingLeftWall || _isTouchingRightWall;
private int _wallDirection;
private float _inputLockTimer;
private float _facingLockTimer;
private ActionData _movementLockAction;
private ActionData _facingLockAction;
[Header("Ground Pound")]
[SerializeField] private ActionData _groundPoundData;
[SerializeField] private string _groundPoundFallAnimationState = "GroundSlamLoop";
[SerializeField] private string _groundPoundImpactAnimationState = "GroundSlamEnd";
[SerializeField] private float _groundPoundWindupDuration = 0.15f;
[SerializeField] private float _groundPoundFallSpeed = 25f;
private bool _isGroundPounding;
[Header("Motion")]
[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();
[Header("Kinematic Physics")]
[SerializeField] private float _gravity = -25f;
[SerializeField] private float _maxFallSpeed = 20f;
[SerializeField] private float _skinWidth = 0.02f;
[Header("Attack")]
[SerializeField] private ComboNode _punchRootNode;
[SerializeField] private ComboNode _kickRootNode;
[SerializeField] private LayerMask _enemyLayer;
[SerializeField] private AttackHitbox _attackHitbox;
[SerializeField] private string _idleAnimationState = "Idle";
[SerializeField] private float _bufferOpenTime = 0.1f;
[SerializeField] private float _bufferLifetime = 0.5f;
private ComboInputType? _pendingInput;
private float _pendingInputTime = -1f;
private float _attackCooldownTimer;
private ComboNode _currentNode;
private float _comboWindowTimer;
private CancellationTokenSource _attackCts;
private CancellationTokenSource _motionCts;
private CancellationTokenSource _animationSpeedCts;
private CancellationTokenSource _actionVelocityCts;
private bool _isAttackActive;
private bool _isMotionActive;
private float _actionDirection = 1f;
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;
private float _attackStartTime = -1f;
private bool _hitFired;
private readonly List<RaycastHit2D> _castResults = new();
private Collider2D[] _bodyColliders;
private Rigidbody2D _rb;
private Animator _anim;
private SpriteRenderer _spriteRenderer;
private void Awake()
{
_rb = GetComponent<Rigidbody2D>();
_anim = GetComponent<Animator>();
_spriteRenderer = GetComponentInChildren<SpriteRenderer>();
_bodyColliders = GetComponentsInChildren<Collider2D>();
EnsureAttackHitbox();
_attackHitbox.OnHit += OnAttackHit;
}
private void Start()
{
InputManager.Instance.OnMove_Event += OnMoveInput;
InputManager.Instance.OnJump_Event += OnJumpInput;
InputManager.Instance.OnPunch_Event += OnPunchInput;
InputManager.Instance.OnKick_Event += OnKickInput;
InputManager.Instance.OnDash_Event += OnDashInput;
InputManager.Instance.OnRoll_Event += OnRollInput;
InputManager.Instance.OnBackDash_Event += OnBackDashInput;
InputManager.Instance.OnGrabSmash_Event += OnGrabSmashInput;
}
private void OnDestroy()
{
if (InputManager.Instance != null)
{
InputManager.Instance.OnMove_Event -= OnMoveInput;
InputManager.Instance.OnJump_Event -= OnJumpInput;
InputManager.Instance.OnPunch_Event -= OnPunchInput;
InputManager.Instance.OnKick_Event -= OnKickInput;
InputManager.Instance.OnDash_Event -= OnDashInput;
InputManager.Instance.OnRoll_Event -= OnRollInput;
InputManager.Instance.OnBackDash_Event -= OnBackDashInput;
InputManager.Instance.OnGrabSmash_Event -= OnGrabSmashInput;
}
CancelAndDispose(ref _attackCts);
CancelAndDispose(ref _motionCts);
CancelAndDispose(ref _animationSpeedCts);
CancelAndDispose(ref _actionVelocityCts);
if (_attackHitbox != null)
_attackHitbox.OnHit -= OnAttackHit;
}
private void FixedUpdate()
{
// 이동, 벽타기, 점프 판단이 모두 이 값을 쓰므로 충돌 체크를 먼저 갱신한다.
_isGrounded = Physics2D.OverlapCircle(_groundCheck.position, _groundCheckRadius, _groundLayer);
_isTouchingLeftWall = Physics2D.OverlapCircle(_wallCheckLeft.position, _wallCheckRadius, _groundLayer);
_isTouchingRightWall = Physics2D.OverlapCircle(_wallCheckRight.position, _wallCheckRadius, _groundLayer);
_wallDirection = _isTouchingRightWall ? 1 : (_isTouchingLeftWall ? -1 : 0);
if (_attackCooldownTimer > 0f)
_attackCooldownTimer -= Time.fixedDeltaTime;
UpdateMotionCooldowns();
ExecuteBufferedInputIfReady();
TickComboWindow();
if (!IsMovementLocked() && !IsActionActive())
_rb.linearVelocity = new Vector2(_moveInputX * _moveSpeed, _rb.linearVelocity.y);
if (!IsFacingLocked() && !IsActionActive())
UpdateFacingFromMoveInput();
ApplyGravity();
if (IsTouchingWall && !_isGrounded && _rb.linearVelocity.y < -_wallSlideSpeed)
_rb.linearVelocity = new Vector2(_rb.linearVelocity.x, -_wallSlideSpeed);
// 플레이어와 적 몸체는 물리 충돌하지 않고, 땅/벽만 캐스트로 이동을 막는다.
ClampVelocityToGround();
UpdateLocomotionAnimation();
}
private void ExecuteBufferedInputIfReady()
{
if (_attackCooldownTimer > 0f || !_pendingInput.HasValue) return;
ComboInputType buffered = _pendingInput.Value;
bool stillValid = Time.time - _pendingInputTime <= _bufferLifetime;
_pendingInput = null;
if (stillValid)
ExecuteComboInput(buffered);
}
private void TickComboWindow()
{
if (_comboWindowTimer <= 0f) return;
_comboWindowTimer -= Time.fixedDeltaTime;
if (_comboWindowTimer <= 0f)
_currentNode = null;
}
private void OnMoveInput(Vector2 value)
{
_moveInputX = value.x == 0f ? 0f : Mathf.Sign(value.x);
if (_facingLockTimer <= 0f && !IsActionActive())
UpdateFacingFromMoveInput();
}
private void UpdateFacingFromMoveInput()
{
if (_moveInputX != 0f && _spriteRenderer != null)
_spriteRenderer.flipX = _moveInputX < 0f;
}
private void OnJumpInput()
{
if (_isGrounded)
{
_rb.linearVelocity = new Vector2(_rb.linearVelocity.x, _jumpForce);
}
else if (IsTouchingWall)
{
_rb.linearVelocity = new Vector2(-_wallDirection * _wallJumpForce.x, _wallJumpForce.y);
_inputLockTimer = _wallJumpInputLockDuration;
}
}
private void OnPunchInput() => HandleComboInput(ComboInputType.Punch);
private void OnKickInput() => HandleComboInput(ComboInputType.Kick);
private void OnGrabSmashInput()
{
if (!_isGrounded && _groundPoundData != null)
{
PerformGroundPound();
return;
}
HandleComboInput(ComboInputType.Grab);
}
private void OnDashInput() => ExecuteMotionNode(_dashRootNode);
private void OnRollInput() => ExecuteMotionNode(_rollRootNode);
private void OnBackDashInput() => ExecuteMotionNode(_backDashRootNode);
private void HandleComboInput(ComboInputType input)
{
if (_isMotionActive && !CanTransitionFromCurrentNode(input))
return;
if (_attackCooldownTimer > 0f)
{
float elapsed = Time.time - _attackStartTime;
if (_lastAttackGizmoData != null && elapsed >= _bufferOpenTime)
{
_pendingInput = input;
_pendingInputTime = Time.time;
}
return;
}
ExecuteComboInput(input);
}
private void ExecuteComboInput(ComboInputType input)
{
// 콤보 입력 가능 시간이 열려 있으면 현재 노드에서 다음 연계로 이어간다.
if (_comboWindowTimer > 0f && _currentNode != null)
{
foreach (var transition in _currentNode.Transitions)
{
if (transition.Trigger != input) continue;
if (transition.Next == null || transition.Next.Action == null) continue;
if (!CanPerformAction(transition.Next.Action)) continue;
ApplyForwardStep(transition.ForwardStep, transition.ForwardStepDuration);
PerformAttack(transition.Next.Action, transition.ForwardStep > 0f);
_currentNode = transition.Next;
_comboWindowTimer = transition.Next.ComboWindow;
return;
}
}
// 이어갈 연계가 없으면 입력에 맞는 루트 노드부터 새로 시작한다.
ComboNode root = input switch
{
ComboInputType.Punch => _punchRootNode,
ComboInputType.Kick => _kickRootNode,
ComboInputType.Grab => _grabSmashRootNode,
_ => null
};
if (root == null || root.Action == null) return;
if (!CanPerformAction(root.Action)) return;
PerformAttack(root.Action);
_currentNode = root;
_comboWindowTimer = root.ComboWindow;
}
private bool CanPerformAction(ActionData data)
{
if (data == null) return false;
// 잡기 액션은 사정 거리 안에 적이 있어야만 실행. 없으면 입력 자체를 캔슬.
if (data.IsGrab && FindGrabTarget(data) == null) return false;
return true;
}
private void ExecuteMotionNode(ComboNode root)
{
if (root == null || root.Action == null) return;
if (IsMotionOnCooldown(root.Action)) return;
PerformMotion(root.Action);
_currentNode = root;
_comboWindowTimer = root.ComboWindow;
}
private void UpdateMotionCooldowns()
{
if (_motionCooldownTimers.Count == 0) return;
_motionCooldownKeys.Clear();
foreach (var pair in _motionCooldownTimers)
_motionCooldownKeys.Add(pair.Key);
foreach (var action in _motionCooldownKeys)
{
float remaining = _motionCooldownTimers[action] - Time.fixedDeltaTime;
if (remaining <= 0f)
_motionCooldownTimers.Remove(action);
else
_motionCooldownTimers[action] = remaining;
}
}
private bool IsMotionOnCooldown(ActionData data)
{
return data != null && _motionCooldownTimers.ContainsKey(data);
}
private void SetMotionCooldown(ActionData data)
{
if (data == null || data.Cooldown <= 0f) return;
_motionCooldownTimers[data] = data.Cooldown;
}
private async void PerformAttack(ActionData data, bool preserveHorizontalVelocity = false)
{
CancelMotion();
CancelAndDispose(ref _attackCts);
_attackCts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
CancellationTokenSource currentAttackCts = _attackCts;
CancellationToken token = _attackCts.Token;
_attackCooldownTimer = data.Cooldown;
_isAttackActive = true;
_lastAttackGizmoData = data;
_attackStartTime = Time.time;
_hitFired = false;
ClearActionLocks();
CaptureActionDirection(data);
PlayActionAnimation(data);
PlayActionVelocity(data);
LockMovementIfNeeded(data, preserveHorizontalVelocity, true);
LockFacingIfNeeded(data);
try
{
if (data.IsGrab)
await GrabRoutine(data, token);
else
await HitRoutine(data, token);
}
catch (System.OperationCanceledException)
{
_attackHitbox?.Deactivate();
}
if (_attackCts == currentAttackCts)
_isAttackActive = false;
}
private async void PerformMotion(ActionData data)
{
if (data == null || IsMotionOnCooldown(data)) return;
// 대시/구르기 같은 모션은 공격을 끊고 새로운 콤보 노드가 된다.
CancelAttack();
CancelAndDispose(ref _motionCts);
_motionCts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
CancellationToken token = _motionCts.Token;
SetMotionCooldown(data);
ClearActionLocks();
CaptureActionDirection(data);
_isMotionActive = true;
FaceMotionDirection(data);
PlayActionAnimation(data);
PlayActionVelocity(data);
LockMovementIfNeeded(data, false, true);
LockFacingIfNeeded(data);
bool completed = false;
try
{
await MotionRoutine(data, token);
completed = true;
}
catch (System.OperationCanceledException)
{
_isMotionActive = false;
}
if (completed)
{
StopActionVelocity(data);
_isMotionActive = false;
ClearActionLocks();
PlayIdleAnimation();
}
}
private async Awaitable MotionRoutine(ActionData data, CancellationToken token)
{
float elapsed = 0f;
float duration = GetActionDuration(data);
while (ShouldKeepActionPlaying(data, elapsed, duration))
{
token.ThrowIfCancellationRequested();
await Awaitable.NextFrameAsync(token);
elapsed += Time.deltaTime;
}
}
private async void PerformGroundPound()
{
if (_groundPoundData == null) return;
CancelAttack();
CancelAndDispose(ref _motionCts);
_motionCts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
CancellationToken token = _motionCts.Token;
_isMotionActive = true;
_isGroundPounding = true;
_attackStartTime = Time.time;
_lastAttackGizmoData = _groundPoundData;
_hitFired = false;
ClearActionLocks();
CaptureActionDirection(_groundPoundData);
FaceMotionDirection(_groundPoundData);
PlayActionAnimation(_groundPoundData);
LockMovementIfNeeded(_groundPoundData);
LockFacingIfNeeded(_groundPoundData);
float fallSpeed = _groundPoundFallSpeed > 0f
? _groundPoundFallSpeed
: Mathf.Max(Mathf.Abs(_groundPoundData.Velocity.y), 25f);
try
{
// 1) 윈드업: 짧게 공중 정지 후 슬램. ApplyGravity는 _isGroundPounding 플래그로 우회.
float windupElapsed = 0f;
while (windupElapsed < _groundPoundWindupDuration && !_isGrounded)
{
token.ThrowIfCancellationRequested();
_rb.linearVelocity = Vector2.zero;
windupElapsed += Time.deltaTime;
await Awaitable.NextFrameAsync(token);
}
// 2) 낙하 애니메이션으로 전환 (루프). 윈드업이 끝났을 때만 실행.
if (_anim != null && !string.IsNullOrEmpty(_groundPoundFallAnimationState) && !_isGrounded)
{
CancelAndDispose(ref _animationSpeedCts);
_anim.speed = 1f;
_anim.Play(_groundPoundFallAnimationState);
_anim.Update(0f);
}
// 3) 지면 닿을 때까지 강제 낙하.
while (!_isGrounded)
{
token.ThrowIfCancellationRequested();
_rb.linearVelocity = new Vector2(0f, -fallSpeed);
await Awaitable.NextFrameAsync(token);
}
// 4) 착지 순간 임팩트 애니메이션으로 전환 (낙하 거리 불일치 해소)
if (_anim != null && !string.IsNullOrEmpty(_groundPoundImpactAnimationState))
{
CancelAndDispose(ref _animationSpeedCts);
_anim.speed = 1f;
_anim.Play(_groundPoundImpactAnimationState);
_anim.Update(0f);
}
// 착지 임팩트
if (_groundPoundData.HasHit && _attackHitbox != null)
{
ActivateAttackHitbox(_groundPoundData);
float hitDuration = Mathf.Max(_groundPoundData.HitDuration, 0.05f);
await Awaitable.WaitForSecondsAsync(hitDuration, token);
_attackHitbox.Deactivate();
}
// 후딜
float recovery = Mathf.Max(_groundPoundData.MotionDuration, 0.15f);
await Awaitable.WaitForSecondsAsync(recovery, token);
PlayIdleAnimation();
}
catch (System.OperationCanceledException)
{
_attackHitbox?.Deactivate();
}
finally
{
_isGroundPounding = false;
_isMotionActive = false;
ClearActionLocks();
// 그라운드 파운드 직후 Land 애니메이션 중복 재생 방지
_wasGroundedLastFrame = _isGrounded;
}
}
private void CancelAttack()
{
_isAttackActive = false;
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 IsActionActive()
{
return _isAttackActive || _isMotionActive || _isGroundPounding;
}
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;
if (!data.HasHit)
{
await WaitForActionEnd(data, token);
return;
}
if (data.HitTiming > 0f)
await Awaitable.WaitForSecondsAsync(data.HitTiming, token);
// 타격 가능 시간은 범위 검사 대신 실제 트리거 콜라이더를 켜서 표현한다.
ActivateAttackHitbox(data);
float activeTime = Mathf.Max(data.HitDuration, 0.02f);
await Awaitable.WaitForSecondsAsync(activeTime, token);
_attackHitbox.Deactivate();
await WaitForActionEnd(data, token, Time.time - attackStartTime);
ClearActionLocks();
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);
Vector2 hitVelocity = GetHitVelocity(data.HitVelocity);
Vector2? hitTargetPosition = GetHitTargetPosition(data);
_lastHitData = data;
_lastHitCenter = transform.TransformPoint(localPosition);
_lastHitTime = Time.time;
_hitFired = true;
Debug.Log($"[Activate] t={Time.time:F3} action={GetActionName(data)} hitDuration={data.HitDuration:F3} motionActive={_isMotionActive} groundPounding={_isGroundPounding}");
Vector2 sourcePosition = _rb != null ? _rb.position : (Vector2)transform.position;
_attackHitbox.Activate(data, localPosition, hitVelocity, sourcePosition, hitTargetPosition, data.CorrectHitTargetY, _groundLayer.value, _enemyLayer);
}
private void EnsureAttackHitbox()
{
// 인스펙터에서 직접 넣을 수도 있고, 없으면 기본 공격 판정을 자동으로 만든다.
if (_attackHitbox != null)
{
SetAttackHitboxLayer();
return;
}
_attackHitbox = GetComponentInChildren<AttackHitbox>(true);
if (_attackHitbox != null)
{
SetAttackHitboxLayer();
return;
}
GameObject hitboxObject = new GameObject("AttackHitbox");
hitboxObject.transform.SetParent(transform, false);
hitboxObject.AddComponent<CircleCollider2D>();
_attackHitbox = hitboxObject.AddComponent<AttackHitbox>();
SetAttackHitboxLayer();
}
private void SetAttackHitboxLayer()
{
// 플레이어와 적의 물리 충돌이 꺼져 있으므로 공격 판정은 플레이어 레이어를 쓰면 안 된다.
int defaultLayer = LayerMask.NameToLayer("Default");
if (defaultLayer >= 0)
_attackHitbox.gameObject.layer = defaultLayer;
}
private void ApplyActionVelocity(ActionData data, float normalizedTime = 0f)
{
float direction = GetMotionDirection(data);
float speedMultiplier = data.MotionSpeedCurve != null
? data.MotionSpeedCurve.Evaluate(normalizedTime)
: 1f;
Vector2 velocity = data.Velocity * speedMultiplier;
velocity.x *= direction;
if (data.PreserveYVelocity)
velocity.y = _rb.linearVelocity.y;
_rb.linearVelocity = velocity;
}
private void StopActionVelocity(ActionData data)
{
if (!data.StopHorizontalVelocityOnEnd) return;
_rb.linearVelocity = new Vector2(0f, _rb.linearVelocity.y);
}
private void LockMovementIfNeeded(ActionData data, bool preserveHorizontalVelocity = false, bool forceLock = false)
{
if (!forceLock && data.CanMoveDuringAction) return;
// 이동 없는 공격은 직전 프레임의 걷기 속도를 이어받지 않게 한다.
if (!preserveHorizontalVelocity && data.Velocity == Vector2.zero)
_rb.linearVelocity = new Vector2(0f, _rb.linearVelocity.y);
if (forceLock)
{
// 액션 길이 전체를 보장: 애니메이션 길이와 무관하게 MotionDuration만큼 잠금.
_inputLockTimer = Mathf.Max(data.MotionDuration, 0.02f);
_movementLockAction = null;
}
else
{
_inputLockTimer = GetActionLockDuration(data);
_movementLockAction = ShouldUseAnimationLength(data) ? data : null;
}
}
private void LockFacingIfNeeded(ActionData data)
{
if (data.CanTurnDuringAction) return;
_facingLockTimer = GetActionLockDuration(data);
_facingLockAction = ShouldUseAnimationLength(data) ? data : null;
}
private void PlayIdleAnimation()
{
if (_anim == null) return;
CancelAndDispose(ref _animationSpeedCts);
CancelAndDispose(ref _actionVelocityCts);
_anim.speed = 1f;
_isInActionAnimation = false;
_activeBaseState = null;
PlayLocomotionState();
}
private void PlayLocomotionState()
{
if (_anim == null) return;
string desired = ChooseLocomotionState();
if (string.IsNullOrEmpty(desired)) return;
if (desired == _activeBaseState) return;
_anim.Play(desired);
_activeBaseState = desired;
}
private string ChooseLocomotionState()
{
if (_landTimer > 0f && !string.IsNullOrEmpty(_landAnimationState))
return _landAnimationState;
float vy = _rb.linearVelocity.y;
bool inAir = !_isGrounded || vy > _jumpMidThreshold;
if (inAir)
{
if (vy > _jumpMidThreshold && !string.IsNullOrEmpty(_jumpRiseAnimationState))
return _jumpRiseAnimationState;
if (vy < -_jumpMidThreshold && !string.IsNullOrEmpty(_jumpFallAnimationState))
return _jumpFallAnimationState;
if (!string.IsNullOrEmpty(_jumpMidAnimationState))
return _jumpMidAnimationState;
}
if (_moveInputX != 0f && !string.IsNullOrEmpty(_walkAnimationState))
return _walkAnimationState;
return _idleAnimationState;
}
private void UpdateLocomotionAnimation()
{
if (_isInActionAnimation) return;
if (_isGrounded && !_wasGroundedLastFrame && !string.IsNullOrEmpty(_landAnimationState))
_landTimer = _landAnimationDuration;
_wasGroundedLastFrame = _isGrounded;
if (_landTimer > 0f)
_landTimer -= Time.fixedDeltaTime;
PlayLocomotionState();
}
private void PlayActionAnimation(ActionData data)
{
if (_anim == null) return;
CancelAndDispose(ref _animationSpeedCts);
_animationSpeedCts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
_anim.speed = GetAnimationSpeed(data, 0f);
if (!string.IsNullOrEmpty(data.AnimationState))
{
_anim.Play(data.AnimationState);
_anim.Update(0f);
_isInActionAnimation = true;
_activeBaseState = null;
}
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);
float elapsed = 0f;
try
{
while (ShouldKeepActionPlaying(data, elapsed, duration))
{
token.ThrowIfCancellationRequested();
float normalizedTime = GetActionNormalizedTime(data, elapsed, duration);
_anim.speed = GetAnimationSpeed(data, normalizedTime);
await Awaitable.NextFrameAsync(token);
elapsed += Time.deltaTime;
}
}
catch (System.OperationCanceledException) { }
}
private float GetAnimationSpeed(ActionData data, float normalizedTime)
{
float curveMultiplier = data.AnimationSpeedCurve != null
? data.AnimationSpeedCurve.Evaluate(normalizedTime)
: 1f;
return Mathf.Max(data.AnimationSpeed * curveMultiplier, 0.01f);
}
private float GetMotionDirection(ActionData data)
{
return _actionDirection;
}
private void FaceMotionDirection(ActionData data)
{
if (_spriteRenderer == null) return;
_spriteRenderer.flipX = _actionDirection < 0f;
_facingLockTimer = 0f;
}
private void CaptureActionDirection(ActionData data)
{
if (data != null && data.UseInputDirection && _moveInputX != 0f)
_actionDirection = _moveInputX;
else
_actionDirection = _spriteRenderer != null && _spriteRenderer.flipX ? -1f : 1f;
}
private Vector2 GetHitVelocity(Vector2 hitVelocity)
{
float facing = _spriteRenderer != null && _spriteRenderer.flipX ? -1f : 1f;
hitVelocity.x *= facing;
return hitVelocity;
}
private Vector2 GetAttackLocalPosition(Vector2 offset)
{
float facing = _spriteRenderer != null && _spriteRenderer.flipX ? -1f : 1f;
offset.x *= facing;
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;
return string.IsNullOrEmpty(data.ActionName) ? data.name : data.ActionName;
}
private void ApplyForwardStep(float distance, float duration)
{
if (distance <= 0f) return;
float safeDuration = Mathf.Max(duration, 0.02f);
float facing = _spriteRenderer != null && _spriteRenderer.flipX ? -1f : 1f;
float vx = facing * (distance / safeDuration);
_rb.linearVelocity = new Vector2(vx, _rb.linearVelocity.y);
_inputLockTimer = safeDuration;
_movementLockAction = null;
}
private bool IsActionAnimationComplete(ActionData data)
{
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 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);
}
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 (!ShouldUseAnimationLength(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()
{
// 그라운드 파운드는 _maxFallSpeed에 막히지 않도록 자체 속도를 그대로 유지한다.
if (_isGroundPounding) return;
float vy = _rb.linearVelocity.y;
if (_isGrounded && vy <= 0f)
{
if (vy != 0f)
_rb.linearVelocity = new Vector2(_rb.linearVelocity.x, 0f);
return;
}
float newY = Mathf.Max(vy + _gravity * Time.fixedDeltaTime, -_maxFallSpeed);
_rb.linearVelocity = new Vector2(_rb.linearVelocity.x, newY);
}
private void ClampVelocityToGround()
{
int solidMask = _groundLayer.value;
if (solidMask == 0) return;
Vector2 velocity = _rb.linearVelocity;
bool changed = false;
if (Mathf.Abs(velocity.x) > 0.001f)
{
float sign = Mathf.Sign(velocity.x);
float maxDist = Mathf.Abs(velocity.x * Time.fixedDeltaTime);
float hitDist = GetClosestHitDistance(new Vector2(sign, 0f), maxDist + _skinWidth, solidMask);
if (hitDist < maxDist + _skinWidth)
{
float allowed = Mathf.Max(hitDist - _skinWidth, 0f);
velocity.x = sign * (allowed / Time.fixedDeltaTime);
changed = true;
}
}
if (Mathf.Abs(velocity.y) > 0.001f)
{
float sign = Mathf.Sign(velocity.y);
float maxDist = Mathf.Abs(velocity.y * Time.fixedDeltaTime);
float hitDist = GetClosestHitDistance(new Vector2(0f, sign), maxDist + _skinWidth, solidMask);
if (hitDist < maxDist + _skinWidth)
{
float allowed = Mathf.Max(hitDist - _skinWidth, 0f);
velocity.y = sign * (allowed / Time.fixedDeltaTime);
changed = true;
}
}
if (changed)
_rb.linearVelocity = velocity;
}
private float GetClosestHitDistance(Vector2 direction, float distance, int layerMask)
{
if (_bodyColliders == null || _bodyColliders.Length == 0)
_bodyColliders = GetComponentsInChildren<Collider2D>();
ContactFilter2D filter = new ContactFilter2D
{
useLayerMask = true,
layerMask = layerMask,
useTriggers = false
};
// 빠른 이동이 지형을 뚫지 않도록 트리거가 아닌 몸체 콜라이더를 전부 캐스트한다.
float closest = float.PositiveInfinity;
for (int i = 0; i < _bodyColliders.Length; i++)
{
Collider2D bodyCollider = _bodyColliders[i];
if (bodyCollider == null || bodyCollider.isTrigger) continue;
_castResults.Clear();
int hitCount = bodyCollider.Cast(direction, filter, _castResults, distance);
for (int j = 0; j < hitCount; j++)
{
if (_castResults[j].distance < closest)
closest = _castResults[j].distance;
}
}
return closest;
}
private void OnDrawGizmos()
{
if (_lastHitData == null || _lastHitTime < 0f) return;
float since = Time.time - _lastHitTime;
if (since < 0f || since > _hitGizmoFadeDuration) return;
float t = Mathf.Clamp01(since / _hitGizmoFadeDuration);
float alpha = Mathf.Lerp(0.85f, 0f, t);
float radius = _lastHitData.Radius;
Gizmos.color = new Color(1f, 0.2f, 0.2f, alpha * 0.35f);
Gizmos.DrawSphere(_lastHitCenter, radius);
Gizmos.color = new Color(1f, 0f, 0f, alpha);
Gizmos.DrawWireSphere(_lastHitCenter, radius);
}
private void OnDrawGizmosSelected()
{
if (_groundCheck != null)
{
Gizmos.color = _isGrounded ? Color.green : Color.red;
Gizmos.DrawWireSphere(_groundCheck.position, _groundCheckRadius);
}
if (_wallCheckLeft != null)
{
Gizmos.color = _isTouchingLeftWall ? Color.green : Color.red;
Gizmos.DrawWireSphere(_wallCheckLeft.position, _wallCheckRadius);
}
if (_wallCheckRight != null)
{
Gizmos.color = _isTouchingRightWall ? Color.green : Color.red;
Gizmos.DrawWireSphere(_wallCheckRight.position, _wallCheckRadius);
}
}
private void OnGUI()
{
if (!_showAttackDebug || _lastAttackGizmoData == null) return;
ActionData data = _lastAttackGizmoData;
float elapsed = Time.time - _attackStartTime;
string status = _hitFired
? (elapsed < data.HitTiming + Mathf.Max(data.HitDuration, 0.01f) ? "HIT" : "DONE")
: "WINDUP";
string cooldownText = _attackCooldownTimer > 0f
? $"{_attackCooldownTimer:F3}s left"
: "READY";
string bufferText = _pendingInput.HasValue
? $"<color=#ffcc00>BUFFERED: {_pendingInput.Value}</color>"
: (elapsed >= _bufferOpenTime && _attackCooldownTimer > 0f ? "ready to buffer" : "-");
string info =
$"<b>{GetActionName(data)}</b> [{status}]\n" +
$"Elapsed : {elapsed:F3} s\n" +
$"HitTiming : {data.HitTiming:F3} s\n" +
$"HitDuration : {data.HitDuration:F3} s\n" +
$"Cooldown : {data.Cooldown:F3} s ({cooldownText})\n" +
$"ComboWindow : {_comboWindowTimer:F3} s\n" +
$"Buffer : {bufferText}";
GUIStyle style = new GUIStyle(GUI.skin.box)
{
fontSize = 26,
alignment = TextAnchor.UpperLeft,
richText = true,
padding = new RectOffset(16, 16, 12, 12),
normal = { textColor = Color.white }
};
GUI.Box(new Rect(10, 10, 520, 282), info, style);
DrawTimelineBar(data, elapsed);
}
private void DrawTimelineBar(ActionData data, float elapsed)
{
float barX = 10f;
float barY = 302f;
float barW = 520f;
float barH = 36f;
float totalTime = Mathf.Max(
data.HitTiming + Mathf.Max(data.HitDuration, 0.05f),
data.Cooldown,
data.MotionDuration,
0.3f);
GUI.color = new Color(0f, 0f, 0f, 0.6f);
GUI.DrawTexture(new Rect(barX, barY, barW, barH), Texture2D.whiteTexture);
float hitX = barX + (data.HitTiming / totalTime) * barW;
float hitEndX = barX + ((data.HitTiming + data.HitDuration) / totalTime) * barW;
GUI.color = new Color(1f, 0.3f, 0.3f, 0.5f);
GUI.DrawTexture(new Rect(hitX, barY, Mathf.Max(hitEndX - hitX, 4f), barH), Texture2D.whiteTexture);
float bufferX = barX + Mathf.Clamp01(_bufferOpenTime / totalTime) * barW;
GUI.color = new Color(0.2f, 1f, 0.4f, 0.9f);
GUI.DrawTexture(new Rect(bufferX - 2f, barY, 4f, barH), Texture2D.whiteTexture);
float cdEndX = barX + Mathf.Clamp01(data.Cooldown / totalTime) * barW;
GUI.color = new Color(0.3f, 0.7f, 1f, 0.9f);
GUI.DrawTexture(new Rect(cdEndX - 2f, barY, 4f, barH), Texture2D.whiteTexture);
float cursorX = barX + Mathf.Clamp01(elapsed / totalTime) * barW;
GUI.color = Color.yellow;
GUI.DrawTexture(new Rect(cursorX - 2f, barY - 4f, 4f, barH + 8f), Texture2D.whiteTexture);
GUI.color = Color.white;
}
}