diff --git a/Assets/02_Scripts/Combat/ActionData.cs b/Assets/02_Scripts/Combat/ActionData.cs index f2d5a45..bd530f2 100644 --- a/Assets/02_Scripts/Combat/ActionData.cs +++ b/Assets/02_Scripts/Combat/ActionData.cs @@ -1,52 +1,71 @@ using UnityEngine; using UnityEngine.Serialization; +// ============================================================================ +// ActionData +// ---------------------------------------------------------------------------- +// 모든 액션(공격/모션/잡기/그라운드파운드)의 데이터를 표현하는 ScriptableObject. +// .asset 파일로 만들어 Inspector에서 디자이너가 직접 편집 가능. +// PlayerController는 코드 없이 데이터만 바꿔서 새 액션 추가 가능. +// +// 액션 종류 구분: +// - HasMotion = true → 이동 액션 (Dash/Roll 등) +// - HasHit = true → 데미지 액션 (펀치/킥 등) +// - IsGrab = true → 잡기 (가까운 적을 끌어당김) +// - 위 세 가지 조합 가능 → "전진하면서 때리는 콤보" 등 +// ============================================================================ [CreateAssetMenu(fileName = "ActionData", menuName = "Combat/ActionData")] public class ActionData : ScriptableObject { + // [FormerlySerializedAs]: 옛 이름의 직렬화 데이터도 자동 로드 (기존 에셋 호환). [FormerlySerializedAs("AttackName")] [FormerlySerializedAs("MotionName")] - public string ActionName; - public string AnimationState; - public float AnimationSpeed = 1f; - public AnimationCurve AnimationSpeedCurve = AnimationCurve.Linear(0f, 1f, 1f, 1f); - public float Cooldown = 0.3f; - public float ComboWindow = 0.25f; + public string ActionName; // 디버그/Inspector 표시용 이름 + public string AnimationState; // Animator의 State 이름 (Play()로 직접 호출) + public float AnimationSpeed = 1f; // 애니메이션 기본 재생 속도 + public AnimationCurve AnimationSpeedCurve = AnimationCurve.Linear(0f, 1f, 1f, 1f); // 시간에 따른 속도 변화 (windup 빠르게/임팩트 느리게 등) + public float Cooldown = 0.3f; // 이 액션 발동 후 다음 공격 입력 받기까지 시간 + public float ComboWindow = 0.25f; // 콤보 transition 받을 수 있는 시간 (ComboNode에서도 별도 설정 가능) + + // ─── 이동(모션) 파라미터 (HasMotion=true일 때 적용) ────────────────── [Header("Motion")] - public bool HasMotion; - public Vector2 Velocity = Vector2.zero; - public AnimationCurve MotionSpeedCurve = AnimationCurve.Linear(0f, 1f, 1f, 1f); + public bool HasMotion; // 이 액션이 위치 이동을 동반하는지 + public Vector2 Velocity = Vector2.zero; // 모션 속도 (X는 facing 방향으로 자동 부호 변환) + public AnimationCurve MotionSpeedCurve = AnimationCurve.Linear(0f, 1f, 1f, 1f); // 시간에 따른 속도 곱연산 (가속/감속) [FormerlySerializedAs("Duration")] - public float MotionDuration = 0.3f; - public bool CanMoveDuringAction; - public bool CanTurnDuringAction; - public bool UseInputDirection = true; - public bool PreserveYVelocity = true; - public bool StopHorizontalVelocityOnEnd = true; + public float MotionDuration = 0.3f; // 모션 전체 길이 (애니메이션 길이 안 쓸 때) + public bool CanMoveDuringAction; // 액션 중 좌우 입력 허용 여부 (보통 false) + public bool CanTurnDuringAction; // 액션 중 페이싱 변경 허용 여부 + public bool UseInputDirection = true; // true면 현재 입력 방향, false면 현재 페이싱 방향으로 이동 + public bool PreserveYVelocity = true; // true면 점프/낙하 중인 vy 유지, false면 ActionData.Velocity.y로 덮어씀 + public bool StopHorizontalVelocityOnEnd = true; // 액션 종료 시 vx를 0으로 (관성으로 더 가지 않게) + // ─── 공격 판정 (HasHit=true일 때 적용) ───────────────────────────── [Header("Hit")] - public bool HasHit = true; - public Vector2 Offset = new Vector2(0.5f, 0f); - public float Radius = 0.5f; - public int Damage = 10; - public float HitTiming = 0.15f; - public float HitDuration = 0f; + public bool HasHit = true; // 이 액션이 데미지를 주는지 + public Vector2 Offset = new Vector2(0.5f, 0f); // 캐릭터 기준 hit 영역 중심 (X는 facing 방향) + public float Radius = 0.5f; // hit 영역 반경 (AttackHitbox.CircleCollider2D) + public int Damage = 10; // 데미지 양 + public float HitTiming = 0.15f; // 액션 시작 후 hit 발동까지 시간 (선딜) + public float HitDuration = 0f; // hit 영역이 활성 상태로 유지되는 시간 (0이면 단발) + // ─── 피격자 반응 (피격된 적의 동작) ───────────────────────────────── [Header("Hit Reaction")] - public Vector2 HitVelocity = Vector2.zero; - public bool UseHitPositionCorrection; - public Vector2 HitTargetOffset = new Vector2(0.8f, 0f); - public float HitPositionCorrectionDuration = 0.08f; - public bool CorrectHitTargetY; - public string HitReactionAnimationState; + public Vector2 HitVelocity = Vector2.zero; // 적에게 가할 넉백 속도 (X는 공격자 facing 방향) + public bool UseHitPositionCorrection; // 적의 위치를 강제로 보정할지 (잡기/연계 안정성) + public Vector2 HitTargetOffset = new Vector2(0.8f, 0f); // 보정 시 공격자 기준 적의 목표 위치 + public float HitPositionCorrectionDuration = 0.08f; // 보정 보간 시간 (0이면 즉시 텔레포트) + public bool CorrectHitTargetY; // 보정에서 Y도 이동시킬지 (false면 X만) + public string HitReactionAnimationState; // 적이 재생할 피격 애니메이션 State + // ─── 잡기 전용 (IsGrab=true일 때 적용) ───────────────────────────── [Header("Grab")] - public bool IsGrab; - public Vector2 GrabOffset = new Vector2(0.6f, 0f); - public AnimationCurve GrabOffsetXCurve = AnimationCurve.Linear(0f, 1f, 1f, 1f); - public AnimationCurve GrabOffsetYCurve = AnimationCurve.Linear(0f, 1f, 1f, 1f); - public string GrabbedAnimationState; - public float GrabSearchRadius = 2f; - public float GrabRange = 0.5f; + public bool IsGrab; // 잡기 액션 (GrabRoutine에서 처리) + public Vector2 GrabOffset = new Vector2(0.6f, 0f); // 잡힌 적의 위치 (공격자 기준) + public AnimationCurve GrabOffsetXCurve = AnimationCurve.Linear(0f, 1f, 1f, 1f); // 시간에 따른 X 위치 비율 (당기기 효과) + public AnimationCurve GrabOffsetYCurve = AnimationCurve.Linear(0f, 1f, 1f, 1f); // 시간에 따른 Y 위치 비율 (들어올리기 효과) + public string GrabbedAnimationState; // 잡힌 적이 재생할 애니메이션 + public float GrabSearchRadius = 2f; // 잡기 타겟 검색 반경 (이 안에서 후보 찾기) + public float GrabRange = 0.5f; // 실제 잡기 가능 거리 (후보 중 이 거리 안에 있어야 잡힘) } diff --git a/Assets/02_Scripts/Combat/AttackHitbox.cs b/Assets/02_Scripts/Combat/AttackHitbox.cs index 00bb6cf..0febe77 100644 --- a/Assets/02_Scripts/Combat/AttackHitbox.cs +++ b/Assets/02_Scripts/Combat/AttackHitbox.cs @@ -1,22 +1,40 @@ using System.Collections.Generic; using UnityEngine; +// ============================================================================ +// AttackHitbox +// ---------------------------------------------------------------------------- +// 플레이어(또는 임의 캐릭터)의 공격 판정 콜라이더. +// Player의 자식 GameObject로 두고, 액션마다 Activate/Deactivate로 hit window 표현. +// +// 핵심: +// - CircleCollider2D Trigger (물리 충돌 아닌 트리거 감지만) +// - 평소엔 disabled, 액션의 HitTiming~HitDuration 동안만 enabled +// - 활성 순간 이미 범위 안에 있던 적도 ScanImmediateOverlap으로 즉시 검사 +// (OnTriggerEnter는 다음 frame에야 발화되므로 짧은 hit window엔 부족함) +// - _alreadyHit HashSet으로 같은 적에 중복 데미지 방지 +// ============================================================================ [RequireComponent(typeof(CircleCollider2D))] public class AttackHitbox : MonoBehaviour { + // PlayerController가 구독해서 "방금 hit한 적" 추적용 (잡기 타겟 우선 등에 활용). public event System.Action OnHit; private CircleCollider2D _collider; + + // ─── 현재 활성 액션의 데미지/효과 데이터 (Activate에서 세팅) ───────── private int _damage; - private Vector2 _hitVelocity; - private Vector2 _hitSourcePosition; - private Vector2? _hitTargetPosition; - private float _hitPositionMinDistance; - private bool _correctHitTargetY; - private int _hitPositionSolidMask; - private float _hitPositionCorrectionDuration; - private string _hitReactionState; - private LayerMask _targetLayer; + private Vector2 _hitVelocity; // 피해자에게 가할 넉백 속도 + private Vector2 _hitSourcePosition; // 공격자 위치 (피격자 위치 보정 거리 계산용) + private Vector2? _hitTargetPosition; // 피격자 강제 이동 목표 위치 (null이면 보정 안 함) + private float _hitPositionMinDistance; // 이 거리보다 가까우면 위치 보정 적용 + private bool _correctHitTargetY; // 위치 보정에서 Y도 보정할지 + private int _hitPositionSolidMask; // 보정 시 끼이지 않게 검사할 솔리드 레이어 + private float _hitPositionCorrectionDuration; // 보정 보간 시간 (0이면 즉시) + private string _hitReactionState; // 피격자가 재생할 애니메이션 State 이름 + private LayerMask _targetLayer; // 데미지를 줄 레이어 (보통 Enemy) + + // 한 번 hit한 IDamageable은 이 액션 활성 동안 다시 hit되지 않음. private readonly HashSet _alreadyHit = new(); private void Awake() @@ -27,6 +45,8 @@ private void Awake() _collider.enabled = false; } + // 액션 시작 시 호출. 위치/반경/데미지 등 모든 파라미터 세팅 후 콜라이더 활성화. + // _alreadyHit를 클리어해서 새 공격으로 다시 hit 가능하게 함. public void Activate(ActionData data, Vector2 localPosition, Vector2 hitVelocity, Vector2 sourcePosition, Vector2? hitTargetPosition, bool correctHitTargetY, int hitPositionSolidMask, LayerMask targetLayer) { transform.localPosition = localPosition; @@ -48,12 +68,15 @@ public void Activate(ActionData data, Vector2 localPosition, Vector2 hitVelocity ScanImmediateOverlap(); } + // 액션의 HitDuration이 끝나면 호출. 콜라이더 비활성화 + hit 기록 초기화. public void Deactivate() { _collider.enabled = false; _alreadyHit.Clear(); } + // 활성 순간 즉시 검사: Physics2D.OverlapCircleAll로 현재 겹친 콜라이더를 모두 가져와 TryDamage. + // 이게 없으면 짧은 hit window (예: HitDuration=0.02)에 OnTriggerEnter가 못 따라옴. private void ScanImmediateOverlap() { Collider2D[] hits = Physics2D.OverlapCircleAll(transform.position, _collider.radius, _targetLayer); @@ -61,9 +84,17 @@ private void ScanImmediateOverlap() TryDamage(hit); } + // 트리거 이벤트로 들어온 적도 같은 함수로 처리. + // Stay까지 받는 이유: 활성 중간에 새로 들어온 적도 잡으려고. private void OnTriggerEnter2D(Collider2D other) => TryDamage(other); private void OnTriggerStay2D(Collider2D other) => TryDamage(other); + // 데미지 적용 흐름: + // 1) 레이어 마스크로 적 여부 확인 + // 2) Collider 자신 또는 부모에서 IDamageable 검색 (자식 콜라이더 대응) + // 3) 이미 hit한 대상은 건너뜀 (HashSet) + // 4) 데미지 + 넉백 + 위치 보정 정보를 전달하여 TakeDamage 호출 + // 5) OnHit 이벤트 발화 (PlayerController가 마지막 hit한 적 기억) private void TryDamage(Collider2D other) { if ((_targetLayer.value & (1 << other.gameObject.layer)) == 0) return; @@ -81,6 +112,10 @@ private void TryDamage(Collider2D other) OnHit?.Invoke(target); } + // 위치 보정 목표 결정. + // 적이 공격자에게 너무 가까우면 (X 거리 < minDistance) hitTargetPosition을 반환, + // 충분히 떨어져 있으면 null 반환해서 보정 안 함. + // (가까운 적만 끌어당기는 "흡착" 효과 구현) private Vector2? GetCorrectionTargetPosition(Collider2D other) { if (!_hitTargetPosition.HasValue) return null; diff --git a/Assets/02_Scripts/Combat/ComboNode.cs b/Assets/02_Scripts/Combat/ComboNode.cs index 11cd644..b5119ef 100644 --- a/Assets/02_Scripts/Combat/ComboNode.cs +++ b/Assets/02_Scripts/Combat/ComboNode.cs @@ -2,29 +2,46 @@ using UnityEngine; using UnityEngine.Serialization; +// ============================================================================ +// ComboNode + ComboTransition +// ---------------------------------------------------------------------------- +// 콤보 트리 구조를 표현하는 ScriptableObject + Serializable 클래스. +// 각 노드는 "어떤 액션을 수행할지" + "다음에 어떤 입력으로 어디로 갈지"를 정의. +// +// 예시 트리: +// Punch_Root → [Punch] → Punch_Hit2 → [Punch] → Punch_Finisher +// → [Kick] → Kick_Spin +// → [Grab] → Grab_Smash +// +// 각 트랜지션마다 ForwardStep을 다르게 줘서 콤보 흐름에 맞춰 전진 거리 조절. +// ============================================================================ + +// 콤보 입력 타입. PlayerController.HandleComboInput에 매핑됨. public enum ComboInputType { Punch, Kick, Grab, - Motion + Motion // 모션 액션 트리거 (현재는 사용 안 함, 확장 여지로 남겨둠) } +// 한 노드에서 다음 노드로 가는 "간선" 정보. [Serializable] public class ComboTransition { - public ComboInputType Trigger; - public ComboNode Next; - public float ForwardStep = 0f; - public float ForwardStepDuration = 0.1f; + public ComboInputType Trigger; // 어떤 입력이 들어와야 이 transition을 탈지 + public ComboNode Next; // 이동할 다음 노드 + public float ForwardStep = 0f; // 이 transition을 탈 때 전진할 거리 (적과 거리 좁히기) + public float ForwardStepDuration = 0.1f;// 전진 동작 시간 } +// 콤보 트리의 노드. .asset 파일로 관리. [CreateAssetMenu(fileName = "ComboNode", menuName = "Combat/ComboNode")] public class ComboNode : ScriptableObject { - public string NodeName; + public string NodeName; // Inspector 식별용 [FormerlySerializedAs("Attack")] - public ActionData Action; - public float ComboWindow = 0.8f; - public ComboTransition[] Transitions; + public ActionData Action; // 이 노드에 진입했을 때 수행할 액션 + public float ComboWindow = 0.8f; // 이 노드에서 다음 입력 받을 수 있는 시간 + public ComboTransition[] Transitions; // 다음 노드들 (입력별로 분기) } diff --git a/Assets/02_Scripts/Combat/Health.cs b/Assets/02_Scripts/Combat/Health.cs index eca86ee..786ee5d 100644 --- a/Assets/02_Scripts/Combat/Health.cs +++ b/Assets/02_Scripts/Combat/Health.cs @@ -1,16 +1,33 @@ using System; using UnityEngine; +// ============================================================================ +// Health +// ---------------------------------------------------------------------------- +// 순수한 HP 데이터 컴포넌트. 시각 효과/넉백/사망 처리는 일절 안 함. +// Enemy, Player 등 어떤 GameObject든 Health 컴포넌트만 추가하면 HP 관리 가능. +// +// 일반적 사용 흐름: +// 1) Enemy/Player가 IDamageable.TakeDamage 받음 +// 2) 시각 효과(flash) + 넉백 처리 +// 3) 마지막에 _health.TakeDamage(amount) 호출 +// 4) Health가 HP 감소 + OnHealthChanged 이벤트 발화 → HP바 등 UI 자동 갱신 +// 5) HP가 0 되면 OnDied 이벤트 발화 → Enemy가 HandleDeath()로 Destroy 등 처리 +// ============================================================================ public class Health : MonoBehaviour { [SerializeField] private int _maxHealth = 30; private int _currentHealth; + // ─── 읽기 전용 프로퍼티 ────────────────────────────────────────────── public int MaxHealth => _maxHealth; public int CurrentHealth => _currentHealth; public float Ratio => _maxHealth > 0 ? (float)_currentHealth / _maxHealth : 0f; public bool IsDead => _currentHealth <= 0; + // ─── 외부 구독용 이벤트 ────────────────────────────────────────────── + // OnHealthChanged: (current, max). HP바, 데미지 숫자 표시 등에 사용. + // OnDied: 사망 순간 1회만 발화. Enemy.HandleDeath에서 구독. public event Action OnHealthChanged; public event Action OnDied; @@ -19,6 +36,8 @@ private void Awake() _currentHealth = _maxHealth; } + // 데미지 적용. 양수 데미지만 받고, 이미 죽었으면 무시. + // OnDied는 "방금 죽은 순간"에만 발화 (previous > 0 && current == 0). public void TakeDamage(int amount) { if (amount <= 0 || IsDead) return; @@ -31,6 +50,7 @@ public void TakeDamage(int amount) OnDied?.Invoke(); } + // 회복. 죽은 상태에서는 회복 안 됨 (부활 로직은 별도로 만들어야 함). public void Heal(int amount) { if (amount <= 0 || IsDead) return; @@ -39,12 +59,14 @@ public void Heal(int amount) OnHealthChanged?.Invoke(_currentHealth, _maxHealth); } + // 풀체력으로 리셋. 부활/재시작 시 사용. public void ResetHealth() { _currentHealth = _maxHealth; OnHealthChanged?.Invoke(_currentHealth, _maxHealth); } + // 최대 HP 변경. fill=true면 현재 HP도 풀로 채우고, false면 새 max로 클램프만. public void SetMaxHealth(int newMax, bool fill = true) { _maxHealth = Mathf.Max(newMax, 1); diff --git a/Assets/02_Scripts/Combat/IDamageable.cs b/Assets/02_Scripts/Combat/IDamageable.cs index bb39f7e..8f77d14 100644 --- a/Assets/02_Scripts/Combat/IDamageable.cs +++ b/Assets/02_Scripts/Combat/IDamageable.cs @@ -1,5 +1,21 @@ using UnityEngine; +// ============================================================================ +// IDamageable +// ---------------------------------------------------------------------------- +// "데미지를 받을 수 있는 무언가"를 표현하는 인터페이스. +// AttackHitbox가 트리거에 들어온 콜라이더에서 이 인터페이스를 찾아 TakeDamage 호출. +// Enemy, Player 등 어떤 GameObject든 이 인터페이스만 구현하면 같은 공격 시스템으로 피격 가능. +// ---------------------------------------------------------------------------- +// 매개변수: +// amount — 데미지 양 +// hitVelocity — 넉백 속도 (피해자에게 적용할 vx/vy) +// hitReactionAnimationState — 피격 모션 애니메이션 State 이름 (null 가능) +// hitTargetPosition — 피격 시 위치 보정 목표 (null이면 보정 안 함) +// correctHitTargetY — 위 위치 보정에서 Y도 보정할지 (false면 X만) +// hitPositionSolidMask — 위치 보정 시 끼이지 않게 검사할 솔리드 레이어 +// hitPositionCorrectionDuration — 위치 보정 보간 시간 (0이면 즉시 스냅) +// ============================================================================ public interface IDamageable { void TakeDamage(int amount, Vector2 hitVelocity = default, string hitReactionAnimationState = null, Vector2? hitTargetPosition = null, bool correctHitTargetY = false, int hitPositionSolidMask = 0, float hitPositionCorrectionDuration = 0f); diff --git a/Assets/02_Scripts/Enemy/Enemy.cs b/Assets/02_Scripts/Enemy/Enemy.cs index 4104570..5e30bf6 100644 --- a/Assets/02_Scripts/Enemy/Enemy.cs +++ b/Assets/02_Scripts/Enemy/Enemy.cs @@ -1,39 +1,61 @@ using System.Collections.Generic; using UnityEngine; +// ============================================================================ +// Enemy +// ---------------------------------------------------------------------------- +// 적 캐릭터의 모든 행동을 관리. 현재는 AI 이동 없음 (정지 표적). +// 책임: +// - IDamageable 구현 → 공격 받기 +// - 피격 시각 효과 (색 깜빡) +// - 피격 시 넉백 및 위치 보정 +// - 벽 충돌 시 반사 (튕기는 효과) +// - 다른 적과의 소프트 분리 (한 점에 겹치지 않도록) +// - 잡기 상태 처리 (플레이어가 강제로 끌고 다님) +// - 사망 처리 (Health.OnDied 이벤트로 트리거) +// +// HP는 Health 컴포넌트로 분리. Enemy는 Health.TakeDamage를 위임 호출. +// ============================================================================ [RequireComponent(typeof(Collider2D))] [RequireComponent(typeof(Rigidbody2D))] [RequireComponent(typeof(Health))] public class Enemy : MonoBehaviour, IDamageable { + // ─── 피격 시각 효과 ────────────────────────────────────────────────── [Header("Hit Feedback")] - [SerializeField] private float _hitFlashDuration = 0.1f; - [SerializeField] private Color _hitFlashColor = Color.red; + [SerializeField] private float _hitFlashDuration = 0.1f; // 빨강 깜빡 지속 시간 + [SerializeField] private Color _hitFlashColor = Color.red; // 깜빡 색상 + // ─── 넉백 / 벽 반사 파라미터 ───────────────────────────────────────── [Header("Hit Bounce")] - [SerializeField] private float _hitReactionDuration = 0.5f; - [SerializeField] private float _airborneHitYVelocity = 3f; - [SerializeField] private float _wallBounceVelocityMultiplier = 0.8f; - [SerializeField] private float _wallBounceMinXVelocity = 1f; - [SerializeField] private float _wallBounceUpwardVelocity = 1.5f; + [SerializeField] private float _hitReactionDuration = 0.5f; // 넉백 유효 시간 (벽 반사 가능 시간) + [SerializeField] private float _airborneHitYVelocity = 3f; // 공중에서 맞을 때 강제 Y 속도 (띄우기 효과) + [SerializeField] private float _wallBounceVelocityMultiplier = 0.8f;// 벽에 부딪힐 때 속도 감쇠 + [SerializeField] private float _wallBounceMinXVelocity = 1f; // 이 값보다 느리면 반사 안 함 (작은 충돌 무시) + [SerializeField] private float _wallBounceUpwardVelocity = 1.5f; // 반사 후 최소 Y 속도 (위로 살짝 튀게) + // ─── 다른 적과의 시각적 분리 ───────────────────────────────────────── + // 같은 위치에 적이 겹쳐 보이지 않도록 살짝 옆으로 미는 힘. [Header("Separation")] - [SerializeField] private float _separationRadius = 0.6f; - [SerializeField] private float _separationStrength = 2f; - [SerializeField] private LayerMask _separationLayer; - private static readonly Collider2D[] _separationBuffer = new Collider2D[16]; + [SerializeField] private float _separationRadius = 0.6f; // 이 거리 안의 다른 적과 분리 + [SerializeField] private float _separationStrength = 2f; // 분리 강도 (units/sec) + [SerializeField] private LayerMask _separationLayer; // 검사 대상 레이어 (보통 Enemy) + private static readonly Collider2D[] _separationBuffer = new Collider2D[16]; // OverlapCircle 결과 버퍼 (GC 회피) - private Health _health; + private Health _health; // HP 컴포넌트 (별도 분리 → 플레이어도 같은 컴포넌트 재사용 가능) private Rigidbody2D _rb; private Animator _anim; private SpriteRenderer _spriteRenderer; - private Color _originalColor; - private float _flashTimer; - private float _hitReactionTimer; + private Color _originalColor; // hit flash 끝나면 복귀할 원본 색 + private float _flashTimer; // 깜빡 남은 시간 + private float _hitReactionTimer; // 넉백 유효 시간 카운트다운 (벽 반사 조건) private bool _isGrounded; - private Vector2 _lastVelocity; + private Vector2 _lastVelocity; // 직전 프레임 속도 (벽 충돌 시 반사 벡터 계산용) private Collider2D[] _bodyColliders; private readonly List _castResults = new(); + + // ─── 피격 위치 보정 (플레이어 공격이 적의 위치를 안정화) ───────────── + // 잡기/연계의 안정성을 위해, 공격이 적의 위치를 일정한 곳으로 끌어오는 기능. private const float HitPositionSkinWidth = 0.02f; private bool _isHitPositionCorrecting; private bool _correctHitPositionY; @@ -41,10 +63,13 @@ public class Enemy : MonoBehaviour, IDamageable private float _hitPositionCorrectionDuration; private Vector2 _hitPositionCorrectionStart; private Vector2 _hitPositionCorrectionTarget; - private bool _isGrabbed; - private Vector2 _grabTargetPosition; - private int _grabSolidMask; + // ─── 잡기 상태 ─────────────────────────────────────────────────────── + private bool _isGrabbed; // 플레이어에게 잡힌 상태인지 + private Vector2 _grabTargetPosition;// 잡힌 동안 강제로 위치할 좌표 + private int _grabSolidMask; // 잡혀서 이동할 때 벽에 끼이지 않게 검사할 솔리드 레이어 + + // 컴포넌트 캐싱 + Health.OnDied 구독 (사망 시 HandleDeath 자동 호출). private void Awake() { _health = GetComponent(); @@ -57,12 +82,14 @@ private void Awake() _originalColor = _spriteRenderer.color; } + // 이벤트 구독 해제 (Destroy 시 누수 방지). private void OnDestroy() { if (_health != null) _health.OnDied -= HandleDeath; } + // 매 프레임: hit flash 타이머 + hit reaction 타이머 카운트다운. private void Update() { if (_flashTimer > 0f) @@ -79,12 +106,17 @@ private void Update() _hitReactionTimer -= Time.deltaTime; } + // 매 물리 프레임의 메인: + // - 잡힌 상태면 그랩 위치로 강제 이동 + // - 그 외엔 피격 위치 보정 진행 + 분리력 적용 + // - 벽 반사 계산을 위해 직전 velocity 기록 private void FixedUpdate() { if (_rb != null) { if (_isGrabbed) { + // 잡힌 동안엔 자체 물리 무시하고 플레이어가 지정한 위치로 강제 이동. _rb.linearVelocity = Vector2.zero; _rb.MovePosition(_grabTargetPosition); _lastVelocity = Vector2.zero; @@ -97,6 +129,12 @@ private void FixedUpdate() } } + // 같은 레이어의 다른 적과 너무 가까우면 옆으로 밀어 시각적으로 분리. + // 알고리즘: + // 1) OverlapCircle로 주변 적 검색 + // 2) 각 적마다 거리 비례로 push 벡터 누적 (가까울수록 강하게) + // 3) 평균 방향 × Strength × deltaTime 만큼 X축으로만 밀어냄 + // (Y로 밀면 공중 부양/점프 효과 생겨서 어색함) private void ApplySeparation() { if (_separationRadius <= 0f || _separationStrength <= 0f) return; @@ -117,11 +155,11 @@ private void ApplySeparation() { Collider2D other = _separationBuffer[i]; if (other == null) continue; - if (other.attachedRigidbody == _rb) continue; + if (other.attachedRigidbody == _rb) continue; // 자기 자신 스킵 Vector2 away = _rb.position - (Vector2)other.transform.position; float dist = away.magnitude; - if (dist >= _separationRadius) continue; + if (dist >= _separationRadius) continue; // 영향권 밖 Vector2 dir; if (dist < 0.001f) @@ -134,6 +172,7 @@ private void ApplySeparation() dir = away / dist; } + // 가까울수록 strength가 1에 가까워짐 (멀어질수록 0). float strength = 1f - (dist / _separationRadius); push += dir * strength; contributors++; @@ -146,6 +185,14 @@ private void ApplySeparation() _rb.position += new Vector2(push.x, 0f) * (_separationStrength * Time.fixedDeltaTime); } + // IDamageable 구현. 데미지 처리 흐름: + // 1) 죽었으면 무시 + // 2) 잡힌 상태 해제 (피격되면 잡기 풀림) + // 3) 시각 효과 (빨간 깜빡) + // 4) 피격 애니메이션 재생 + // 5) 이전 넉백 속도 초기화 후 새 넉백 적용 + // 6) 위치 보정 (옵션) + // 7) Health.TakeDamage로 HP 감소 → 0이면 OnDied 이벤트로 HandleDeath 트리거 public void TakeDamage(int amount, Vector2 hitVelocity = default, string hitReactionAnimationState = null, Vector2? hitTargetPosition = null, bool correctHitTargetY = false, int hitPositionSolidMask = 0, float hitPositionCorrectionDuration = 0f) { if (_health == null || _health.IsDead) return; @@ -185,6 +232,8 @@ public void TakeDamage(int amount, Vector2 hitVelocity = default, string hitReac Debug.Log($"{name} 피격: -{amount} (HP: {_health.CurrentHealth}/{_health.MaxHealth})"); } + // 플레이어가 잡기 액션 시작 시 호출. 이 적이 잡힘 상태로 진입. + // 이후 매 FixedUpdate에서 _grabTargetPosition으로 강제 이동됨. public void BeginGrab(string grabbedAnimationState, int solidMask) { if (_health == null || _health.IsDead) return; @@ -205,6 +254,8 @@ public void BeginGrab(string grabbedAnimationState, int solidMask) _anim.Play(grabbedAnimationState); } + // 플레이어가 잡힌 적을 매 프레임 이동시킬 때 호출 (예: 들어올렸다 내려치기). + // GetSafeHitTargetPosition으로 벽에 끼이지 않게 안전 위치 계산. public void UpdateGrabPosition(Vector2 position) { if (!_isGrabbed || _rb == null) return; @@ -213,11 +264,16 @@ public void UpdateGrabPosition(Vector2 position) _grabTargetPosition = GetSafeHitTargetPosition(position, _grabSolidMask); } + // 잡기 종료. 적은 다시 자체 물리로 돌아감. public void EndGrab() { _isGrabbed = false; } + // Unity의 OnCollisionEnter2D 콜백. 두 가지 일을 처리: + // 1) 지면 상태 갱신 (착지 감지) + // 2) 넉백 중에 벽 부딪히면 반사 (튀어오르는 효과) + // 플레이어와 부딪히면 반사 안 함 (다른 적과 부딪히는 경우만). private void OnCollisionEnter2D(Collision2D collision) { UpdateGroundedState(collision); @@ -228,7 +284,7 @@ private void OnCollisionEnter2D(Collision2D collision) for (int i = 0; i < collision.contactCount; i++) { Vector2 normal = collision.GetContact(i).normal; - if (Mathf.Abs(normal.x) < 0.5f) continue; + if (Mathf.Abs(normal.x) < 0.5f) continue; // 수평 충돌만 처리 (천장/바닥 무시) BounceOffWall(normal); return; @@ -245,6 +301,8 @@ private void OnCollisionExit2D(Collision2D collision) _isGrounded = false; } + // 공중 피격은 항상 위로 띄우는 효과 적용 (격투게임 표준). + // 지상 피격은 hitVelocity 그대로 사용. private Vector2 GetHitReactionVelocity(Vector2 hitVelocity) { // 공중 추가타는 고정된 세로 속도를 쓰되, 이전 물리 속도는 절대 이어받지 않는다. @@ -281,6 +339,8 @@ private void BeginHitTargetPositionCorrection(Vector2? hitTargetPosition, bool c _hitPositionCorrectionTarget = targetPosition; } + // 매 FixedUpdate에서 진행 중인 위치 보정 보간 한 스텝 실행. + // SmoothStep으로 부드러운 진입/이탈 곡선 사용. private void ApplySmoothHitPositionCorrection() { if (!_isHitPositionCorrecting || _rb == null) return; @@ -299,6 +359,8 @@ private void ApplySmoothHitPositionCorrection() _isHitPositionCorrecting = false; } + // 목표 위치까지 cast해서 벽에 막히지 않는 최대 거리까지의 위치 반환. + // 위치 보정/잡기 이동 시 적이 벽에 끼이는 걸 방지. private Vector2 GetSafeHitTargetPosition(Vector2 targetPosition, int solidMask) { if (solidMask == 0) return targetPosition; @@ -311,12 +373,14 @@ private Vector2 GetSafeHitTargetPosition(Vector2 targetPosition, int solidMask) Vector2 direction = moveDelta / distance; float closestDistance = GetClosestBodyCastDistance(direction, distance + HitPositionSkinWidth, solidMask); if (closestDistance >= distance + HitPositionSkinWidth) - return targetPosition; + return targetPosition; // 막힘 없음 → 목표 그대로 float allowedDistance = Mathf.Max(closestDistance - HitPositionSkinWidth, 0f); - return startPosition + direction * allowedDistance; + return startPosition + direction * allowedDistance; // skin만큼 떨어진 지점까지 } + // 모든 body collider를 direction 방향으로 cast해서 가장 가까운 hit 거리 반환. + // GetSafeHitTargetPosition의 헬퍼. private float GetClosestBodyCastDistance(Vector2 direction, float distance, int solidMask) { if (_bodyColliders == null || _bodyColliders.Length == 0) @@ -347,6 +411,8 @@ private float GetClosestBodyCastDistance(Vector2 direction, float distance, int return closest; } + // 접촉 노멀의 Y가 0.5보다 크면 (위쪽 방향이면) 지면 위로 판정. + // 비스듬한 경사도 어느정도 지면으로 인정. private void UpdateGroundedState(Collision2D collision) { for (int i = 0; i < collision.contactCount; i++) @@ -359,6 +425,10 @@ private void UpdateGroundedState(Collision2D collision) } } + // 피격 후 벽에 부딪힐 때 속도를 반사 (Vector2.Reflect 사용). + // _lastVelocity와 현재 velocity 중 더 큰 것을 사용하는 이유: + // - 충돌 직전 frame에 이미 velocity가 줄어들 수 있어서, 직전 값을 fallback으로 사용 + // _hitReactionTimer를 다시 채워서 연쇄 반사 가능 (벽 사이를 통통 튀게). private void BounceOffWall(Vector2 wallNormal) { // 피격 반응 중 옆벽에 부딪히면 현재 넉백 속도를 반사한다. @@ -366,9 +436,10 @@ private void BounceOffWall(Vector2 wallNormal) ? _lastVelocity : _rb.linearVelocity; - if (Mathf.Abs(incomingVelocity.x) < _wallBounceMinXVelocity) return; + if (Mathf.Abs(incomingVelocity.x) < _wallBounceMinXVelocity) return; // 너무 느린 충돌 무시 Vector2 bouncedVelocity = Vector2.Reflect(incomingVelocity, wallNormal) * _wallBounceVelocityMultiplier; + // 반사 후 Y가 너무 낮으면 위로 튀어오르게 강제 (지면에 깔리지 않도록). if (bouncedVelocity.y < _wallBounceUpwardVelocity) bouncedVelocity.y = _wallBounceUpwardVelocity; @@ -376,6 +447,8 @@ private void BounceOffWall(Vector2 wallNormal) _hitReactionTimer = _hitReactionDuration; } + // Health.OnDied 이벤트 콜백. 사망 처리. + // 현재는 단순 Destroy. 나중에 풀링/드롭/이펙트 추가하려면 여기에 확장. private void HandleDeath() { Debug.Log($"{name} 사망"); diff --git a/Assets/02_Scripts/Enemy/EnemySpawner.cs b/Assets/02_Scripts/Enemy/EnemySpawner.cs index 307c9bd..ff10c9f 100644 --- a/Assets/02_Scripts/Enemy/EnemySpawner.cs +++ b/Assets/02_Scripts/Enemy/EnemySpawner.cs @@ -1,42 +1,59 @@ using System.Collections.Generic; using UnityEngine; +// ============================================================================ +// EnemySpawner +// ---------------------------------------------------------------------------- +// 단순한 적 스폰 컨트롤러 (웨이브 없이 자동 스폰/리스폰). +// WaveManager가 시간 제한 스폰이라면, 이건 무한/제한 스폰의 일반 패턴. +// +// 사용 예시: +// - 테스트 씬: Max Alive 1 + Respawn 켜기 → 한 마리씩 무한 등장 +// - 아레나: Max Alive 5 + Total Limit 20 + Respawn 켜기 → 총 20마리, 동시 5 +// - 일회성 매복: Spawn On Start 끄고 외부에서 SpawnNow() 호출 +// ============================================================================ public class EnemySpawner : MonoBehaviour { + // ─── 스폰 설정 ─────────────────────────────────────────────────────── [Header("Spawn Configuration")] - [SerializeField] private Enemy _enemyPrefab; - [SerializeField] private int _maxAliveCount = 3; - [SerializeField] private float _spawnInterval = 2f; - [SerializeField] private float _initialDelay = 0f; + [SerializeField] private Enemy _enemyPrefab; // 스폰할 적 프리팹 + [SerializeField] private int _maxAliveCount = 3; // 동시에 살아있을 수 있는 최대 수 + [SerializeField] private float _spawnInterval = 2f; // 스폰 간격 (초) + [SerializeField] private float _initialDelay = 0f; // 첫 스폰 전 대기 시간 + // ─── 위치 설정 ─────────────────────────────────────────────────────── [Header("Spawn Position")] - [SerializeField] private Transform[] _spawnPoints; - [SerializeField] private float _spawnRadius = 0f; - [SerializeField] private Transform _enemyParent; + [SerializeField] private Transform[] _spawnPoints; // 비워두면 자기 위치, 여러 개면 랜덤 선택 + [SerializeField] private float _spawnRadius = 0f; // 0보다 크면 spawn point 주변 무작위 분산 + [SerializeField] private Transform _enemyParent; // 스폰된 적을 묶을 부모 (Hierarchy 정리용) + // ─── 동작 옵션 ─────────────────────────────────────────────────────── [Header("Behavior")] - [SerializeField] private bool _spawnOnStart = true; - [SerializeField] private bool _respawnOnDeath = true; - [SerializeField] private int _totalSpawnLimit = 0; + [SerializeField] private bool _spawnOnStart = true; // Start에서 자동 스폰 시작 + [SerializeField] private bool _respawnOnDeath = true; // 죽으면 자동 리스폰 (false면 일회성) + [SerializeField] private int _totalSpawnLimit = 0; // 누적 스폰 한도 (0이면 무제한) - private readonly List _aliveEnemies = new(); - private float _nextSpawnTime; - private int _totalSpawned; - private bool _active; + private readonly List _aliveEnemies = new(); // 현재 살아있는 적들 (자동 정리됨) + private float _nextSpawnTime; // 다음 스폰 가능 시각 + private int _totalSpawned; // 지금까지 누적 스폰 수 + private bool _active; // 스폰 활성 상태 private void Start() { if (_spawnOnStart) BeginSpawning(); } + // 외부 트리거에서 스폰 시작 (예: 플레이어가 영역 진입). public void BeginSpawning() { _active = true; _nextSpawnTime = Time.time + _initialDelay; } + // 스폰 일시 중지. 살아있는 적은 그대로 유지. public void StopSpawning() => _active = false; + // 스폰 + 살아있는 적 모두 제거 + 카운터 초기화. 재시작용. public void ResetSpawner() { for (int i = _aliveEnemies.Count - 1; i >= 0; i--) @@ -53,18 +70,21 @@ private void Update() { if (!_active) return; + // Destroy된 적 참조는 null이 됨. 자동 정리. _aliveEnemies.RemoveAll(e => e == null); - if (!_respawnOnDeath && _aliveEnemies.Count == 0 && _totalSpawned > 0) return; - if (_totalSpawnLimit > 0 && _totalSpawned >= _totalSpawnLimit) return; - if (_aliveEnemies.Count >= _maxAliveCount) return; - if (Time.time < _nextSpawnTime) return; + // 조기 종료 조건들: + if (!_respawnOnDeath && _aliveEnemies.Count == 0 && _totalSpawned > 0) return; // 일회성이고 다 죽었으면 끝 + if (_totalSpawnLimit > 0 && _totalSpawned >= _totalSpawnLimit) return; // 누적 한도 도달 + if (_aliveEnemies.Count >= _maxAliveCount) return; // 동시 최대치 도달 + if (Time.time < _nextSpawnTime) return; // 다음 스폰 시간 안 됨 if (_enemyPrefab == null) return; SpawnOne(); _nextSpawnTime = Time.time + _spawnInterval; } + // 1마리 즉시 스폰. 외부에서 직접 호출 가능 (예: 이벤트 트리거). public Enemy SpawnOne() { if (_enemyPrefab == null) return null; @@ -76,6 +96,10 @@ public Enemy SpawnOne() return enemy; } + // 스폰 위치 결정: + // 1) Spawn Points 있으면 그중 랜덤 선택 + // 2) 없으면 자기 위치 + // 3) Spawn Radius > 0이면 위 위치에서 ±radius 무작위 오프셋 추가 private Vector3 GetSpawnPosition() { Vector3 basePos; @@ -99,6 +123,8 @@ private Vector3 GetSpawnPosition() return basePos; } + // Scene 뷰에서 스폰 위치/반경 시각화. + // 각 spawn point마다 시안색 원(위치)과 반경 원(분산) 표시. private void OnDrawGizmosSelected() { Color pointColor = Color.cyan; diff --git a/Assets/02_Scripts/Enemy/WaveData.cs b/Assets/02_Scripts/Enemy/WaveData.cs index efd9abb..7f20d01 100644 --- a/Assets/02_Scripts/Enemy/WaveData.cs +++ b/Assets/02_Scripts/Enemy/WaveData.cs @@ -2,19 +2,32 @@ using System.Collections.Generic; using UnityEngine; +// ============================================================================ +// WaveData +// ---------------------------------------------------------------------------- +// 한 웨이브의 정의. .asset으로 만들어 WaveManager에 순서대로 할당. +// +// 예시: +// WaveName = "Wave 1" +// Spawns = [(BlackMan, 3), (Robot, 1)] → BlackMan 3마리 + Robot 1마리 +// TimeLimit = 30 → 30초 안에 다 잡아야 함 +// SpawnInterval = 0.5 → 마리 사이 0.5초 간격 +// StartDelay = 2 → 웨이브 시작 전 2초 대기 +// ============================================================================ [CreateAssetMenu(fileName = "WaveData", menuName = "Combat/WaveData")] public class WaveData : ScriptableObject { - public string WaveName; - public List Spawns = new(); - public float TimeLimit = 30f; - public float SpawnInterval = 0.3f; - public float StartDelay = 0f; + public string WaveName; // UI/로그용 식별자 + public List Spawns = new(); // 스폰할 적 종류와 수량 목록 + public float TimeLimit = 30f; // 이 시간 안에 모두 잡지 못하면 패배 (초) + public float SpawnInterval = 0.3f; // 적 한 마리씩 스폰하는 시간 간격 + public float StartDelay = 0f; // 웨이브 시작 전 대기 시간 (인트로/카운트다운 시간) } +// 한 종류의 적을 몇 마리 스폰할지 표현. [Serializable] public class SpawnEntry { - public Enemy EnemyPrefab; - public int Count = 1; + public Enemy EnemyPrefab; // 스폰할 Enemy 프리팹 + public int Count = 1; // 마리 수 } diff --git a/Assets/02_Scripts/Enemy/WaveManager.cs b/Assets/02_Scripts/Enemy/WaveManager.cs index c3fc2db..d2b7979 100644 --- a/Assets/02_Scripts/Enemy/WaveManager.cs +++ b/Assets/02_Scripts/Enemy/WaveManager.cs @@ -3,41 +3,63 @@ using System.Threading; using UnityEngine; +// ============================================================================ +// WaveManager +// ---------------------------------------------------------------------------- +// 시간 제한 웨이브 시스템. 각 웨이브를 순서대로 진행하며, 시간 내 클리어 못 하면 패배. +// +// 흐름: +// StartWaves() → for each wave: +// - StartDelay 대기 +// - OnWaveStart 발화 +// - 적 스폰 시작 (백그라운드 비동기) +// - 타이머 시작 +// - 매 프레임: 적 다 잡았는지 / 타이머 만료됐는지 체크 +// - 클리어 → OnWaveCleared → IntermissionDuration 대기 → 다음 웨이브 +// - 타임아웃 → OnDefeat → 살아있는 적 정리 → 종료 +// 모든 웨이브 클리어 → OnAllWavesCleared +// +// UI 연동: 외부 시스템이 4개 이벤트 구독하여 UI/사운드 트리거. +// ============================================================================ public class WaveManager : MonoBehaviour { + // ─── 웨이브 데이터 ─────────────────────────────────────────────────── [Header("Waves")] - [SerializeField] private List _waves = new(); - [SerializeField] private float _intermissionDuration = 2f; - [SerializeField] private bool _startOnAwake = true; + [SerializeField] private List _waves = new(); // 순서대로 진행할 웨이브들 + [SerializeField] private float _intermissionDuration = 2f; // 웨이브 사이 대기 시간 ("Wave Clear!" 표시 시간) + [SerializeField] private bool _startOnAwake = true; // Start에서 자동 시작 + // ─── 스폰 위치 설정 (모든 웨이브 공통) ─────────────────────────────── [Header("Spawn Position")] - [SerializeField] private Transform[] _spawnPoints; - [SerializeField] private float _spawnRadius = 0f; - [SerializeField] private Transform _enemyParent; + [SerializeField] private Transform[] _spawnPoints; // 비워두면 자기 위치 + [SerializeField] private float _spawnRadius = 0f; // 각 spawn point 주변 무작위 분산 + [SerializeField] private Transform _enemyParent; // 스폰된 적을 묶을 부모 (Hierarchy 정리) [Header("On Defeat")] - [SerializeField] private bool _destroyAliveEnemiesOnDefeat = true; + [SerializeField] private bool _destroyAliveEnemiesOnDefeat = true; // 패배 시 남은 적 정리 - public event Action OnWaveStart; - public event Action OnWaveCleared; - public event Action OnAllWavesCleared; - public event Action OnDefeat; + // ─── 외부에서 구독할 이벤트들 (UI/사운드 연동용) ───────────────────── + public event Action OnWaveStart; // 웨이브 시작: (index, data) + public event Action OnWaveCleared; // 웨이브 클리어: (index) + public event Action OnAllWavesCleared; // 모든 웨이브 끝 (승리) + public event Action OnDefeat; // 패배: 어느 웨이브에서 실패했는지 + // ─── 외부 읽기 전용 상태 (UI 연동용) ──────────────────────────────── public int CurrentWaveIndex { get; private set; } public int TotalWaveCount => _waves != null ? _waves.Count : 0; public WaveData CurrentWave => _waves != null && CurrentWaveIndex >= 0 && CurrentWaveIndex < _waves.Count ? _waves[CurrentWaveIndex] : null; - public float TimeRemaining { get; private set; } - public int AliveCount => _aliveEnemies.Count; - public int RemainingToSpawn { get; private set; } - public bool IsRunning { get; private set; } - public bool IsDefeated { get; private set; } - public bool IsVictory { get; private set; } + public float TimeRemaining { get; private set; } // 현재 웨이브 남은 시간 + public int AliveCount => _aliveEnemies.Count; // 살아있는 적 수 + public int RemainingToSpawn { get; private set; } // 아직 스폰 안 한 적 수 + public bool IsRunning { get; private set; } // 웨이브 진행 중 + public bool IsDefeated { get; private set; } // 패배 상태 + public bool IsVictory { get; private set; } // 모든 웨이브 클리어 private readonly List _aliveEnemies = new(); - private CancellationTokenSource _waveCts; + private CancellationTokenSource _waveCts; // 전체 웨이브 진행 취소 토큰 (OnDestroy/StopWaves에서 취소) private void Start() { @@ -50,18 +72,22 @@ private void OnDestroy() _waveCts?.Dispose(); } + // 외부에서 호출해 웨이브 시작 (예: UI 버튼, 트리거). public void StartWaves() { if (_waves == null || _waves.Count == 0) return; RunAllWaves(); } + // 진행 중인 웨이브 강제 중단. 게임 오버/메뉴 진입 등에 사용. public void StopWaves() { _waveCts?.Cancel(); IsRunning = false; } + // 모든 웨이브를 순차 실행하는 메인 비동기 루프. + // try/catch로 토큰 취소 시 예외 흡수 → OnDestroy 시 깔끔하게 종료. private async void RunAllWaves() { _waveCts?.Cancel(); @@ -106,12 +132,20 @@ private async void RunAllWaves() catch (OperationCanceledException) { } } + // 한 웨이브를 처리. + // 반환값: true = 클리어, false = 타임아웃(패배) + // 본 루프는 매 프레임: + // 1) 죽은 적 자동 정리 + // 2) 모두 스폰 완료 + 살아있는 적 0 → 클리어 + // 3) 타이머 0 도달 → 패배 + // 4) 타이머 감소 private async Awaitable RunWave(WaveData wave, CancellationToken token) { IsRunning = true; TimeRemaining = wave.TimeLimit; _aliveEnemies.Clear(); + // 이번 웨이브에서 스폰할 총 적 수 계산. int totalToSpawn = 0; foreach (var entry in wave.Spawns) if (entry != null) totalToSpawn += Mathf.Max(entry.Count, 0); @@ -125,6 +159,8 @@ private async Awaitable RunWave(WaveData wave, CancellationToken token) token.ThrowIfCancellationRequested(); _aliveEnemies.RemoveAll(e => e == null); + // 클리어 조건: 모든 적이 스폰됐고 + 살아있는 적이 0. + // (스폰 중에 이미 죽었다고 클리어 인정하면 안 되니까 RemainingToSpawn=0도 같이 체크) if (RemainingToSpawn == 0 && _aliveEnemies.Count == 0) { IsRunning = false; @@ -135,13 +171,15 @@ private async Awaitable RunWave(WaveData wave, CancellationToken token) if (TimeRemaining <= 0f) { IsRunning = false; - return false; + return false; // 패배 } await Awaitable.NextFrameAsync(token); } } + // 적을 SpawnInterval 간격으로 스폰. 비동기로 흘려보내고 본 루프와 병렬 실행. + // (스폰 중에도 본 루프의 타이머 카운트다운 + 클리어 체크가 계속 돌아감) private async void SpawnWaveEnemiesAsync(WaveData wave, CancellationToken token) { try @@ -208,6 +246,7 @@ private void DestroyAliveEnemies() _aliveEnemies.Clear(); } + // 디버그용 OnGUI 표시. 실제 게임 UI는 별도로 구성하고 이건 빠르게 확인용. private void OnGUI() { GUIStyle style = new GUIStyle(GUI.skin.box) diff --git a/Assets/02_Scripts/Managers/InputManager.cs b/Assets/02_Scripts/Managers/InputManager.cs index d62a485..0687196 100644 --- a/Assets/02_Scripts/Managers/InputManager.cs +++ b/Assets/02_Scripts/Managers/InputManager.cs @@ -2,14 +2,31 @@ using UnityEngine; using UnityEngine.InputSystem; +// ============================================================================ +// InputManager +// ---------------------------------------------------------------------------- +// Unity Input System의 자동 생성된 GameInput을 직접 인스턴스화해서 관리. +// PlayerInput 컴포넌트 안 쓰고 코드로만 처리 → 씬 세팅 의존성 최소화. +// +// 동작: +// - 싱글톤 (Instance 정적 참조) +// - Awake에서 GameInput 생성 + IPlayerActions 인터페이스로 콜백 등록 +// - 각 InputAction이 발화되면 해당 C# event 발화 +// - PlayerController 등 외부에서 event 구독해서 처리 +// +// 모든 액션은 InputActionPhase.Started 시점에만 발화 (눌렀을 때 1회). +// Move만 ctx.ReadValue로 매 프레임 값 전달 (analog 입력). +// ============================================================================ public class InputManager : MonoBehaviour, GameInput.IPlayerActions { + // 외부에서 InputManager.Instance.OnXxx_Event += handler 형태로 구독. public static InputManager Instance { get; private set; } private GameInput _input; - public event Action OnMove_Event; - public event Action OnJump_Event; + // ─── 입력 이벤트들 (PlayerController 등이 구독) ────────────────────── + public event Action OnMove_Event; // 매 프레임 (방향키 값) + public event Action OnJump_Event; // 한 번씩 (눌렀을 때) public event Action OnPunch_Event; public event Action OnKick_Event; public event Action OnDash_Event; @@ -17,6 +34,7 @@ public class InputManager : MonoBehaviour, GameInput.IPlayerActions public event Action OnBackDash_Event; public event Action OnGrabSmash_Event; + // 싱글톤 초기화 + GameInput 생성 + 콜백 등록. private void Awake() { if (Instance != null && Instance != this) @@ -27,20 +45,24 @@ private void Awake() Instance = this; _input = new GameInput(); + // IPlayerActions의 OnMove/OnJump/... 메서드를 인풋 액션의 콜백으로 자동 연결. _input.Player.SetCallbacks(this); } + // GameInput은 활성/비활성 토글이 필요한 자원. ?. 처리로 Awake보다 OnEnable이 먼저 호출되는 경우 보호. private void OnEnable() => _input?.Player.Enable(); - private void OnDisable() => _input?.Player.Disable(); - private void OnDestroy() => _input?.Dispose(); - + + // ─── 콜백 구현 (IPlayerActions 인터페이스) ─────────────────────────── + // Move는 값을 그대로 전달 (정규화/디지털화는 PlayerController가 결정). public void OnMove(InputAction.CallbackContext ctx) { OnMove_Event?.Invoke(ctx.ReadValue()); } + // 버튼 타입 입력들은 Started phase에서만 발화 → "눌렀을 때 1회". + // 계속 누르고 있어도 추가 발화 안 됨. canceled (뗌)는 무시. public void OnJump(InputAction.CallbackContext ctx) { if (ctx.phase == InputActionPhase.Started) @@ -51,7 +73,6 @@ public void OnPunch(InputAction.CallbackContext ctx) { if (ctx.phase == InputActionPhase.Started) OnPunch_Event?.Invoke(); - } public void OnKick(InputAction.CallbackContext ctx) diff --git a/Assets/02_Scripts/Player/PlayerController.cs b/Assets/02_Scripts/Player/PlayerController.cs index b0ac302..0e34287 100644 --- a/Assets/02_Scripts/Player/PlayerController.cs +++ b/Assets/02_Scripts/Player/PlayerController.cs @@ -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 _motionCooldownTimers = new(); - private readonly List _motionCooldownKeys = new(); + [SerializeField] private ComboNode _dashRootNode; // Dash 진입 노드 + [SerializeField] private ComboNode _rollRootNode; // Roll + [SerializeField] private ComboNode _backDashRootNode; // 후방 대시 + [SerializeField] private ComboNode _grabSmashRootNode; // 잡기 콤보 진입 + private readonly Dictionary _motionCooldownTimers = new(); // 액션별 쿨다운 남은 시간 + private readonly List _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 _castResults = new(); - private Collider2D[] _bodyColliders; + private readonly List _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(); @@ -112,9 +134,12 @@ private void Awake() _spriteRenderer = GetComponentInChildren(); _bodyColliders = GetComponentsInChildren(); 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; diff --git a/Assets/02_Scripts/UI/HpBar.cs b/Assets/02_Scripts/UI/HpBar.cs index e74aeec..623b338 100644 --- a/Assets/02_Scripts/UI/HpBar.cs +++ b/Assets/02_Scripts/UI/HpBar.cs @@ -1,13 +1,29 @@ using UnityEngine; +// ============================================================================ +// HpBar +// ---------------------------------------------------------------------------- +// SpriteRenderer 기반 HP바. Canvas보다 가벼움 (Canvas Rebuild 비용 없음). +// 부모(또는 Inspector 할당)의 Health 컴포넌트 이벤트 OnHealthChanged 구독. +// +// 동작: +// - Background와 Fill 두 SpriteRenderer로 구성 +// - Fill의 스프라이트 피벗은 LEFT (0, 0.5) 여야 X 스케일 변경 시 왼쪽부터 줄어듦 +// - HP 비율에 따라 _fill.localScale.x 조정 +// - 임계값 기반으로 색상도 자동 변경 (높음/중간/낮음) +// ============================================================================ public class HpBar : MonoBehaviour { - [SerializeField] private Health _health; - [SerializeField] private Transform _fill; - [SerializeField] private bool _autoFindHealthInParent = true; - [SerializeField] private bool _hideWhenFull = true; - [SerializeField] private float _smoothSpeed = 0f; + [SerializeField] private Health _health; // 비워두면 _autoFindHealthInParent로 자동 검색 + [SerializeField] private Transform _fill; // 채움 sprite transform (스케일 조정 대상) + [SerializeField] private bool _autoFindHealthInParent = true; // _health가 null이면 부모에서 Health 자동 검색 + [SerializeField] private bool _hideWhenFull = true; // HP 풀이거나 0일 때 HpBar 자동 숨김 + [SerializeField] private float _smoothSpeed = 0f; // 0이면 즉시 반영, 0보다 크면 보간 (units/sec) + // ─── 임계값별 색상 ────────────────────────────────────────────────── + // ratio > _midThreshold → _highColor (정상) + // _lowThreshold < ratio <= _midThreshold → _midColor (주의) + // ratio <= _lowThreshold → _lowColor (위험) [Header("Color Thresholds")] [SerializeField] private Color _highColor = new Color(0.2f, 0.9f, 0.3f, 1f); [SerializeField] private Color _midColor = new Color(1f, 0.85f, 0.2f, 1f); @@ -15,10 +31,10 @@ public class HpBar : MonoBehaviour [SerializeField, Range(0f, 1f)] private float _midThreshold = 0.5f; [SerializeField, Range(0f, 1f)] private float _lowThreshold = 0.2f; - private Vector3 _baseFillScale; - private SpriteRenderer _fillRenderer; - private float _currentRatio = 1f; - private float _targetRatio = 1f; + private Vector3 _baseFillScale; // 풀체력 시 Fill 스케일 (Awake에 캐싱, 이후 ratio 곱해서 사용) + private SpriteRenderer _fillRenderer; // Fill의 색상 변경용 SpriteRenderer + private float _currentRatio = 1f; // 현재 표시된 HP 비율 (보간 진행 시 _targetRatio로 수렴) + private float _targetRatio = 1f; // 도달해야 할 HP 비율 private void Awake() { @@ -32,6 +48,8 @@ private void Awake() } } + // 활성/비활성 토글 시 자동 구독·해제 (메모리 누수 방지). + // 활성 시 즉시 한 번 갱신해서 현재 HP 상태 반영. private void OnEnable() { if (_health == null) return; @@ -46,6 +64,7 @@ private void OnDisable() _health.OnHealthChanged -= HandleHealthChanged; } + // 보간 모드일 때만 매 프레임 스케일 갱신. private void Update() { if (_smoothSpeed <= 0f) return; @@ -55,10 +74,12 @@ private void Update() ApplyScale(); } + // Health.OnHealthChanged 이벤트 콜백. ratio 계산 → 스케일/색상 갱신 → 숨김 처리. private void HandleHealthChanged(int current, int max) { _targetRatio = max > 0 ? (float)current / max : 0f; + // 즉시 모드면 바로 스케일 반영. 보간 모드면 Update에서 점진적으로. if (_smoothSpeed <= 0f) { _currentRatio = _targetRatio; @@ -67,6 +88,7 @@ private void HandleHealthChanged(int current, int max) ApplyColor(_targetRatio); + // 풀체력(1)이거나 사망(0)이면 HpBar 자체를 숨김 (UI 정리 + GameObject 부하 감소). if (_hideWhenFull && _fill != null) { bool shouldShow = _targetRatio < 1f && _targetRatio > 0f; @@ -75,6 +97,7 @@ private void HandleHealthChanged(int current, int max) } } + // Fill의 X 스케일 = baseScale.x × ratio. 피벗이 LEFT여야 왼쪽부터 채워짐. private void ApplyScale() { if (_fill == null) return; @@ -84,6 +107,7 @@ private void ApplyScale() _fill.localScale = scale; } + // 비율 구간 매핑으로 색상 결정. 임계값 교차 시 즉시 바뀜 (보간 안 함). private void ApplyColor(float ratio) { if (_fillRenderer == null) return;