using System.Collections.Generic; using System.Threading; using UnityEngine; public class PlayerController : MonoBehaviour,IDamageable { [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 int _maxJumpCount = 2; [SerializeField] private Transform _groundCheck; [SerializeField] private float _groundCheckRadius = 0.1f; [SerializeField] private LayerMask _groundLayer; private bool _isGrounded; private int _jumpsUsed; [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 _motionCooldownTimers = new(); private readonly List _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 _castResults = new(); private Collider2D[] _bodyColliders; private Rigidbody2D _rb; private Animator _anim; private SpriteRenderer _spriteRenderer; private void Awake() { _rb = GetComponent(); _anim = GetComponent(); _spriteRenderer = GetComponentInChildren(); _bodyColliders = GetComponentsInChildren(); 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 (_isGrounded && _rb.linearVelocity.y <= 0f) _jumpsUsed = 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) { PerformJump(); } else if (IsTouchingWall) { _rb.linearVelocity = new Vector2(-_wallDirection * _wallJumpForce.x, _wallJumpForce.y); _inputLockTimer = _wallJumpInputLockDuration; } else if (_jumpsUsed < _maxJumpCount) { PerformJump(); } } private void PerformJump() { _rb.linearVelocity = new Vector2(_rb.linearVelocity.x, _jumpForce); _jumpsUsed++; } 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(); 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(true); if (_attackHitbox != null) { SetAttackHitboxLayer(); return; } GameObject hitboxObject = new GameObject("AttackHitbox"); hitboxObject.transform.SetParent(transform, false); hitboxObject.AddComponent(); _attackHitbox = hitboxObject.AddComponent(); 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(); 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 ? $"BUFFERED: {_pendingInput.Value}" : (elapsed >= _bufferOpenTime && _attackCooldownTimer > 0f ? "ready to buffer" : "-"); string info = $"{GetActionName(data)} [{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; } public void TakeDamage(int amount, Vector2 hitVelocity = default, string hitReactionAnimationState = null, Vector2? hitTargetPosition = null, bool correctHitTargetY = false, int hitPositionSolidMask = 0, float hitPositionCorrectionDuration = 0) { Debug.Log("플레이어 타격받음"); } }