using UnityEngine; // 유니티 엔진의 기본 기능을 불러올거에요 -> UnityEngine을 using System.Collections; // 코루틴 기능을 사용할거에요 -> System.Collections를 [RequireComponent(typeof(CharacterController))] // 컴포넌트를 강제로 추가할거에요 -> CharacterController를 public class PlayerMovement : MonoBehaviour // 클래스를 선언할거에요 -> PlayerMovement를 { [Header("=== 참조 ===")] // 인스펙터 창에 제목을 표시할거에요 -> 참조를 [SerializeField] private Stats stats; // 변수를 선언할거에요 -> 이동 속도 Stats를 [SerializeField] private PlayerHealth health; // 변수를 선언할거에요 -> 체력/피격 상태 PlayerHealth를 [SerializeField] private PlayerAnimator pAnim; // 변수를 선언할거에요 -> 애니메이션 제어 PlayerAnimator를 [SerializeField] private PlayerAttack attackScript; // 변수를 선언할거에요 -> 공격 상태 PlayerAttack를 [SerializeField] private PlayerSoundFX soundFX; // 변수를 선언할거에요 -> 사운드 스크립트를 [Header("=== CharacterController ===")] // 인스펙터 창에 제목을 표시할거에요 private CharacterController _controller; // 변수를 선언할거에요 -> 이동 담당 CharacterController를 [Header("=== 대시 설정 ===")] // 인스펙터 창에 제목을 표시할거에요 [SerializeField] private float dashDistance = 3f; // 변수를 선언할거에요 -> 대시 거리를 [SerializeField] private float dashDuration = 0.08f; // 변수를 선언할거에요 -> 대시 지속 시간을 [SerializeField] private float dashCooldown = 1.5f; // 변수를 선언할거에요 -> 대시 쿨타임을 [Header("=== 차징 감속 설정 ===")] // 인스펙터 창에 제목을 표시할거에요 [Range(0.1f, 1f)] [SerializeField] private float minSpeedMultiplier = 0.3f; // 변수를 선언할거에요 -> 차징 중 최소 속도 배율을 [Header("=== 중력 설정 ===")] // 인스펙터 창에 제목을 표시할거에요 [SerializeField] private float gravity = -20f; // 변수를 선언할거에요 -> 중력 가속도를 [Header("=== 충돌 설정 ===")] // 인스펙터 창에 제목을 표시할거에요 [Tooltip("무기 레이어 (이 레이어와는 충돌하지 않음)")] [SerializeField] private LayerMask weaponLayer; // 변수를 선언할거에요 -> 충돌 무시할 무기 레이어를 // ───────────────────────────────────────────────────────── // ⭐ 경직 시스템 // ───────────────────────────────────────────────────────── [Header("=== 경직 설정 ===")] // 인스펙터 창에 제목을 표시할거에요 -> 경직 설정을 [Tooltip("돌진 몬스터 피격 시 경직 지속 시간 (초)")] [SerializeField] private float staggerDuration = 0.8f; // 변수를 선언할거에요 -> 경직 지속 시간을 (Inspector에서 조절 가능) private bool _isStaggered = false; // 변수를 선언할거에요 -> 현재 경직 상태인지 여부를 private Coroutine _staggerCoroutine = null; // 변수를 선언할거에요 -> 경직 코루틴 핸들을 (중복 방지용) public bool IsStaggered => _isStaggered; // 프로퍼티를 선언할거에요 -> 경직 상태를 외부에서 읽을 수 있도록 public float StaggerDuration => staggerDuration; // 프로퍼티를 선언할거에요 -> 경직 지속 시간을 외부에서 읽을 수 있도록 [SerializeField] private StaggerIndicator staggerIndicator; // 변수를 선언할거에요 -> 경직 UI 연동용을 // ───────────────────────────────────────────────────────── private Vector3 _moveDir; // 변수를 선언할거에요 -> 입력 이동 방향을 private bool _isSprinting; // 변수를 선언할거에요 -> 달리기 여부를 private bool _isDashing; // 변수를 선언할거에요 -> 대시 여부를 private float _lastDashTime; // 변수를 선언할거에요 -> 마지막 대시 시간을 private float _verticalVelocity; // 변수를 선언할거에요 -> 수직 낙하 속도를 // ── 넉백 전용 ────────────────────────────────────────────────── // CharacterController는 Rigidbody가 없어서 AddForce가 불가해요. // BossIronBall이 ApplyKnockback()을 호출하면 여기에 수평 속도를 저장하고 // ApplyGravityOnly() 안에서 매 프레임 _controller.Move()에 합산해요. private Vector3 _knockbackVelocity = Vector3.zero; // 변수를 선언할거에요 -> 현재 넉백 수평 속도를 (ApplyGravityOnly에서 합산됨) [Header("=== 애니메이션 보간 ===")] // 인스펙터 창에 제목을 표시할거에요 [SerializeField] private float animSmoothSpeed = 8f; // 변수를 선언할거에요 -> 애니메이션 보간 속도를 private float _currentAnimVal = 0f; // 변수를 초기화할거에요 -> 현재 보간 애니메이션 값을 private float _initialYPosition; // 변수를 선언할거에요 -> 시작 높이를 private void Awake() // 함수를 실행할거에요 -> 초기화를 { _controller = GetComponent(); // 가져올거에요 -> CharacterController를 if (_controller == null) // 조건이 맞으면 실행할거에요 -> 없으면 Debug.LogError("[PlayerMovement] CharacterController가 필요합니다!"); // 에러를 출력할거에요 _initialYPosition = transform.position.y; // 저장할거에요 -> 초기 Y 위치를 } private void Start() // 함수를 실행할거에요 -> 시작 시 { SetupLayerCollision(); // 실행할거에요 -> 레이어 충돌 설정을 } // ───────────────────────────────────────────────────────── // ⭐ 경직 적용 함수 — ChargeMonster 무기에서 호출 // // 사용법: // hit.GetComponent()?.ApplyStagger(); // 또는 duration 직접 지정: // hit.GetComponent()?.ApplyStagger(1.2f); // ───────────────────────────────────────────────────────── public void ApplyStagger(float duration = -1f) // 함수를 선언할거에요 -> 외부에서 경직을 걸 때 호출할 { float actualDuration = duration > 0f ? duration : staggerDuration; // 결정할거에요 -> 인자가 있으면 그 값, 없으면 Inspector 기본값을 if (_staggerCoroutine != null) // 조건이 맞으면 실행할거에요 -> 이미 경직 중이면 StopCoroutine(_staggerCoroutine); // 멈출거에요 -> 이전 경직 코루틴을 (덮어쓰기) _staggerCoroutine = StartCoroutine(StaggerRoutine(actualDuration)); // 시작할거에요 -> 새 경직 코루틴을 if (staggerIndicator != null) staggerIndicator.NotifyStagger(actualDuration); // 알릴거에요 -> UI에 경직 시작을 } private IEnumerator StaggerRoutine(float duration) // 코루틴을 정의할거에요 -> 경직 지속 로직을 { _isStaggered = true; // 설정할거에요 -> 경직 상태를 true로 if (pAnim != null) pAnim.UpdateMove(0f); // 멈출거에요 -> 이동 애니메이션을 yield return new WaitForSeconds(duration); // 기다릴거에요 -> 경직 시간만큼 _isStaggered = false; // 해제할거에요 -> 경직 상태를 _staggerCoroutine = null; // 초기화할거에요 -> 코루틴 핸들을 } // ────────────────────────────────────────────────────────────── // ⭐ 넉백 시스템 — BossIronBall 등 무거운 충격에서 호출 // // 사용법 (BossIronBall 등): // Vector3 dir = (playerRoot.position - ballPos).normalized; // dir.y = 0f; // 수평 방향만 (y는 아래에서 따로 처리) // playerMovement.ApplyKnockback(dir, 8f, 2f, 0.5f); // // 내부 동작: // ① _knockbackVelocity 에 초기 수평 속도 저장 // ② _verticalVelocity 에 위로 튀기는 초기값 저장 // ③ _isStaggered = true → Update() 일반 이동 차단 // ④ ApplyGravityOnly() 가 매 프레임 _knockbackVelocity + 중력 합산 // ⑤ KnockbackRoutine 이 수평 속도를 duration 동안 감속 후 해제 // ────────────────────────────────────────────────────────────── /// /// 넉백을 적용해요. CharacterController 기반 플레이어 전용. /// /// 수평 방향 (Y=0 권장, Normalize 안 해도 내부에서 처리) /// 수평 날아가는 세기 (m/s). 추천: 6~10 /// 위로 튀기는 세기 (m/s). 추천: 2~4. 0이면 수평만 /// 넉백 + 경직 지속 시간 (초). 추천: 0.4~0.7 public void ApplyKnockback(Vector3 direction, float horizontalForce, float upwardForce = 2f, float duration = 0.5f) // 함수를 선언할거에요 -> 외부에서 넉백을 줄 때 호출할 { if (_staggerCoroutine != null) // 조건이 맞으면 실행할거에요 -> 이미 경직/넉백 중이면 StopCoroutine(_staggerCoroutine); // 멈출거에요 -> 이전 코루틴을 (새 넉백으로 덮어쓰기) Vector3 horizontal = new Vector3(direction.x, 0f, direction.z).normalized; // 계산할거에요 -> 수평 방향 벡터를 (Y성분 제거 후 정규화) _knockbackVelocity = horizontal * horizontalForce; // 저장할거에요 -> 수평 넉백 초기 속도를 _verticalVelocity = upwardForce; // 저장할거에요 -> 위로 튀기는 초기 수직 속도를 _staggerCoroutine = StartCoroutine(KnockbackRoutine(horizontal, horizontalForce, duration)); // 시작할거에요 -> 넉백 감속 코루틴을 if (staggerIndicator != null) staggerIndicator.NotifyStagger(duration); // 알릴거에요 -> UI에 경직 시작을 } private IEnumerator KnockbackRoutine(Vector3 dir, float initialForce, float duration) // 코루틴을 정의할거에요 -> 넉백 수평 속도 감속 로직을 { _isStaggered = true; // 설정할거에요 -> 경직 상태를 (Update 일반 이동 차단) if (pAnim != null) pAnim.UpdateMove(0f); // 멈출거에요 -> 이동 애니메이션을 float elapsed = 0f; // 초기화할거에요 -> 경과 시간을 while (elapsed < duration) // 반복할거에요 -> 넉백 지속 시간 동안 { float t = elapsed / duration; // 계산할거에요 -> 0→1 진행 비율을 _knockbackVelocity = dir * Mathf.Lerp(initialForce, 0f, t); // 줄일거에요 -> 수평 속도를 (처음엔 빠름, 끝엔 0) elapsed += Time.deltaTime; // 더할거에요 -> 경과 시간을 yield return null; // 기다릴거에요 -> 다음 프레임까지 } _knockbackVelocity = Vector3.zero; // 초기화할거에요 -> 넉백 속도를 완전히 0으로 _isStaggered = false; // 해제할거에요 -> 경직 상태를 _staggerCoroutine = null; // 초기화할거에요 -> 코루틴 핸들을 } // ────────────────────────────────────────────────────────────── // ⭐ 흡인 효과 — 보스 스매시 공격 시 플레이어를 공격 범위로 끌어당겨요 // // 사용법 (보스 코루틴에서 매 프레임 호출): // playerMovement.ApplySuction(smashCenter, 3f); // // 내부 동작: // ① 공격 중심 → 플레이어 방향의 수평 벡터 계산 // ② pullSpeed * deltaTime 만큼 CharacterController.Move()로 이동 // ③ 플레이어 자신의 이동과 합산되어 "저항하며 끌려가는" 느낌 연출 // ④ 경직/넉백 중이면 흡인 무시 (넉백이 우선) // ⑤ 이미 중심보다 가까우면 흡인하지 않음 (중심 통과 방지) // ────────────────────────────────────────────────────────────── /// /// 흡인 효과를 적용해요. 매 프레임 보스 코루틴에서 호출하세요. /// CharacterController.Move()를 추가 호출하므로 일반 이동과 합산됩니다. /// /// 끌어당길 목표 지점 (보스 공격 판정 중심) /// 초당 끌어당기는 속도 (m/s). 추천: 2~5 /// 이 거리 이내면 흡인 중단 (중심 통과 방지). 추천: 0.5 public void ApplySuction(Vector3 pullCenter, float pullSpeed, float minDistance = 0.5f) // 함수를 선언할거에요 -> 외부에서 흡인을 줄 때 호출할 { if (_controller == null) return; // 중단할거에요 -> 컨트롤러 없으면 if (_isStaggered) return; // 중단할거에요 -> 넉백/경직 중이면 (넉백이 흡인보다 우선) Vector3 toCenter = pullCenter - transform.position; // 계산할거에요 -> 공격 중심까지의 벡터를 toCenter.y = 0f; // 제거할거에요 -> 수직 성분을 (수평 이동만) float dist = toCenter.magnitude; // 계산할거에요 -> 공격 중심까지의 수평 거리를 if (dist < minDistance) return; // 중단할거에요 -> 이미 충분히 가까우면 (중심 통과 방지) Vector3 pullDir = toCenter.normalized; // 정규화할거에요 -> 끌어당길 방향을 (중심 방향) // 거리가 가까울수록 흡인력이 강해지는 비선형 보간 (역거리 가중) // → 멀리 있으면 살짝 끌리고, 가까이 오면 강하게 빨려드는 느낌 float distFactor = Mathf.Clamp01(1f - (dist / 10f)); // 계산할거에요 -> 거리 비율을 (10m 기준, 가까울수록 1에 가까움) float adjustedSpeed = pullSpeed * (0.3f + 0.7f * distFactor); // 계산할거에요 -> 최종 흡인 속도를 (기본 30% + 거리 보정 70%) Vector3 suctionMotion = pullDir * adjustedSpeed * Time.deltaTime; // 계산할거에요 -> 이번 프레임 흡인 이동량을 _controller.Move(suctionMotion); // 이동할거에요 -> 공격 중심 방향으로 끌어당기기 } private void SetupLayerCollision() // 함수를 선언할거에요 -> 레이어 충돌 설정을 { int playerLayer = gameObject.layer; // 가져올거에요 -> 내 레이어 번호를 if (weaponLayer != 0) // 조건이 맞으면 실행할거에요 -> 무기 레이어가 설정됐으면 { int weaponLayerIndex = GetLayerFromMask(weaponLayer); // 변환할거에요 -> 마스크를 인덱스로 if (weaponLayerIndex >= 0) // 조건이 맞으면 실행할거에요 -> 유효한 인덱스면 { Physics.IgnoreLayerCollision(playerLayer, weaponLayerIndex, true); // 무시할거에요 -> 플레이어-무기 충돌을 Debug.Log($"[PlayerMovement] {LayerMask.LayerToName(playerLayer)}와 {LayerMask.LayerToName(weaponLayerIndex)} 간 충돌 무시 설정 완료"); // 출력할거에요 } } } private int GetLayerFromMask(LayerMask mask) // 함수를 선언할거에요 -> 비트마스크를 인덱스로 변환할 { int layerNumber = 0; // 초기화할거에요 -> 레이어 번호를 0으로 int layer = mask.value; // 가져올거에요 -> 마스크 정수값을 while (layer > 1) // 반복할거에요 -> 1보다 클 동안 { layer = layer >> 1; // 밀거에요 -> 비트를 오른쪽으로 layerNumber++; // 증가할거에요 -> 레이어 번호를 } return layerNumber; // 반환할거에요 -> 계산된 레이어 번호를 } public void SetMoveInput(Vector3 dir, bool sprint) // 함수를 선언할거에요 -> 이동 입력을 받을 { _moveDir = dir; // 저장할거에요 -> 이동 방향을 _isSprinting = sprint; // 저장할거에요 -> 달리기 여부를 } public void AttemptDash() // 함수를 선언할거에요 -> 대시를 시도할 { if (CanDash()) StartCoroutine(DashRoutine()); // 실행할거에요 -> 가능하면 대시 코루틴을 } private bool CanDash() // 함수를 선언할거에요 -> 대시 가능 여부를 반환할 { bool isCooldownOver = Time.time >= _lastDashTime + dashCooldown; // 확인할거에요 -> 쿨타임이 지났는지를 bool isAlive = health != null && !health.IsDead; // 확인할거에요 -> 살아있는지를 return isCooldownOver && !_isDashing && isAlive; // 반환할거에요 -> 모두 만족할 때만 true를 } private void Update() // 함수를 실행할거에요 -> 매 프레임마다 { // 1. 행동 불가 상태 체크 // ⭐ _isStaggered 추가 — 경직 중에도 이동 차단 if (health != null && (health.IsDead || health.isHit || _isDashing || _isStaggered)) // 조건이 맞으면 실행할거에요 -> 죽었거나, 피격이거나, 대시 중이거나, 경직 중이면 { // ⭐ 경직 중 공격 강제 취소 — 차징 포함 if (_isStaggered && attackScript != null) // 조건이 맞으면 실행할거에요 -> 경직 상태이고 공격 스크립트가 있으면 { attackScript.CancelCharging(); // 취소할거에요 -> 차징을 attackScript.IsAttacking = false; // 해제할거에요 -> 공격 상태를 } if (pAnim != null && !health.IsDead) pAnim.UpdateMove(0f); // 멈출거에요 -> 이동 애니메이션을 ApplyGravityOnly(); // 적용할거에요 -> 중력만 return; // 중단할거에요 -> 이동 코드를 } // 2. 공격 중 이동 차단 if (attackScript != null && attackScript.IsAttacking) // 조건이 맞으면 실행할거에요 -> 공격 중이면 { if (pAnim != null) pAnim.UpdateMove(0f); // 멈출거에요 -> 이동 애니메이션을 ApplyGravityOnly(); // 적용할거에요 -> 중력만 return; // 중단할거에요 -> 이동 코드를 } // 3. 이동 속도 계산 float speed = _isSprinting ? stats.CurrentRunSpeed : stats.CurrentMoveSpeed; // 결정할거에요 -> 달리기/걷기 속도를 if (attackScript != null && attackScript.IsCharging) // 조건이 맞으면 실행할거에요 -> 차징 중이면 { float speedReduction = Mathf.Lerp(1.0f, minSpeedMultiplier, attackScript.ChargeProgress); // 계산할거에요 -> 차징 진행도에 따른 감속 배율을 speed *= speedReduction; // 적용할거에요 -> 감속을 } // 4. 최종 이동 벡터 계산 Vector3 motion = _moveDir * speed * Time.deltaTime; // 계산할거에요 -> 이번 프레임 이동량을 ApplyGravity(); // 계산할거에요 -> 수직 속도를 motion.y = _verticalVelocity * Time.deltaTime; // 넣을거에요 -> 수직 이동량을 _controller.Move(motion); // 이동할거에요 -> 캐릭터 컨트롤러를 UpdateAnimation(); // 갱신할거에요 -> 이동 애니메이션을 } private void ApplyGravity() // 함수를 선언할거에요 -> 중력을 계산할 { if (_controller.isGrounded) // 조건이 맞으면 실행할거에요 -> 땅에 닿아있으면 _verticalVelocity = -2f; // 설정할거에요 -> 바닥 고정 속도를 else // 조건이 틀리면 실행할거에요 -> 공중이면 _verticalVelocity += gravity * Time.deltaTime; // 더할거에요 -> 중력 가속도를 } private void ApplyGravityOnly() // 함수를 선언할거에요 -> 중력(+넉백)을 적용할 { ApplyGravity(); // 계산할거에요 -> 수직 속도를 (중력 누적) // ⭐ 넉백 수평 속도도 합산해요. // _knockbackVelocity 가 Vector3.zero 이면 기존 동작과 완전히 동일해요. Vector3 motion = new Vector3( // 만들거에요 -> 합산 이동 벡터를 _knockbackVelocity.x, // 수평 X: 넉백 속도 _verticalVelocity, // 수직 Y: 중력 속도 _knockbackVelocity.z // 수평 Z: 넉백 속도 ) * Time.deltaTime; // 프레임 속도 보정 _controller.Move(motion); // 이동할거에요 -> 넉백 + 중력 합산 벡터로 } private void UpdateAnimation() // 함수를 선언할거에요 -> 이동 애니메이션을 갱신할 { if (pAnim == null || stats == null) return; // 중단할거에요 -> 필수 참조 없으면 Vector3 horizontalVel = _controller.velocity; // 가져올거에요 -> 현재 속도를 horizontalVel.y = 0f; // 무시할거에요 -> 수직 속도를 float actualSpeed = horizontalVel.magnitude; // 계산할거에요 -> 수평 속도 크기를 float walkSpeed = stats.CurrentMoveSpeed; // 가져올거에요 -> 걷기 속도를 float runSpeed = stats.CurrentRunSpeed; // 가져올거에요 -> 달리기 속도를 float targetAnimVal = 0f; // 초기화할거에요 -> 목표 애니메이션 값을 if (actualSpeed > 0.1f) // 조건이 맞으면 실행할거에요 -> 움직이고 있으면 { if (actualSpeed <= walkSpeed) // 조건이 맞으면 실행할거에요 -> 걷기 이하면 targetAnimVal = Mathf.Lerp(0f, 0.5f, actualSpeed / walkSpeed); // 보간할거에요 -> 0~0.5로 else // 조건이 틀리면 실행할거에요 -> 달리기면 targetAnimVal = Mathf.Lerp(0.5f, 1.0f, (actualSpeed - walkSpeed) / (runSpeed - walkSpeed)); // 보간할거에요 -> 0.5~1.0으로 } if (attackScript != null && attackScript.IsCharging) // 조건이 맞으면 실행할거에요 -> 차징 중이면 targetAnimVal *= 0.5f; // 줄일거에요 -> 절반으로 _currentAnimVal = Mathf.Lerp(_currentAnimVal, targetAnimVal, Time.deltaTime * animSmoothSpeed); // 보간할거에요 -> 부드럽게 pAnim.UpdateMove(_currentAnimVal); // 전달할거에요 -> 보간된 애니메이션 값을 } private IEnumerator DashRoutine() // 코루틴을 정의할거에요 -> 대시 로직을 { _isDashing = true; // 설정할거에요 -> 대시 상태를 _lastDashTime = Time.time; // 저장할거에요 -> 대시 시작 시간을 if (soundFX != null) soundFX.PlayDash(); // 재생할거에요 -> 대시 소리를 if (PlayerBehaviorTracker.Instance != null) // 조건이 맞으면 실행할거에요 -> 트래커가 있으면 PlayerBehaviorTracker.Instance.RecordDodge(); // 기록할거에요 -> 회피를 Vector3 dashDir = _moveDir.sqrMagnitude > 0.001f ? _moveDir : -transform.forward; // 결정할거에요 -> 대시 방향을 if (health != null) health.isInvincible = true; // 켤거에요 -> 무적을 float startTime = Time.time; // 저장할거에요 -> 대시 시작 시간을 while (Time.time < startTime + dashDuration) // 반복할거에요 -> 대시 시간 동안 { float speed = dashDistance / dashDuration; // 계산할거에요 -> 대시 속도를 Vector3 dashMotion = dashDir * speed * Time.deltaTime; // 계산할거에요 -> 이동량을 _controller.Move(dashMotion); // 이동할거에요 -> 대시 방향으로 yield return null; // 대기할거에요 -> 다음 프레임까지 } if (health != null) health.isInvincible = false; // 끌거에요 -> 무적을 _isDashing = false; // 해제할거에요 -> 대시 상태를 } public bool IsGrounded() => _controller.isGrounded; // 반환할거에요 -> 땅 접지 여부를 public bool IsDashing() => _isDashing; // 반환할거에요 -> 대시 여부를 public float GetCurrentSpeed() => _controller.velocity.magnitude; // 반환할거에요 -> 현재 이동 속도를 }