1795 lines
72 KiB
C#
1795 lines
72 KiB
C#
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using UnityEngine;
|
|
using UnityEngine.Serialization;
|
|
|
|
// ============================================================================
|
|
// 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; // 이동 속도 (units/sec)
|
|
[SerializeField] private string _walkAnimationState = "Run"; // 걷기/달리기 애니메이션 State 이름
|
|
[SerializeField] private float _backpedalSpeed = 3f; // 백페달(Down+좌우) 이동 속도 — 보통 _moveSpeed보다 느리게
|
|
[SerializeField] private string _backpedalAnimationState = "BackWalk"; // 백페달 시 재생할 애니메이션 (뒤로 걷기)
|
|
private float _moveInputX = 0f; // 현재 X 입력값 (-1/0/1)
|
|
private float _moveInputY = 0f; // 현재 Y 입력값 (-1/0/1) — 위/아래 방향 공격 판정용
|
|
private string _activeBaseState; // 현재 재생 중인 locomotion State (중복 Play 방지용)
|
|
private bool _isInActionAnimation; // 액션 애니메이션 재생 중인지 (locomotion 잠시 양보)
|
|
|
|
// 피격
|
|
[Header("Hit Animation")]
|
|
[SerializeField] private string _knockbackAnimationState = "Knockback";
|
|
[SerializeField] private float _knockbackDuration = 1f; // 넉백 경직 시간 (Knockback 클립 길이에 맞춰 설정)
|
|
private float _hitstunTimer; // 경직 남은 최소 시간
|
|
private bool _isStunned; // 경직 상태 (공중이면 착지까지 연장)
|
|
|
|
// ─── 점프 단계별 애니메이션 ───────────────────────────────────────────
|
|
// 점프를 4단계(Rise/Mid/Fall/Land)로 분리해서 vy에 따라 자동 전환.
|
|
[Header("Jump Animation")]
|
|
[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; // 점프 시 vy 값
|
|
[SerializeField] private int _maxJumpCount = 2; // 최대 점프 횟수 (지상 + 공중 점프 포함)
|
|
[SerializeField] private float _groundCheckDistance = 0.1f; // GroundCheck 콜라이더 하향 캐스트 거리
|
|
[SerializeField] private float _groundCheckRayInset = 0.02f; // 벽 옆면 접촉을 피하기 위한 좌우 안쪽 여백
|
|
[SerializeField, Range(0f, 1f)] private float _groundNormalMinY = 0.65f; // 이 값 이상 위를 보는 면만 지면으로 인정
|
|
[SerializeField] private LayerMask _groundLayer; // 지면/벽으로 취급할 레이어
|
|
private bool _isGrounded; // 현재 지면 접촉 여부
|
|
private int _jumpsUsed; // 이번 공중 체류 동안 사용한 점프 수
|
|
|
|
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; // 슬램 낙하 속도 (gravity 우회)
|
|
private bool _isGroundPounding; // 그라운드 파운드 중일 때 ApplyGravity 우회 플래그
|
|
|
|
// ─── 모션 액션 (콤보 트리와 별개의 즉발 액션) ─────────────────────────
|
|
[Header("Motion")]
|
|
[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; // 중력 가속도 (units/sec^2, 음수)
|
|
[SerializeField] private float _maxFallSpeed = 20f; // 낙하 최대 속도 클램프
|
|
[SerializeField] private Collider2D _groundCheckCollider; // 지면 판정 전용 콜라이더 (trigger여도 됨)
|
|
|
|
[FormerlySerializedAs("_wallCheckCollider")]
|
|
[SerializeField] private Collider2D _bodyCollider;
|
|
[FormerlySerializedAs("_wallCheckDistance")]
|
|
[SerializeField] private float _collisionSkinWidth = 0.02f;
|
|
|
|
// ─── 공격 (펀치/킥/잡기 콤보 시스템) ──────────────────────────────────
|
|
[Header("Attack")]
|
|
[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 bool _actionAllowsMovement; // 현재 액션이 진행 중 좌우 이동을 허용하는지 (CanMoveDuringAction)
|
|
private bool _actionAllowsTurn; // 현재 액션이 진행 중 페이싱 변경을 허용하는지 (CanTurnDuringAction)
|
|
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; // OnGUI에 공격 정보 패널 표시 여부
|
|
private float _attackStartTime = -1f; // 액션 시작 시각 (디버그 elapsed 계산)
|
|
private bool _hitFired; // 현재 액션에서 hit이 이미 발화됐는지
|
|
|
|
private readonly List<RaycastHit2D> _castResults = new(); // Cast 결과 버퍼 (GC 회피용)
|
|
private Rigidbody2D _rb;
|
|
private Animator _anim;
|
|
private SpriteRenderer _spriteRenderer; // 좌우 반전 + flipX 페이싱용
|
|
private Health _health;
|
|
|
|
// ─── 무기 시스템 ────────────────────────────────────────────────────
|
|
// PlayerWeaponInventory가 현재 장착 무기 관리. 무기 교체 시 OnWeaponChanged 이벤트 발화.
|
|
// 무장 시 idle/walk 애니메이션과 Punch 콤보 root가 무기 데이터로 교체됨.
|
|
private PlayerWeaponInventory _weaponInventory;
|
|
|
|
// 컴포넌트 캐싱 + 자식 AttackHitbox 자동 보장 + hit 이벤트 구독.
|
|
private void Awake()
|
|
{
|
|
_rb = GetComponent<Rigidbody2D>();
|
|
_anim = GetComponent<Animator>();
|
|
_spriteRenderer = GetComponentInChildren<SpriteRenderer>();
|
|
_health = GetComponent<Health>();
|
|
ResolveBodyColliderReference();
|
|
EnsureAttackHitbox();
|
|
// 공격이 enemy를 hit하면 _lastHitEnemy를 기억 → 다음 잡기에서 그 enemy를 우선 타겟팅.
|
|
_attackHitbox.OnHit += OnAttackHit;
|
|
|
|
// 무기 인벤토리: 영속 싱글톤 (DontDestroyOnLoad). 없으면 EnsureInstance가 새로 생성.
|
|
// 씬 전환 시 PlayerController는 파괴되지만 인벤토리는 살아남아 무기 목록을 유지한다.
|
|
_weaponInventory = PlayerWeaponInventory.EnsureInstance();
|
|
_weaponInventory.OnWeaponChanged += OnWeaponChanged;
|
|
}
|
|
|
|
private void ResolveBodyColliderReference()
|
|
{
|
|
if (_bodyCollider != null && _bodyCollider.enabled) return;
|
|
|
|
Collider2D[] colliders = GetComponentsInChildren<Collider2D>(true);
|
|
for (int i = 0; i < colliders.Length; i++)
|
|
{
|
|
Collider2D candidate = colliders[i];
|
|
if (candidate == null || !candidate.enabled || candidate.isTrigger) continue;
|
|
if (candidate == _groundCheckCollider) continue;
|
|
if (candidate.GetComponent<AttackHitbox>() != null) continue;
|
|
|
|
_bodyCollider = candidate;
|
|
return;
|
|
}
|
|
}
|
|
|
|
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;
|
|
InputManager.Instance.OnWeaponSlot1_Event += OnWeaponSlot1Input;
|
|
InputManager.Instance.OnWeaponSlot2_Event += OnWeaponSlot2Input;
|
|
InputManager.Instance.OnWeaponSlot3_Event += OnWeaponSlot3Input;
|
|
}
|
|
|
|
// 이벤트 구독 해제 + 진행 중인 async 작업 모두 취소.
|
|
// 토큰을 명시적으로 dispose하지 않으면 await 중 GC 누수 위험.
|
|
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;
|
|
InputManager.Instance.OnWeaponSlot1_Event -= OnWeaponSlot1Input;
|
|
InputManager.Instance.OnWeaponSlot2_Event -= OnWeaponSlot2Input;
|
|
InputManager.Instance.OnWeaponSlot3_Event -= OnWeaponSlot3Input;
|
|
}
|
|
|
|
CancelAndDispose(ref _attackCts);
|
|
CancelAndDispose(ref _motionCts);
|
|
CancelAndDispose(ref _animationSpeedCts);
|
|
CancelAndDispose(ref _actionVelocityCts);
|
|
|
|
if (_attackHitbox != null)
|
|
_attackHitbox.OnHit -= OnAttackHit;
|
|
if (_weaponInventory != null)
|
|
_weaponInventory.OnWeaponChanged -= OnWeaponChanged;
|
|
}
|
|
|
|
// ─── 무기 슬롯 입력 핸들러 ───────────────────────────────────────────
|
|
// Key 1 → 맨손, Key 2 → 첫 픽업 무기, Key 3 → 두 번째 픽업 무기.
|
|
private void OnWeaponSlot1Input() => _weaponInventory.EquipUnarmed();
|
|
private void OnWeaponSlot2Input() => _weaponInventory.EquipSlot(0);
|
|
private void OnWeaponSlot3Input() => _weaponInventory.EquipSlot(1);
|
|
|
|
// 무기 교체 이벤트 콜백.
|
|
// 콤보 상태를 리셋하고 locomotion 애니메이션을 즉시 갱신.
|
|
private void OnWeaponChanged(WeaponData weapon)
|
|
{
|
|
_currentNode = null;
|
|
_comboWindowTimer = 0f;
|
|
_pendingInput = null;
|
|
_activeBaseState = null; // 강제 재평가
|
|
PlayLocomotionState();
|
|
}
|
|
|
|
// 매 물리 프레임의 메인 흐름:
|
|
// 1) 충돌 상태 갱신 (지면)
|
|
// 2) 점프 카운트 리셋 (지면 + 낙하 중일 때만)
|
|
// 3) 쿨다운/콤보 윈도우/버퍼 처리
|
|
// 4) 좌우 이동 입력을 velocity로 반영 (잠금/액션 중일 땐 스킵)
|
|
// 5) 페이싱 갱신
|
|
// 6) 자체 중력 적용
|
|
// 7) Locomotion 애니메이션 갱신 (Idle/Walk/Jump/Land)
|
|
private void FixedUpdate()
|
|
{
|
|
_isGrounded = CheckGrounded();
|
|
|
|
// 지면에 닿고 점프 중이 아니면(상승 중 아님) 점프 카운트 리셋.
|
|
if (_isGrounded && _rb.linearVelocity.y <= 0f)
|
|
_jumpsUsed = 0;
|
|
|
|
if (_attackCooldownTimer > 0f)
|
|
_attackCooldownTimer -= Time.fixedDeltaTime;
|
|
|
|
UpdateMotionCooldowns();
|
|
ExecuteBufferedInputIfReady(); // 쿨다운 끝나면 저장된 입력 실행
|
|
TickComboWindow(); // 콤보 윈도우 카운트다운
|
|
|
|
// 입력 잠금이 없고, (액션 중이 아니거나 액션이 이동을 허용할 때) 좌우 입력으로 velocity 갱신.
|
|
// 경직 중에는 이동 입력을 무시한다 (Launch로 받은 속도 + 중력만 작용).
|
|
if (!IsStunned() && !IsMovementLocked() && (!IsActionActive() || _actionAllowsMovement))
|
|
{
|
|
// 백페달이면 느린 속도. 액션 중에도 동일하게 적용 (이동사격 = Down 변형 + CanMoveDuringAction).
|
|
float moveSpeed = IsBackpedaling() ? _backpedalSpeed : _moveSpeed;
|
|
_rb.linearVelocity = new Vector2(_moveInputX * moveSpeed, _rb.linearVelocity.y);
|
|
}
|
|
|
|
if (!IsStunned() && !IsFacingLocked() && (!IsActionActive() || _actionAllowsTurn))
|
|
UpdateFacingFromMoveInput();
|
|
|
|
//중력적용
|
|
ApplyGravity();
|
|
ResolveKinematicCollisions();
|
|
TickHitstun(); // 피격 경직 타이머 카운트다운
|
|
|
|
UpdateLocomotionAnimation();
|
|
|
|
}
|
|
|
|
// 쿨다운 직전에 버퍼된 콤보 입력을, 쿨다운이 풀리는 순간 자동 발화.
|
|
// 입력 후 _bufferLifetime이 지났으면 폐기 (오래된 입력은 의도가 아닐 가능성 큼).
|
|
private void ExecuteBufferedInputIfReady()
|
|
{
|
|
if (_attackCooldownTimer > 0f || !_pendingInput.HasValue) return;
|
|
|
|
ComboInputType buffered = _pendingInput.Value;
|
|
bool expired = Time.time - _pendingInputTime > _bufferLifetime;
|
|
|
|
// 진행 중인 공격을 "콤보 연계 없이" 캔슬하는 입력이면 아직 발화하지 않고,
|
|
// 그 공격이 끝날 때까지 버퍼에 남겨둔다 (마지막 공격 등이 캔슬되지 않게).
|
|
// 단, 수명이 지난 입력은 폐기.
|
|
if (!expired && _isAttackActive && !CanTransitionFromCurrentNode(buffered))
|
|
return;
|
|
|
|
_pendingInput = null;
|
|
if (!expired)
|
|
ExecuteComboInput(buffered);
|
|
}
|
|
|
|
// 콤보 윈도우 카운트다운. 0에 도달하면 현재 노드를 리셋 → 다음 입력은 root부터 새로 시작.
|
|
private void TickComboWindow()
|
|
{
|
|
if (_comboWindowTimer <= 0f) return;
|
|
|
|
_comboWindowTimer -= Time.fixedDeltaTime;
|
|
if (_comboWindowTimer <= 0f)
|
|
_currentNode = null;
|
|
}
|
|
|
|
// InputManager의 OnMove_Event 콜백.
|
|
// 키보드라 -1/0/1로 정규화 (디지털 입력). 아날로그 패드면 Sign 빼고 그대로 사용.
|
|
private void OnMoveInput(Vector2 value)
|
|
{
|
|
_moveInputX = value.x == 0f ? 0f : Mathf.Sign(value.x);
|
|
_moveInputY = value.y == 0f ? 0f : Mathf.Sign(value.y);
|
|
if (!IsStunned() && _facingLockTimer <= 0f && (!IsActionActive() || _actionAllowsTurn))
|
|
UpdateFacingFromMoveInput();
|
|
}
|
|
|
|
// SpriteRenderer.flipX로 좌우 반전 (transform.localScale이 아닌 이유: 자식 콜라이더 위치가 따라 뒤집히면 곤란).
|
|
// Down(아래) 키를 누르고 있으면 페이싱을 고정 — 백페달에서 뒤로 가면서 정면을 유지하는 핵심 규칙.
|
|
// 이 함수가 페이싱 전환의 단일 통로라, 여기만 막으면 locomotion·콤보 입력 전부에 적용된다.
|
|
private void UpdateFacingFromMoveInput()
|
|
{
|
|
if (_moveInputY < 0f) return;
|
|
|
|
if (_moveInputX != 0f && _spriteRenderer != null)
|
|
_spriteRenderer.flipX = _moveInputX < 0f;
|
|
}
|
|
|
|
// Down + 좌우를 함께 누른 상태 = 백페달.
|
|
// 페이싱 고정(UpdateFacingFromMoveInput) + 느린 이동(_backpedalSpeed) + 전용 애니메이션.
|
|
private bool IsBackpedaling()
|
|
{
|
|
return _moveInputY < 0f && _moveInputX != 0f;
|
|
}
|
|
|
|
// 점프 우선순위: 지상 점프 > 공중(2단) 점프
|
|
private void OnJumpInput()
|
|
{
|
|
if (IsStunned()) return; // 경직 중 점프 불가
|
|
|
|
if (_isGrounded)
|
|
{
|
|
PerformJump();
|
|
}
|
|
else if (_jumpsUsed < _maxJumpCount)
|
|
{
|
|
// 공중 점프 (2단/3단). _maxJumpCount=2면 지상점프 + 공중 1회 가능.
|
|
PerformJump();
|
|
}
|
|
}
|
|
|
|
private void PerformJump()
|
|
{
|
|
_rb.linearVelocity = new Vector2(_rb.linearVelocity.x, _jumpForce);
|
|
_jumpsUsed++;
|
|
}
|
|
|
|
// ─── 입력 핸들러: 콤보(공격) 라우팅 ──────────────────────────────────
|
|
// Punch/Kick은 그대로 콤보 시스템으로.
|
|
// Grab은 공중이면 그라운드 파운드, 지상이면 콤보 잡기로 분기.
|
|
// Dash/Roll/BackDash는 콤보가 아닌 단발 모션.
|
|
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);
|
|
|
|
// 콤보 입력 라우팅 — 3가지 경우를 처리:
|
|
// 1) 모션 중이면 트랜지션 가능 여부 확인 (예: 대시 중 펀치)
|
|
// 2) 쿨다운 중이면 _bufferOpenTime 이후 들어온 입력만 버퍼링
|
|
// 3) 그 외에는 즉시 ExecuteComboInput으로 발화
|
|
private void HandleComboInput(ComboInputType input)
|
|
{
|
|
if (IsStunned()) return; // 경직 중 공격 불가
|
|
|
|
// 콤보 연계(현재 노드의 트랜지션) 가능 여부 — 연계는 애니메이션 중에도 즉시 캔슬-체인.
|
|
bool canChain = CanTransitionFromCurrentNode(input);
|
|
|
|
if (_isMotionActive && !canChain)
|
|
return;
|
|
|
|
// 쿨다운 중이거나, 진행 중인 공격을 "콤보 연계 없이" 끊으려는 입력이면 버퍼링.
|
|
// (마지막 공격처럼 다음 콤보가 없는 입력이 애니메이션을 캔슬하던 문제 방지)
|
|
if (_attackCooldownTimer > 0f || (_isAttackActive && !canChain))
|
|
{
|
|
float elapsed = Time.time - _attackStartTime;
|
|
// _bufferOpenTime 전엔 너무 일찍 누른 입력으로 간주하고 무시.
|
|
if (_lastAttackGizmoData != null && elapsed >= _bufferOpenTime)
|
|
{
|
|
_pendingInput = input;
|
|
_pendingInputTime = Time.time;
|
|
}
|
|
return;
|
|
}
|
|
|
|
ExecuteComboInput(input);
|
|
}
|
|
|
|
private void ExecuteComboInput(ComboInputType input)
|
|
{
|
|
// 좌우 방향키를 누르고 있으면 그쪽으로 페이싱 전환 후 공격.
|
|
// (콤보 중 facing이 잠겨 있어도 다음 콤보는 누른 방향으로 나간다.)
|
|
UpdateFacingFromMoveInput();
|
|
|
|
// 콤보 입력 가능 시간이 열려 있으면 현재 노드에서 다음 연계로 이어간다.
|
|
if (_comboWindowTimer > 0f && _currentNode != null)
|
|
{
|
|
foreach (var transition in _currentNode.Transitions)
|
|
{
|
|
if (transition.Trigger != input) continue;
|
|
if (transition.Next == null) continue;
|
|
|
|
// 방향키 입력에 맞는 변형 액션을 고른다 (없으면 노드의 기본 Action).
|
|
ActionData nextAction = ResolveNodeAction(transition.Next);
|
|
if (nextAction == null) continue;
|
|
if (!CanPerformAction(nextAction)) continue;
|
|
|
|
ApplyForwardStep(transition.ForwardStep, transition.ForwardStepDuration);
|
|
PerformAttack(nextAction, transition.ForwardStep > 0f);
|
|
_currentNode = transition.Next;
|
|
_comboWindowTimer = transition.Next.ComboWindow;
|
|
return;
|
|
}
|
|
}
|
|
|
|
// 이어갈 연계가 없으면 입력에 맞는 루트 노드부터 새로 시작한다.
|
|
// Punch 입력은 무기 장착 시 무기의 AttackRootNode로 분기 (맨손이면 기본 _punchRootNode).
|
|
ComboNode root = input switch
|
|
{
|
|
ComboInputType.Punch => GetPunchRootNode(),
|
|
ComboInputType.Kick => _kickRootNode,
|
|
ComboInputType.Grab => _grabSmashRootNode,
|
|
_ => null
|
|
};
|
|
if (root == null) return;
|
|
|
|
ActionData rootAction = ResolveNodeAction(root);
|
|
if (rootAction == null) return;
|
|
if (!CanPerformAction(rootAction)) return;
|
|
|
|
PerformAttack(rootAction);
|
|
_currentNode = root;
|
|
_comboWindowTimer = root.ComboWindow;
|
|
}
|
|
|
|
// 현재 방향키 입력을 공격 방향(AttackDirection)으로 변환.
|
|
// 좌우 입력은 ExecuteComboInput에서 누른 쪽으로 페이싱을 돌리므로 항상 Forward.
|
|
// 대각선 입력은 위/아래를 우선한다 (점프 견제·다운 어택을 우선).
|
|
private AttackDirection GetAttackDirection()
|
|
{
|
|
if (_moveInputY > 0f) return AttackDirection.Up;
|
|
if (_moveInputY < 0f) return AttackDirection.Down;
|
|
if (_moveInputX != 0f) return AttackDirection.Forward;
|
|
return AttackDirection.Neutral;
|
|
}
|
|
|
|
// 노드의 방향별 변형 중 현재 입력 방향과 일치하는 액션을 선택.
|
|
// 일치하는 변형이 없거나 방향 입력이 없으면 노드의 기본 Action을 사용.
|
|
private ActionData ResolveNodeAction(ComboNode node)
|
|
{
|
|
if (node == null) return null;
|
|
|
|
AttackDirection direction = GetAttackDirection();
|
|
if (direction != AttackDirection.Neutral && node.DirectionalVariants != null)
|
|
{
|
|
foreach (var variant in node.DirectionalVariants)
|
|
{
|
|
if (variant != null && variant.Direction == direction && variant.Action != null)
|
|
return variant.Action;
|
|
}
|
|
}
|
|
|
|
return node.Action;
|
|
}
|
|
|
|
// 무장 시 무기 데이터의 AttackRootNode 사용 (비어있으면 기본 _punchRootNode 폴백).
|
|
private ComboNode GetPunchRootNode()
|
|
{
|
|
WeaponData equipped = _weaponInventory != null ? _weaponInventory.CurrentWeapon : null;
|
|
if (equipped != null && equipped.AttackRootNode != null)
|
|
return equipped.AttackRootNode;
|
|
return _punchRootNode;
|
|
}
|
|
|
|
private bool CanPerformAction(ActionData data)
|
|
{
|
|
if (data == null) return false;
|
|
// 무기 조건 미충족이면 실행 불가 (콤보 트랜지션 무기별 분기 필터링).
|
|
if (!IsWeaponRequirementMet(data)) return false;
|
|
// 잡기 액션은 사정 거리 안에 적이 있어야만 실행. 없으면 입력 자체를 캔슬.
|
|
if (data.IsGrab && FindGrabTarget(data) == null) return false;
|
|
return true;
|
|
}
|
|
|
|
// 현재 장착 무기가 액션의 RequiredWeapon 조건을 만족하는지.
|
|
// 같은 입력의 콤보 트랜지션을 무기별로 깔아두면 이 검사로 맞는 것만 통과한다.
|
|
private bool IsWeaponRequirementMet(ActionData data)
|
|
{
|
|
WeaponData equipped = _weaponInventory != null ? _weaponInventory.CurrentWeapon : null;
|
|
return data.RequiredWeapon switch
|
|
{
|
|
WeaponRequirement.Unarmed => equipped == null,
|
|
WeaponRequirement.Sword => equipped != null && equipped.Type == WeaponType.Sword,
|
|
WeaponRequirement.Gun => equipped != null && equipped.Type == WeaponType.Gun,
|
|
_ => true, // Any
|
|
};
|
|
}
|
|
|
|
private void ExecuteMotionNode(ComboNode root)
|
|
{
|
|
if (IsStunned()) return; // 경직 중 대시/구르기 불가
|
|
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;
|
|
_actionAllowsMovement = data.CanMoveDuringAction;
|
|
_actionAllowsTurn = data.CanTurnDuringAction;
|
|
_lastAttackGizmoData = data;
|
|
_attackStartTime = Time.time;
|
|
_hitFired = false;
|
|
|
|
ClearActionLocks();
|
|
CaptureActionDirection(data);
|
|
PlayActionAnimation(data);
|
|
|
|
PlayActionVelocity(data);
|
|
|
|
LockMovementIfNeeded(data, preserveHorizontalVelocity);
|
|
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;
|
|
_actionAllowsMovement = false;
|
|
_actionAllowsTurn = 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;
|
|
_actionAllowsMovement = data.CanMoveDuringAction;
|
|
_actionAllowsTurn = data.CanTurnDuringAction;
|
|
FaceMotionDirection(data);
|
|
PlayActionAnimation(data);
|
|
PlayActionVelocity(data);
|
|
LockMovementIfNeeded(data, false);
|
|
LockFacingIfNeeded(data);
|
|
|
|
bool completed = false;
|
|
try
|
|
{
|
|
await MotionRoutine(data, token);
|
|
completed = true;
|
|
}
|
|
catch (System.OperationCanceledException)
|
|
{
|
|
_isMotionActive = false;
|
|
_actionAllowsMovement = false;
|
|
_actionAllowsTurn = false;
|
|
}
|
|
|
|
if (completed)
|
|
{
|
|
StopActionVelocity(data);
|
|
_isMotionActive = false;
|
|
_actionAllowsMovement = false;
|
|
_actionAllowsTurn = 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 (IsStunned()) return; // 경직 중 그라운드 파운드 불가
|
|
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;
|
|
_actionAllowsMovement = false;
|
|
_actionAllowsTurn = false;
|
|
CancelAndDispose(ref _attackCts);
|
|
CancelAndDispose(ref _actionVelocityCts);
|
|
_attackCooldownTimer = 0f;
|
|
_pendingInput = null;
|
|
_attackHitbox?.Deactivate();
|
|
ClearActionLocks();
|
|
}
|
|
|
|
private void CancelMotion()
|
|
{
|
|
_isMotionActive = false;
|
|
_actionAllowsMovement = false;
|
|
_actionAllowsTurn = 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) continue;
|
|
|
|
// 방향/무기 조건까지 반영해서 실제로 실행 가능한 트랜지션이 있는지 확인.
|
|
ActionData nextAction = ResolveNodeAction(transition.Next);
|
|
if (nextAction != null && CanPerformAction(nextAction))
|
|
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, hitStunDuration: data.HitStunDuration);
|
|
}
|
|
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 && _lastHitEnemy.IsGrabbable &&
|
|
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;
|
|
if (!enemy.IsGrabbable) 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;
|
|
|
|
Vector2 sourcePosition = _rb != null ? _rb.position : (Vector2)transform.position;
|
|
_attackHitbox.Activate(data, localPosition, hitVelocity, sourcePosition, hitTargetPosition, data.CorrectHitTargetY, _groundLayer.value, _enemyLayer);
|
|
|
|
SpawnHitEffect(data);
|
|
}
|
|
|
|
// hit 발동 시점에 이펙트 프리팹 생성.
|
|
// 위치는 HitEffectOffset으로 별도 지정 (hit 영역 Offset과 독립).
|
|
// 페이싱에 맞춰 좌우 반전하고, 설정된 수명만큼만 유지.
|
|
private void SpawnHitEffect(ActionData data)
|
|
{
|
|
if (data.HitEffectPrefab == null) return;
|
|
|
|
Vector2 localPosition = GetAttackLocalPosition(data.HitEffectOffset);
|
|
Vector3 worldPosition = transform.TransformPoint(localPosition);
|
|
GameObject effect = Instantiate(data.HitEffectPrefab, worldPosition, Quaternion.identity);
|
|
|
|
// 캐릭터가 왼쪽을 보면 이펙트도 X 반전.
|
|
if (_spriteRenderer != null && _spriteRenderer.flipX)
|
|
{
|
|
Vector3 scale = effect.transform.localScale;
|
|
scale.x *= -1f;
|
|
effect.transform.localScale = scale;
|
|
}
|
|
|
|
// 플레이어 자식으로 붙이면 캐릭터 따라 움직임 (검광처럼 캐릭터에 붙는 이펙트).
|
|
if (data.HitEffectAttachToPlayer)
|
|
effect.transform.SetParent(transform);
|
|
|
|
// 0보다 크면 자동 파괴. 0이면 프리팹이 스스로 정리한다고 가정 (파티클 Stop Action 등).
|
|
if (data.HitEffectLifetime > 0f)
|
|
Destroy(effect, data.HitEffectLifetime);
|
|
}
|
|
|
|
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)
|
|
{
|
|
// 액션 중 좌우 이동을 허용하는 액션(걸으면서 공격 등)은 입력 잠금을 걸지 않는다.
|
|
// 실제 이동 허용은 FixedUpdate의 _actionAllowsMovement 게이트가 담당.
|
|
if (data.CanMoveDuringAction)
|
|
{
|
|
_inputLockTimer = 0f;
|
|
_movementLockAction = null;
|
|
return;
|
|
}
|
|
|
|
// 이동 없는 공격은 직전 프레임의 걷기 속도를 이어받지 않게 한다.
|
|
if (!preserveHorizontalVelocity && data.Velocity == Vector2.zero)
|
|
_rb.linearVelocity = new Vector2(0f, _rb.linearVelocity.y);
|
|
|
|
// 액션 길이 전체를 보장: 애니메이션 길이와 무관하게 MotionDuration만큼 잠금.
|
|
_inputLockTimer = Mathf.Max(data.MotionDuration, 0.02f);
|
|
_movementLockAction = 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;
|
|
}
|
|
|
|
// 무기 장착 시 무기 데이터의 idle/walk를 우선 사용 (비어있으면 기본값으로 폴백).
|
|
WeaponData equipped = _weaponInventory != null ? _weaponInventory.CurrentWeapon : null;
|
|
string walk = equipped != null && !string.IsNullOrEmpty(equipped.WalkAnimationState)
|
|
? equipped.WalkAnimationState
|
|
: _walkAnimationState;
|
|
string idle = equipped != null && !string.IsNullOrEmpty(equipped.IdleAnimationState)
|
|
? equipped.IdleAnimationState
|
|
: _idleAnimationState;
|
|
string backpedal = equipped != null && !string.IsNullOrEmpty(equipped.BackpedalAnimationState)
|
|
? equipped.BackpedalAnimationState
|
|
: _backpedalAnimationState;
|
|
|
|
// 백페달(Down+좌우)이면 전용 애니메이션 — 뒤로 걷는 모션 (공격 중이 아닐 때만 표시).
|
|
if (IsBackpedaling() && !string.IsNullOrEmpty(backpedal))
|
|
return backpedal;
|
|
|
|
if (_moveInputX != 0f && !string.IsNullOrEmpty(walk))
|
|
return walk;
|
|
|
|
return idle;
|
|
}
|
|
|
|
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)
|
|
{
|
|
float groundedFallSpeed = Mathf.Max(_gravity * Time.fixedDeltaTime, -_maxFallSpeed);
|
|
_rb.linearVelocity = new Vector2(_rb.linearVelocity.x, groundedFallSpeed);
|
|
return;
|
|
}
|
|
|
|
float newY = Mathf.Max(vy + _gravity * Time.fixedDeltaTime, -_maxFallSpeed);
|
|
_rb.linearVelocity = new Vector2(_rb.linearVelocity.x, newY);
|
|
}
|
|
|
|
private bool CheckGrounded()
|
|
{
|
|
return TryGetGroundHit(Mathf.Max(_groundCheckDistance, 0f), out _);
|
|
}
|
|
|
|
private void ResolveKinematicCollisions()
|
|
{
|
|
int solidMask = _groundLayer.value;
|
|
if (solidMask == 0) return;
|
|
if (_bodyCollider == null || !_bodyCollider.enabled) return;
|
|
|
|
float dt = Time.fixedDeltaTime;
|
|
if (dt <= 0f) return;
|
|
|
|
Vector2 velocity = _rb.linearVelocity;
|
|
|
|
velocity.x = ResolveAxisVelocity(velocity.x, Vector2.right * Mathf.Sign(velocity.x), solidMask, dt, out _);
|
|
|
|
float originalY = velocity.y;
|
|
velocity.y = ResolveAxisVelocity(velocity.y, Vector2.up * Mathf.Sign(velocity.y), solidMask, dt, out bool blockedY);
|
|
|
|
if (blockedY && originalY <= 0f)
|
|
{
|
|
_isGrounded = true;
|
|
_jumpsUsed = 0;
|
|
}
|
|
|
|
_rb.linearVelocity = velocity;
|
|
}
|
|
|
|
private float ResolveAxisVelocity(float axisVelocity, Vector2 direction, int solidMask, float dt, out bool blocked)
|
|
{
|
|
blocked = false;
|
|
|
|
if (Mathf.Abs(axisVelocity) <= 0.001f) return axisVelocity;
|
|
|
|
float skinWidth = Mathf.Max(_collisionSkinWidth, 0f);
|
|
float moveDistance = Mathf.Abs(axisVelocity) * dt;
|
|
float castDistance = moveDistance + skinWidth;
|
|
|
|
if (!TryGetBodyHit(direction, castDistance, solidMask, out RaycastHit2D hit))
|
|
return axisVelocity;
|
|
|
|
blocked = true;
|
|
float allowedDistance = Mathf.Max(hit.distance - skinWidth, 0f);
|
|
return Mathf.Sign(axisVelocity) * (allowedDistance / dt);
|
|
}
|
|
|
|
private bool TryGetBodyHit(Vector2 direction, float castDistance, int solidMask, out RaycastHit2D closestHit)
|
|
{
|
|
closestHit = default;
|
|
|
|
ContactFilter2D filter = CreateSolidContactFilter(solidMask);
|
|
float closestDistance = float.PositiveInfinity;
|
|
bool hasHit = false;
|
|
|
|
_castResults.Clear();
|
|
int hitCount = _bodyCollider.Cast(direction, filter, _castResults, castDistance);
|
|
for (int i = 0; i < hitCount; i++)
|
|
{
|
|
RaycastHit2D hit = _castResults[i];
|
|
if (!IsBodyBlockingHit(direction, hit)) continue;
|
|
if (hit.distance >= closestDistance) continue;
|
|
|
|
closestDistance = hit.distance;
|
|
closestHit = hit;
|
|
hasHit = true;
|
|
}
|
|
|
|
return hasHit;
|
|
}
|
|
|
|
private bool IsBodyBlockingHit(Vector2 direction, RaycastHit2D hit)
|
|
{
|
|
if (Mathf.Abs(direction.x) > 0f)
|
|
{
|
|
return direction.x > 0f
|
|
? hit.normal.x < -0.1f
|
|
: hit.normal.x > 0.1f;
|
|
}
|
|
|
|
if (direction.y > 0f)
|
|
return hit.normal.y < -0.1f;
|
|
|
|
return hit.normal.y >= _groundNormalMinY;
|
|
}
|
|
|
|
private bool TryGetGroundHit(float checkDistance, out RaycastHit2D closestHit)
|
|
{
|
|
closestHit = default;
|
|
|
|
int solidMask = _groundLayer.value;
|
|
if (solidMask == 0) return false;
|
|
if (_groundCheckCollider == null || !_groundCheckCollider.enabled) return false;
|
|
|
|
ContactFilter2D filter = CreateSolidContactFilter(solidMask);
|
|
Bounds bounds = _groundCheckCollider.bounds;
|
|
int rayCount = 3;
|
|
float inset = Mathf.Min(Mathf.Max(_groundCheckRayInset, 0f), bounds.extents.x);
|
|
float leftX = bounds.min.x + inset;
|
|
float rightX = bounds.max.x - inset;
|
|
float originLift = bounds.size.y + Mathf.Max(_collisionSkinWidth, 0f) + 0.01f;
|
|
float originY = bounds.min.y + originLift;
|
|
float rayDistance = checkDistance + originLift;
|
|
float closestDistance = float.PositiveInfinity;
|
|
bool hasHit = false;
|
|
|
|
for (int i = 0; i < rayCount; i++)
|
|
{
|
|
float t = rayCount == 1 ? 0.5f : i / (float)(rayCount - 1);
|
|
Vector2 origin = new Vector2(Mathf.Lerp(leftX, rightX, t), originY);
|
|
|
|
_castResults.Clear();
|
|
int hitCount = Physics2D.Raycast(origin, Vector2.down, filter, _castResults, rayDistance);
|
|
for (int j = 0; j < hitCount; j++)
|
|
{
|
|
RaycastHit2D hit = _castResults[j];
|
|
if (hit.normal.y < _groundNormalMinY) continue;
|
|
if (hit.distance >= closestDistance) continue;
|
|
|
|
closestDistance = hit.distance;
|
|
closestHit = hit;
|
|
hasHit = true;
|
|
}
|
|
}
|
|
|
|
return hasHit;
|
|
}
|
|
|
|
private static ContactFilter2D CreateSolidContactFilter(int layerMask)
|
|
{
|
|
return new ContactFilter2D
|
|
{
|
|
useLayerMask = true,
|
|
layerMask = layerMask,
|
|
useTriggers = false
|
|
};
|
|
}
|
|
|
|
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);
|
|
|
|
Color fillColor = new Color(1f, 0.2f, 0.2f, alpha * 0.35f);
|
|
Color wireColor = new Color(1f, 0f, 0f, alpha);
|
|
|
|
if (_lastHitData.Shape == HitShape.Box)
|
|
{
|
|
Vector3 size = new Vector3(_lastHitData.HitSize.x, _lastHitData.HitSize.y, 0.01f);
|
|
Gizmos.color = fillColor;
|
|
Gizmos.DrawCube(_lastHitCenter, size);
|
|
Gizmos.color = wireColor;
|
|
Gizmos.DrawWireCube(_lastHitCenter, size);
|
|
}
|
|
else
|
|
{
|
|
float radius = _lastHitData.Radius;
|
|
Gizmos.color = fillColor;
|
|
Gizmos.DrawSphere(_lastHitCenter, radius);
|
|
Gizmos.color = wireColor;
|
|
Gizmos.DrawWireSphere(_lastHitCenter, radius);
|
|
}
|
|
}
|
|
|
|
private void OnDrawGizmosSelected()
|
|
{
|
|
if (_groundCheckCollider == null || !_groundCheckCollider.enabled) return;
|
|
|
|
Gizmos.color = _isGrounded ? Color.green : Color.red;
|
|
Bounds bounds = _groundCheckCollider.bounds;
|
|
int rayCount = Mathf.Max(3, 1);
|
|
float inset = Mathf.Min(Mathf.Max(_groundCheckRayInset, 0f), bounds.extents.x);
|
|
float leftX = bounds.min.x + inset;
|
|
float rightX = bounds.max.x - inset;
|
|
float bottomY = bounds.min.y;
|
|
float checkY = bottomY - Mathf.Max(_groundCheckDistance, 0f);
|
|
|
|
for (int i = 0; i < rayCount; i++)
|
|
{
|
|
float t = rayCount == 1 ? 0.5f : i / (float)(rayCount - 1);
|
|
float x = Mathf.Lerp(leftX, rightX, t);
|
|
Gizmos.DrawLine(new Vector3(x, bottomY, 0f), new Vector3(x, checkY, 0f));
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// 외부(보스 스킬 등)에서 플레이어의 지면 접촉 여부를 조회.
|
|
public bool IsGrounded => _isGrounded;
|
|
|
|
// 외부에서 플레이어를 강제로 띄우거나 밀어낼 때 사용 (지진 스킬 등).
|
|
// 넉백 = 지정 속도로 띄우고 넉백 모션으로 경직시킨다. 키네마틱 RB라 속도 직접 설정.
|
|
public void Launch(Vector2 velocity)
|
|
{
|
|
if (_rb == null) return;
|
|
_rb.linearVelocity = velocity;
|
|
EnterHitstun(_knockbackAnimationState, _knockbackDuration);
|
|
}
|
|
|
|
// 피격 경직 진행 여부. 경직 중에는 이동/방향전환/점프/공격/대시 입력을 모두 잠근다.
|
|
private bool IsStunned() => _isStunned;
|
|
|
|
// 피격 경직(범용). 지정 모션을 재생하고 duration초 동안 입력을 잠근다. 재호출 시 갱신(refresh).
|
|
// 진행 중이던 플레이어 액션은 취소한다 (피격이 우선).
|
|
// duration은 호출자가 모션 클립 길이에 맞춰 넘긴다 (넉백은 _knockbackDuration).
|
|
// 일반 경직 / 넉백 등 종류별로 animationState만 바꿔 호출하면 된다.
|
|
public void EnterHitstun(string animationState, float duration)
|
|
{
|
|
_isStunned = true;
|
|
_hitstunTimer = duration; // 경직 시간 (재호출 시 갱신/refresh)
|
|
|
|
CancelAttack(); // 진행 중이던 공격/모션을 끊는다 (피격이 우선)
|
|
CancelMotion();
|
|
|
|
// 경직 모션은 액션 애니메이션 취급 — _isInActionAnimation으로 locomotion이 못 덮게 막는다.
|
|
if (_anim != null && !string.IsNullOrEmpty(animationState))
|
|
{
|
|
_isInActionAnimation = true;
|
|
_activeBaseState = null;
|
|
_anim.speed = 1f;
|
|
_anim.Play(animationState);
|
|
_anim.Update(0f);
|
|
}
|
|
}
|
|
|
|
// 경직 타이머 카운트다운. 클립을 다 재생했고(타이머 종료) + 착지했을 때만 경직을 해제한다.
|
|
// 넉백으로 공중에 떠 있는 동안엔 경직/모션을 유지 → 점프 모션으로 안 끊긴다.
|
|
private void TickHitstun()
|
|
{
|
|
if (!_isStunned) return;
|
|
|
|
if (_hitstunTimer > 0f)
|
|
_hitstunTimer -= Time.fixedDeltaTime;
|
|
|
|
|
|
if (_hitstunTimer <= 0f && _isGrounded && !IsActionActive())
|
|
{
|
|
_isStunned = false;
|
|
_isInActionAnimation = false;
|
|
_activeBaseState = null;
|
|
_hitstunTimer = 0f;
|
|
}
|
|
}
|
|
|
|
public void TakeDamage(int amount, Vector2 hitVelocity = default, string hitReactionAnimationState = null, Vector2? hitTargetPosition = null, bool correctHitTargetY = false, int hitPositionSolidMask = 0, float hitPositionCorrectionDuration = 0, float hitStunDuration = -1f)
|
|
{
|
|
if (_health == null || _health.IsDead) return;
|
|
|
|
_health.TakeDamage(amount);
|
|
|
|
if(_health.CurrentHealth <= 0)
|
|
{
|
|
PlayerDead();
|
|
}
|
|
}
|
|
|
|
public void PlayerDead()
|
|
{
|
|
//재시작 UI 띄우기
|
|
GameManager.Instance.Restart.ShowRestart();
|
|
|
|
|
|
|
|
PlayerWeaponInventory.Instance.ClearWeaponInven();
|
|
|
|
Destroy(gameObject);
|
|
}
|
|
}
|