주석추가

This commit is contained in:
2026-05-19 10:51:56 +09:00
parent e01feec160
commit adf6750bc8
12 changed files with 577 additions and 214 deletions

View File

@@ -2,109 +2,131 @@
using System.Threading;
using UnityEngine;
// ============================================================================
// PlayerController
// ----------------------------------------------------------------------------
// 플레이어 캐릭터의 모든 동작을 관리하는 중심 컨트롤러.
// - Kinematic Rigidbody2D 기반 (중력/충돌 모두 코드에서 직접 처리)
// - 이동, 점프(2단), 벽 슬라이드/점프, 그라운드 파운드
// - 콤보 공격 (Punch/Kick/Grab) — ActionData + ComboNode 그래프로 정의
// - 모션 액션 (Dash/Roll/BackDash) — 단발 실행, 자체 쿨다운
// - 입력 버퍼링: 쿨다운 중에도 다음 콤보 입력 받아서 자동 실행
// - 외부에서 들어오는 데미지 수용 (IDamageable)
// ============================================================================
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;
[SerializeField] private float _moveSpeed = 5f; // 이동 속도 (units/sec)
[SerializeField] private string _walkAnimationState = "Run"; // 걷기/달리기 애니메이션 State 이름
private float _moveInputX = 0f; // 현재 X 입력값 (-1/0/1)
private string _activeBaseState; // 현재 재생 중인 locomotion State (중복 Play 방지용)
private bool _isInActionAnimation; // 액션 애니메이션 재생 중인지 (locomotion 잠시 양보)
// ─── 점프 단계별 애니메이션 ───────────────────────────────────────────
// 점프를 4단계(Rise/Mid/Fall/Land)로 분리해서 vy에 따라 자동 전환.
[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;
[SerializeField] private string _jumpRiseAnimationState = "JumpRise"; // 상승 중 (vy > _jumpMidThreshold)
[SerializeField] private string _jumpMidAnimationState = "JumpMid"; // 정점 부근 (|vy| <= threshold)
[SerializeField] private string _jumpFallAnimationState = "JumpFall"; // 낙하 중 (vy < -threshold)
[SerializeField] private string _landAnimationState = "Land"; // 착지 직후 짧게 재생
[SerializeField] private float _jumpMidThreshold = 2f; // Rise/Mid/Fall 구분 vy 경계값
[SerializeField] private float _landAnimationDuration = 0.15f; // Land 애니 지속 시간
private bool _wasGroundedLastFrame = true; // 이전 프레임 grounded 여부 (Land 트리거용)
private float _landTimer; // Land 애니 남은 시간
// ─── 점프 (메커니즘) ─────────────────────────────────────────────────
[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;
[SerializeField] private float _jumpForce = 8f; // 점프 시 vy 값
[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 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;
[SerializeField] private float _wallSlideSpeed = 2f; // 벽 슬라이드 시 낙하 속도 클램프
[SerializeField] private Vector2 _wallJumpForce = new Vector2(4f, 5f); // 벽 점프 시 (반대방향X, +Y) 속도
[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 int _wallDirection; // 닿은 벽 방향 (-1 왼쪽, +1 오른쪽, 0 없음)
private float _inputLockTimer; // 이동 입력 잠금 타이머
private float _facingLockTimer; // 페이싱 잠금 타이머
private ActionData _movementLockAction; // 애니메이션 기반 잠금 시 참조 액션
private ActionData _facingLockAction;
// ─── 그라운드 파운드 (공중에서 Grab 키로 발동하는 다이브 슬램) ────────
[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;
[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; // 슬램 낙하 속도 (gravity 우회)
private bool _isGroundPounding; // 그라운드 파운드 중일 때 ApplyGravity 우회 플래그
// ─── 모션 액션 (콤보 트리와 별개의 즉발 액션) ─────────────────────────
[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();
[SerializeField] private ComboNode _dashRootNode; // Dash 진입 노드
[SerializeField] private ComboNode _rollRootNode; // Roll
[SerializeField] private ComboNode _backDashRootNode; // 후방 대시
[SerializeField] private ComboNode _grabSmashRootNode; // 잡기 콤보 진입
private readonly Dictionary<ActionData, float> _motionCooldownTimers = new(); // 액션별 쿨다운 남은 시간
private readonly List<ActionData> _motionCooldownKeys = new(); // 매 프레임 순회용 임시 키 리스트
// ─── 키네마틱 물리 (Kinematic Rigidbody2D를 위한 자체 중력/충돌) ──────
[Header("Kinematic Physics")]
[SerializeField] private float _gravity = -25f;
[SerializeField] private float _maxFallSpeed = 20f;
[SerializeField] private float _skinWidth = 0.02f;
[SerializeField] private float _gravity = -25f; // 중력 가속도 (units/sec^2, 음수)
[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;
[SerializeField] private ComboNode _punchRootNode; // Punch 입력 시 시작 노드
[SerializeField] private ComboNode _kickRootNode; // Kick 입력 시 시작 노드
[SerializeField] private LayerMask _enemyLayer; // 적 레이어 (공격 판정 대상)
[SerializeField] private AttackHitbox _attackHitbox; // 자식 Trigger 콜라이더 (활성/비활성으로 hit window 표현)
[SerializeField] private string _idleAnimationState = "Idle"; // 기본 idle State
[SerializeField] private float _bufferOpenTime = 0.1f; // 공격 시작 후 이 시간 지나야 다음 입력 버퍼링
[SerializeField] private float _bufferLifetime = 0.5f; // 버퍼된 입력의 유효 시간
private ComboInputType? _pendingInput; // 쿨다운 중에 미리 받은 다음 입력
private float _pendingInputTime = -1f; // 버퍼 입력이 기록된 시각 (lifetime 체크용)
private float _attackCooldownTimer; // 공격 잠금 타이머 (모든 공격이 공유)
private ComboNode _currentNode; // 현재 콤보 트리 위치
private float _comboWindowTimer; // 콤보 다음 입력 허용 시간
private CancellationTokenSource _attackCts; // 공격 코루틴(async) 취소 토큰
private CancellationTokenSource _motionCts; // 모션 코루틴 취소 토큰
private CancellationTokenSource _animationSpeedCts; // 애니메이션 속도 커브 토큰
private CancellationTokenSource _actionVelocityCts; // 액션 속도 커브 토큰
private bool _isAttackActive; // 공격 진행 중 (이동/페이싱 보조 잠금용)
private bool _isMotionActive; // 모션 진행 중
private float _actionDirection = 1f; // 액션 시작 시 캐릭터 페이싱 (속도 X 부호)
private ActionData _lastAttackGizmoData; // OnGUI 디버그 패널용 마지막 액션 참조
[SerializeField] private float _hitGizmoFadeDuration = 0.5f; // hit 영역 Gizmo 페이드 시간
private ActionData _lastHitData; // 마지막으로 발화된 hit 액션 (gizmo)
private Vector2 _lastHitCenter; // hit 발화 위치 (gizmo)
private float _lastHitTime = -1f; // hit 발화 시각 (gizmo)
private Enemy _lastHitEnemy; // 가장 최근 맞은 적 참조 (잡기 타겟 찾기 최적화용)
// ─── 디버그 표시 ─────────────────────────────────────────────────────
[Header("Debug")]
[SerializeField] private bool _showAttackDebug = true;
private float _attackStartTime = -1f;
private bool _hitFired;
[SerializeField] private bool _showAttackDebug = true; // OnGUI에 공격 정보 패널 표시 여부
private float _attackStartTime = -1f; // 액션 시작 시각 (디버그 elapsed 계산)
private bool _hitFired; // 현재 액션에서 hit이 이미 발화됐는지
private readonly List<RaycastHit2D> _castResults = new();
private Collider2D[] _bodyColliders;
private readonly List<RaycastHit2D> _castResults = new(); // Cast 결과 버퍼 (GC 회피용)
private Collider2D[] _bodyColliders; // 캐릭터 몸체 콜라이더들 (cast 대상)
private Rigidbody2D _rb;
private Animator _anim;
private SpriteRenderer _spriteRenderer;
private SpriteRenderer _spriteRenderer; // 좌우 반전 + flipX 페이싱용
// 컴포넌트 캐싱 + 자식 AttackHitbox 자동 보장 + hit 이벤트 구독.
private void Awake()
{
_rb = GetComponent<Rigidbody2D>();
@@ -112,9 +134,12 @@ private void Awake()
_spriteRenderer = GetComponentInChildren<SpriteRenderer>();
_bodyColliders = GetComponentsInChildren<Collider2D>();
EnsureAttackHitbox();
// 공격이 enemy를 hit하면 _lastHitEnemy를 기억 → 다음 잡기에서 그 enemy를 우선 타겟팅.
_attackHitbox.OnHit += OnAttackHit;
}
// InputManager의 이벤트들에 액션별 핸들러를 구독.
// (Awake에서 안 하는 이유: InputManager.Instance가 자기 Awake에서 세팅되어 Start 시점에 보장됨)
private void Start()
{
InputManager.Instance.OnMove_Event += OnMoveInput;
@@ -127,6 +152,8 @@ private void Start()
InputManager.Instance.OnGrabSmash_Event += OnGrabSmashInput;
}
// 이벤트 구독 해제 + 진행 중인 async 작업 모두 취소.
// 토큰을 명시적으로 dispose하지 않으면 await 중 GC 누수 위험.
private void OnDestroy()
{
if (InputManager.Instance != null)
@@ -150,6 +177,16 @@ private void OnDestroy()
_attackHitbox.OnHit -= OnAttackHit;
}
// 매 물리 프레임의 메인 흐름:
// 1) 충돌 상태 갱신 (지면/좌우 벽)
// 2) 점프 카운트 리셋 (지면 + 낙하 중일 때만)
// 3) 쿨다운/콤보 윈도우/버퍼 처리
// 4) 좌우 이동 입력을 velocity로 반영 (잠금/액션 중일 땐 스킵)
// 5) 페이싱 갱신
// 6) 자체 중력 적용
// 7) 벽 슬라이드 시 낙하 속도 클램프
// 8) Cast 기반으로 땅/벽 침투 방지
// 9) Locomotion 애니메이션 갱신 (Idle/Walk/Jump/Land)
private void FixedUpdate()
{
// 이동, 벽타기, 점프 판단이 모두 이 값을 쓰므로 충돌 체크를 먼저 갱신한다.
@@ -159,6 +196,7 @@ private void FixedUpdate()
_wallDirection = _isTouchingRightWall ? 1 : (_isTouchingLeftWall ? -1 : 0);
// 지면에 닿고 점프 중이 아니면(상승 중 아님) 점프 카운트 리셋.
// vy>0 체크 안 하면 점프 직후 같은 프레임에 _isGrounded=true라 카운트가 0으로 되돌아가 무한 점프 가능.
if (_isGrounded && _rb.linearVelocity.y <= 0f)
_jumpsUsed = 0;
@@ -166,9 +204,10 @@ private void FixedUpdate()
_attackCooldownTimer -= Time.fixedDeltaTime;
UpdateMotionCooldowns();
ExecuteBufferedInputIfReady();
TickComboWindow();
ExecuteBufferedInputIfReady(); // 쿨다운 끝나면 저장된 입력 실행
TickComboWindow(); // 콤보 윈도우 카운트다운
// 입력 잠금 + 액션 중이 아닐 때만 좌우 입력으로 velocity 갱신.
if (!IsMovementLocked() && !IsActionActive())
_rb.linearVelocity = new Vector2(_moveInputX * _moveSpeed, _rb.linearVelocity.y);
@@ -177,6 +216,7 @@ private void FixedUpdate()
ApplyGravity();
// 벽에 매달려 낙하 중일 때 vy를 -_wallSlideSpeed로 클램프.
if (IsTouchingWall && !_isGrounded && _rb.linearVelocity.y < -_wallSlideSpeed)
_rb.linearVelocity = new Vector2(_rb.linearVelocity.x, -_wallSlideSpeed);
@@ -186,6 +226,8 @@ private void FixedUpdate()
UpdateLocomotionAnimation();
}
// 쿨다운 직전에 버퍼된 콤보 입력을, 쿨다운이 풀리는 순간 자동 발화.
// 입력 후 _bufferLifetime이 지났으면 폐기 (오래된 입력은 의도가 아닐 가능성 큼).
private void ExecuteBufferedInputIfReady()
{
if (_attackCooldownTimer > 0f || !_pendingInput.HasValue) return;
@@ -197,6 +239,7 @@ private void ExecuteBufferedInputIfReady()
ExecuteComboInput(buffered);
}
// 콤보 윈도우 카운트다운. 0에 도달하면 현재 노드를 리셋 → 다음 입력은 root부터 새로 시작.
private void TickComboWindow()
{
if (_comboWindowTimer <= 0f) return;
@@ -206,6 +249,8 @@ private void TickComboWindow()
_currentNode = null;
}
// InputManager의 OnMove_Event 콜백.
// 키보드라 -1/0/1로 정규화 (디지털 입력). 아날로그 패드면 Sign 빼고 그대로 사용.
private void OnMoveInput(Vector2 value)
{
_moveInputX = value.x == 0f ? 0f : Mathf.Sign(value.x);
@@ -213,12 +258,14 @@ private void OnMoveInput(Vector2 value)
UpdateFacingFromMoveInput();
}
// SpriteRenderer.flipX로 좌우 반전 (transform.localScale이 아닌 이유: 자식 콜라이더 위치가 따라 뒤집히면 곤란).
private void UpdateFacingFromMoveInput()
{
if (_moveInputX != 0f && _spriteRenderer != null)
_spriteRenderer.flipX = _moveInputX < 0f;
}
// 점프 우선순위: 지상 점프 > 벽 점프 > 공중(2단) 점프
private void OnJumpInput()
{
if (_isGrounded)
@@ -227,11 +274,14 @@ private void OnJumpInput()
}
else if (IsTouchingWall)
{
// 벽 반대 방향으로 튕겨나가는 벽 점프. 벽 점프는 _jumpsUsed 카운트 영향 없음.
_rb.linearVelocity = new Vector2(-_wallDirection * _wallJumpForce.x, _wallJumpForce.y);
// 잠시 좌우 입력을 잠가서 즉시 같은 벽으로 돌아붙는 걸 방지.
_inputLockTimer = _wallJumpInputLockDuration;
}
else if (_jumpsUsed < _maxJumpCount)
{
// 공중 점프 (2단/3단). _maxJumpCount=2면 지상점프 + 공중 1회 가능.
PerformJump();
}
}
@@ -242,6 +292,10 @@ private void PerformJump()
_jumpsUsed++;
}
// ─── 입력 핸들러: 콤보(공격) 라우팅 ──────────────────────────────────
// Punch/Kick은 그대로 콤보 시스템으로.
// Grab은 공중이면 그라운드 파운드, 지상이면 콤보 잡기로 분기.
// Dash/Roll/BackDash는 콤보가 아닌 단발 모션.
private void OnPunchInput() => HandleComboInput(ComboInputType.Punch);
private void OnKickInput() => HandleComboInput(ComboInputType.Kick);
private void OnGrabSmashInput()
@@ -256,8 +310,11 @@ private void OnGrabSmashInput()
private void OnDashInput() => ExecuteMotionNode(_dashRootNode);
private void OnRollInput() => ExecuteMotionNode(_rollRootNode);
private void OnBackDashInput() => ExecuteMotionNode(_backDashRootNode);
// 콤보 입력 라우팅 — 3가지 경우를 처리:
// 1) 모션 중이면 트랜지션 가능 여부 확인 (예: 대시 중 펀치)
// 2) 쿨다운 중이면 _bufferOpenTime 이후 들어온 입력만 버퍼링
// 3) 그 외에는 즉시 ExecuteComboInput으로 발화
private void HandleComboInput(ComboInputType input)
{
if (_isMotionActive && !CanTransitionFromCurrentNode(input))
@@ -266,6 +323,7 @@ private void HandleComboInput(ComboInputType input)
if (_attackCooldownTimer > 0f)
{
float elapsed = Time.time - _attackStartTime;
// _bufferOpenTime 전엔 너무 일찍 누른 입력으로 간주하고 무시.
if (_lastAttackGizmoData != null && elapsed >= _bufferOpenTime)
{
_pendingInput = input;