주석추가

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

View File

@@ -1,52 +1,71 @@
using UnityEngine; using UnityEngine;
using UnityEngine.Serialization; using UnityEngine.Serialization;
// ============================================================================
// ActionData
// ----------------------------------------------------------------------------
// 모든 액션(공격/모션/잡기/그라운드파운드)의 데이터를 표현하는 ScriptableObject.
// .asset 파일로 만들어 Inspector에서 디자이너가 직접 편집 가능.
// PlayerController는 코드 없이 데이터만 바꿔서 새 액션 추가 가능.
//
// 액션 종류 구분:
// - HasMotion = true → 이동 액션 (Dash/Roll 등)
// - HasHit = true → 데미지 액션 (펀치/킥 등)
// - IsGrab = true → 잡기 (가까운 적을 끌어당김)
// - 위 세 가지 조합 가능 → "전진하면서 때리는 콤보" 등
// ============================================================================
[CreateAssetMenu(fileName = "ActionData", menuName = "Combat/ActionData")] [CreateAssetMenu(fileName = "ActionData", menuName = "Combat/ActionData")]
public class ActionData : ScriptableObject public class ActionData : ScriptableObject
{ {
// [FormerlySerializedAs]: 옛 이름의 직렬화 데이터도 자동 로드 (기존 에셋 호환).
[FormerlySerializedAs("AttackName")] [FormerlySerializedAs("AttackName")]
[FormerlySerializedAs("MotionName")] [FormerlySerializedAs("MotionName")]
public string ActionName; public string ActionName; // 디버그/Inspector 표시용 이름
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 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")] [Header("Motion")]
public bool HasMotion; public bool HasMotion; // 이 액션이 위치 이동을 동반하는지
public Vector2 Velocity = Vector2.zero; public Vector2 Velocity = Vector2.zero; // 모션 속도 (X는 facing 방향으로 자동 부호 변환)
public AnimationCurve MotionSpeedCurve = AnimationCurve.Linear(0f, 1f, 1f, 1f); public AnimationCurve MotionSpeedCurve = AnimationCurve.Linear(0f, 1f, 1f, 1f); // 시간에 따른 속도 곱연산 (가속/감속)
[FormerlySerializedAs("Duration")] [FormerlySerializedAs("Duration")]
public float MotionDuration = 0.3f; public float MotionDuration = 0.3f; // 모션 전체 길이 (애니메이션 길이 안 쓸 때)
public bool CanMoveDuringAction; public bool CanMoveDuringAction; // 액션 중 좌우 입력 허용 여부 (보통 false)
public bool CanTurnDuringAction; public bool CanTurnDuringAction; // 액션 중 페이싱 변경 허용 여부
public bool UseInputDirection = true; public bool UseInputDirection = true; // true면 현재 입력 방향, false면 현재 페이싱 방향으로 이동
public bool PreserveYVelocity = true; public bool PreserveYVelocity = true; // true면 점프/낙하 중인 vy 유지, false면 ActionData.Velocity.y로 덮어씀
public bool StopHorizontalVelocityOnEnd = true; public bool StopHorizontalVelocityOnEnd = true; // 액션 종료 시 vx를 0으로 (관성으로 더 가지 않게)
// ─── 공격 판정 (HasHit=true일 때 적용) ─────────────────────────────
[Header("Hit")] [Header("Hit")]
public bool HasHit = true; public bool HasHit = true; // 이 액션이 데미지를 주는지
public Vector2 Offset = new Vector2(0.5f, 0f); public Vector2 Offset = new Vector2(0.5f, 0f); // 캐릭터 기준 hit 영역 중심 (X는 facing 방향)
public float Radius = 0.5f; public float Radius = 0.5f; // hit 영역 반경 (AttackHitbox.CircleCollider2D)
public int Damage = 10; public int Damage = 10; // 데미지 양
public float HitTiming = 0.15f; public float HitTiming = 0.15f; // 액션 시작 후 hit 발동까지 시간 (선딜)
public float HitDuration = 0f; public float HitDuration = 0f; // hit 영역이 활성 상태로 유지되는 시간 (0이면 단발)
// ─── 피격자 반응 (피격된 적의 동작) ─────────────────────────────────
[Header("Hit Reaction")] [Header("Hit Reaction")]
public Vector2 HitVelocity = Vector2.zero; public Vector2 HitVelocity = Vector2.zero; // 적에게 가할 넉백 속도 (X는 공격자 facing 방향)
public bool UseHitPositionCorrection; public bool UseHitPositionCorrection; // 적의 위치를 강제로 보정할지 (잡기/연계 안정성)
public Vector2 HitTargetOffset = new Vector2(0.8f, 0f); public Vector2 HitTargetOffset = new Vector2(0.8f, 0f); // 보정 시 공격자 기준 적의 목표 위치
public float HitPositionCorrectionDuration = 0.08f; public float HitPositionCorrectionDuration = 0.08f; // 보정 보간 시간 (0이면 즉시 텔레포트)
public bool CorrectHitTargetY; public bool CorrectHitTargetY; // 보정에서 Y도 이동시킬지 (false면 X만)
public string HitReactionAnimationState; public string HitReactionAnimationState; // 적이 재생할 피격 애니메이션 State
// ─── 잡기 전용 (IsGrab=true일 때 적용) ─────────────────────────────
[Header("Grab")] [Header("Grab")]
public bool IsGrab; public bool IsGrab; // 잡기 액션 (GrabRoutine에서 처리)
public Vector2 GrabOffset = new Vector2(0.6f, 0f); public Vector2 GrabOffset = new Vector2(0.6f, 0f); // 잡힌 적의 위치 (공격자 기준)
public AnimationCurve GrabOffsetXCurve = AnimationCurve.Linear(0f, 1f, 1f, 1f); public AnimationCurve GrabOffsetXCurve = AnimationCurve.Linear(0f, 1f, 1f, 1f); // 시간에 따른 X 위치 비율 (당기기 효과)
public AnimationCurve GrabOffsetYCurve = AnimationCurve.Linear(0f, 1f, 1f, 1f); public AnimationCurve GrabOffsetYCurve = AnimationCurve.Linear(0f, 1f, 1f, 1f); // 시간에 따른 Y 위치 비율 (들어올리기 효과)
public string GrabbedAnimationState; public string GrabbedAnimationState; // 잡힌 적이 재생할 애니메이션
public float GrabSearchRadius = 2f; public float GrabSearchRadius = 2f; // 잡기 타겟 검색 반경 (이 안에서 후보 찾기)
public float GrabRange = 0.5f; public float GrabRange = 0.5f; // 실제 잡기 가능 거리 (후보 중 이 거리 안에 있어야 잡힘)
} }

View File

@@ -1,22 +1,40 @@
using System.Collections.Generic; using System.Collections.Generic;
using UnityEngine; 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))] [RequireComponent(typeof(CircleCollider2D))]
public class AttackHitbox : MonoBehaviour public class AttackHitbox : MonoBehaviour
{ {
// PlayerController가 구독해서 "방금 hit한 적" 추적용 (잡기 타겟 우선 등에 활용).
public event System.Action<IDamageable> OnHit; public event System.Action<IDamageable> OnHit;
private CircleCollider2D _collider; private CircleCollider2D _collider;
// ─── 현재 활성 액션의 데미지/효과 데이터 (Activate에서 세팅) ─────────
private int _damage; private int _damage;
private Vector2 _hitVelocity; private Vector2 _hitVelocity; // 피해자에게 가할 넉백 속도
private Vector2 _hitSourcePosition; private Vector2 _hitSourcePosition; // 공격자 위치 (피격자 위치 보정 거리 계산용)
private Vector2? _hitTargetPosition; private Vector2? _hitTargetPosition; // 피격자 강제 이동 목표 위치 (null이면 보정 안 함)
private float _hitPositionMinDistance; private float _hitPositionMinDistance; // 이 거리보다 가까우면 위치 보정 적용
private bool _correctHitTargetY; private bool _correctHitTargetY; // 위치 보정에서 Y도 보정할지
private int _hitPositionSolidMask; private int _hitPositionSolidMask; // 보정 시 끼이지 않게 검사할 솔리드 레이어
private float _hitPositionCorrectionDuration; private float _hitPositionCorrectionDuration; // 보정 보간 시간 (0이면 즉시)
private string _hitReactionState; private string _hitReactionState; // 피격자가 재생할 애니메이션 State 이름
private LayerMask _targetLayer; private LayerMask _targetLayer; // 데미지를 줄 레이어 (보통 Enemy)
// 한 번 hit한 IDamageable은 이 액션 활성 동안 다시 hit되지 않음.
private readonly HashSet<IDamageable> _alreadyHit = new(); private readonly HashSet<IDamageable> _alreadyHit = new();
private void Awake() private void Awake()
@@ -27,6 +45,8 @@ private void Awake()
_collider.enabled = false; _collider.enabled = false;
} }
// 액션 시작 시 호출. 위치/반경/데미지 등 모든 파라미터 세팅 후 콜라이더 활성화.
// _alreadyHit를 클리어해서 새 공격으로 다시 hit 가능하게 함.
public void Activate(ActionData data, Vector2 localPosition, Vector2 hitVelocity, Vector2 sourcePosition, Vector2? hitTargetPosition, bool correctHitTargetY, int hitPositionSolidMask, LayerMask targetLayer) public void Activate(ActionData data, Vector2 localPosition, Vector2 hitVelocity, Vector2 sourcePosition, Vector2? hitTargetPosition, bool correctHitTargetY, int hitPositionSolidMask, LayerMask targetLayer)
{ {
transform.localPosition = localPosition; transform.localPosition = localPosition;
@@ -48,12 +68,15 @@ public void Activate(ActionData data, Vector2 localPosition, Vector2 hitVelocity
ScanImmediateOverlap(); ScanImmediateOverlap();
} }
// 액션의 HitDuration이 끝나면 호출. 콜라이더 비활성화 + hit 기록 초기화.
public void Deactivate() public void Deactivate()
{ {
_collider.enabled = false; _collider.enabled = false;
_alreadyHit.Clear(); _alreadyHit.Clear();
} }
// 활성 순간 즉시 검사: Physics2D.OverlapCircleAll로 현재 겹친 콜라이더를 모두 가져와 TryDamage.
// 이게 없으면 짧은 hit window (예: HitDuration=0.02)에 OnTriggerEnter가 못 따라옴.
private void ScanImmediateOverlap() private void ScanImmediateOverlap()
{ {
Collider2D[] hits = Physics2D.OverlapCircleAll(transform.position, _collider.radius, _targetLayer); Collider2D[] hits = Physics2D.OverlapCircleAll(transform.position, _collider.radius, _targetLayer);
@@ -61,9 +84,17 @@ private void ScanImmediateOverlap()
TryDamage(hit); TryDamage(hit);
} }
// 트리거 이벤트로 들어온 적도 같은 함수로 처리.
// Stay까지 받는 이유: 활성 중간에 새로 들어온 적도 잡으려고.
private void OnTriggerEnter2D(Collider2D other) => TryDamage(other); private void OnTriggerEnter2D(Collider2D other) => TryDamage(other);
private void OnTriggerStay2D(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) private void TryDamage(Collider2D other)
{ {
if ((_targetLayer.value & (1 << other.gameObject.layer)) == 0) return; if ((_targetLayer.value & (1 << other.gameObject.layer)) == 0) return;
@@ -81,6 +112,10 @@ private void TryDamage(Collider2D other)
OnHit?.Invoke(target); OnHit?.Invoke(target);
} }
// 위치 보정 목표 결정.
// 적이 공격자에게 너무 가까우면 (X 거리 < minDistance) hitTargetPosition을 반환,
// 충분히 떨어져 있으면 null 반환해서 보정 안 함.
// (가까운 적만 끌어당기는 "흡착" 효과 구현)
private Vector2? GetCorrectionTargetPosition(Collider2D other) private Vector2? GetCorrectionTargetPosition(Collider2D other)
{ {
if (!_hitTargetPosition.HasValue) return null; if (!_hitTargetPosition.HasValue) return null;

View File

@@ -2,29 +2,46 @@
using UnityEngine; using UnityEngine;
using UnityEngine.Serialization; 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 public enum ComboInputType
{ {
Punch, Punch,
Kick, Kick,
Grab, Grab,
Motion Motion // 모션 액션 트리거 (현재는 사용 안 함, 확장 여지로 남겨둠)
} }
// 한 노드에서 다음 노드로 가는 "간선" 정보.
[Serializable] [Serializable]
public class ComboTransition public class ComboTransition
{ {
public ComboInputType Trigger; public ComboInputType Trigger; // 어떤 입력이 들어와야 이 transition을 탈지
public ComboNode Next; public ComboNode Next; // 이동할 다음 노드
public float ForwardStep = 0f; public float ForwardStep = 0f; // 이 transition을 탈 때 전진할 거리 (적과 거리 좁히기)
public float ForwardStepDuration = 0.1f; public float ForwardStepDuration = 0.1f;// 전진 동작 시간
} }
// 콤보 트리의 노드. .asset 파일로 관리.
[CreateAssetMenu(fileName = "ComboNode", menuName = "Combat/ComboNode")] [CreateAssetMenu(fileName = "ComboNode", menuName = "Combat/ComboNode")]
public class ComboNode : ScriptableObject public class ComboNode : ScriptableObject
{ {
public string NodeName; public string NodeName; // Inspector 식별용
[FormerlySerializedAs("Attack")] [FormerlySerializedAs("Attack")]
public ActionData Action; public ActionData Action; // 이 노드에 진입했을 때 수행할 액션
public float ComboWindow = 0.8f; public float ComboWindow = 0.8f; // 이 노드에서 다음 입력 받을 수 있는 시간
public ComboTransition[] Transitions; public ComboTransition[] Transitions; // 다음 노드들 (입력별로 분기)
} }

View File

@@ -1,16 +1,33 @@
using System; using System;
using UnityEngine; 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 public class Health : MonoBehaviour
{ {
[SerializeField] private int _maxHealth = 30; [SerializeField] private int _maxHealth = 30;
private int _currentHealth; private int _currentHealth;
// ─── 읽기 전용 프로퍼티 ──────────────────────────────────────────────
public int MaxHealth => _maxHealth; public int MaxHealth => _maxHealth;
public int CurrentHealth => _currentHealth; public int CurrentHealth => _currentHealth;
public float Ratio => _maxHealth > 0 ? (float)_currentHealth / _maxHealth : 0f; public float Ratio => _maxHealth > 0 ? (float)_currentHealth / _maxHealth : 0f;
public bool IsDead => _currentHealth <= 0; public bool IsDead => _currentHealth <= 0;
// ─── 외부 구독용 이벤트 ──────────────────────────────────────────────
// OnHealthChanged: (current, max). HP바, 데미지 숫자 표시 등에 사용.
// OnDied: 사망 순간 1회만 발화. Enemy.HandleDeath에서 구독.
public event Action<int, int> OnHealthChanged; public event Action<int, int> OnHealthChanged;
public event Action OnDied; public event Action OnDied;
@@ -19,6 +36,8 @@ private void Awake()
_currentHealth = _maxHealth; _currentHealth = _maxHealth;
} }
// 데미지 적용. 양수 데미지만 받고, 이미 죽었으면 무시.
// OnDied는 "방금 죽은 순간"에만 발화 (previous > 0 && current == 0).
public void TakeDamage(int amount) public void TakeDamage(int amount)
{ {
if (amount <= 0 || IsDead) return; if (amount <= 0 || IsDead) return;
@@ -31,6 +50,7 @@ public void TakeDamage(int amount)
OnDied?.Invoke(); OnDied?.Invoke();
} }
// 회복. 죽은 상태에서는 회복 안 됨 (부활 로직은 별도로 만들어야 함).
public void Heal(int amount) public void Heal(int amount)
{ {
if (amount <= 0 || IsDead) return; if (amount <= 0 || IsDead) return;
@@ -39,12 +59,14 @@ public void Heal(int amount)
OnHealthChanged?.Invoke(_currentHealth, _maxHealth); OnHealthChanged?.Invoke(_currentHealth, _maxHealth);
} }
// 풀체력으로 리셋. 부활/재시작 시 사용.
public void ResetHealth() public void ResetHealth()
{ {
_currentHealth = _maxHealth; _currentHealth = _maxHealth;
OnHealthChanged?.Invoke(_currentHealth, _maxHealth); OnHealthChanged?.Invoke(_currentHealth, _maxHealth);
} }
// 최대 HP 변경. fill=true면 현재 HP도 풀로 채우고, false면 새 max로 클램프만.
public void SetMaxHealth(int newMax, bool fill = true) public void SetMaxHealth(int newMax, bool fill = true)
{ {
_maxHealth = Mathf.Max(newMax, 1); _maxHealth = Mathf.Max(newMax, 1);

View File

@@ -1,5 +1,21 @@
using UnityEngine; 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 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); void TakeDamage(int amount, Vector2 hitVelocity = default, string hitReactionAnimationState = null, Vector2? hitTargetPosition = null, bool correctHitTargetY = false, int hitPositionSolidMask = 0, float hitPositionCorrectionDuration = 0f);

View File

@@ -1,39 +1,61 @@
using System.Collections.Generic; using System.Collections.Generic;
using UnityEngine; using UnityEngine;
// ============================================================================
// Enemy
// ----------------------------------------------------------------------------
// 적 캐릭터의 모든 행동을 관리. 현재는 AI 이동 없음 (정지 표적).
// 책임:
// - IDamageable 구현 → 공격 받기
// - 피격 시각 효과 (색 깜빡)
// - 피격 시 넉백 및 위치 보정
// - 벽 충돌 시 반사 (튕기는 효과)
// - 다른 적과의 소프트 분리 (한 점에 겹치지 않도록)
// - 잡기 상태 처리 (플레이어가 강제로 끌고 다님)
// - 사망 처리 (Health.OnDied 이벤트로 트리거)
//
// HP는 Health 컴포넌트로 분리. Enemy는 Health.TakeDamage를 위임 호출.
// ============================================================================
[RequireComponent(typeof(Collider2D))] [RequireComponent(typeof(Collider2D))]
[RequireComponent(typeof(Rigidbody2D))] [RequireComponent(typeof(Rigidbody2D))]
[RequireComponent(typeof(Health))] [RequireComponent(typeof(Health))]
public class Enemy : MonoBehaviour, IDamageable public class Enemy : MonoBehaviour, IDamageable
{ {
// ─── 피격 시각 효과 ──────────────────────────────────────────────────
[Header("Hit Feedback")] [Header("Hit Feedback")]
[SerializeField] private float _hitFlashDuration = 0.1f; [SerializeField] private float _hitFlashDuration = 0.1f; // 빨강 깜빡 지속 시간
[SerializeField] private Color _hitFlashColor = Color.red; [SerializeField] private Color _hitFlashColor = Color.red; // 깜빡 색상
// ─── 넉백 / 벽 반사 파라미터 ─────────────────────────────────────────
[Header("Hit Bounce")] [Header("Hit Bounce")]
[SerializeField] private float _hitReactionDuration = 0.5f; [SerializeField] private float _hitReactionDuration = 0.5f; // 넉백 유효 시간 (벽 반사 가능 시간)
[SerializeField] private float _airborneHitYVelocity = 3f; [SerializeField] private float _airborneHitYVelocity = 3f; // 공중에서 맞을 때 강제 Y 속도 (띄우기 효과)
[SerializeField] private float _wallBounceVelocityMultiplier = 0.8f; [SerializeField] private float _wallBounceVelocityMultiplier = 0.8f;// 벽에 부딪힐 때 속도 감쇠
[SerializeField] private float _wallBounceMinXVelocity = 1f; [SerializeField] private float _wallBounceMinXVelocity = 1f; // 이 값보다 느리면 반사 안 함 (작은 충돌 무시)
[SerializeField] private float _wallBounceUpwardVelocity = 1.5f; [SerializeField] private float _wallBounceUpwardVelocity = 1.5f; // 반사 후 최소 Y 속도 (위로 살짝 튀게)
// ─── 다른 적과의 시각적 분리 ─────────────────────────────────────────
// 같은 위치에 적이 겹쳐 보이지 않도록 살짝 옆으로 미는 힘.
[Header("Separation")] [Header("Separation")]
[SerializeField] private float _separationRadius = 0.6f; [SerializeField] private float _separationRadius = 0.6f; // 이 거리 안의 다른 적과 분리
[SerializeField] private float _separationStrength = 2f; [SerializeField] private float _separationStrength = 2f; // 분리 강도 (units/sec)
[SerializeField] private LayerMask _separationLayer; [SerializeField] private LayerMask _separationLayer; // 검사 대상 레이어 (보통 Enemy)
private static readonly Collider2D[] _separationBuffer = new Collider2D[16]; private static readonly Collider2D[] _separationBuffer = new Collider2D[16]; // OverlapCircle 결과 버퍼 (GC 회피)
private Health _health; private Health _health; // HP 컴포넌트 (별도 분리 → 플레이어도 같은 컴포넌트 재사용 가능)
private Rigidbody2D _rb; private Rigidbody2D _rb;
private Animator _anim; private Animator _anim;
private SpriteRenderer _spriteRenderer; private SpriteRenderer _spriteRenderer;
private Color _originalColor; private Color _originalColor; // hit flash 끝나면 복귀할 원본 색
private float _flashTimer; private float _flashTimer; // 깜빡 남은 시간
private float _hitReactionTimer; private float _hitReactionTimer; // 넉백 유효 시간 카운트다운 (벽 반사 조건)
private bool _isGrounded; private bool _isGrounded;
private Vector2 _lastVelocity; private Vector2 _lastVelocity; // 직전 프레임 속도 (벽 충돌 시 반사 벡터 계산용)
private Collider2D[] _bodyColliders; private Collider2D[] _bodyColliders;
private readonly List<RaycastHit2D> _castResults = new(); private readonly List<RaycastHit2D> _castResults = new();
// ─── 피격 위치 보정 (플레이어 공격이 적의 위치를 안정화) ─────────────
// 잡기/연계의 안정성을 위해, 공격이 적의 위치를 일정한 곳으로 끌어오는 기능.
private const float HitPositionSkinWidth = 0.02f; private const float HitPositionSkinWidth = 0.02f;
private bool _isHitPositionCorrecting; private bool _isHitPositionCorrecting;
private bool _correctHitPositionY; private bool _correctHitPositionY;
@@ -41,10 +63,13 @@ public class Enemy : MonoBehaviour, IDamageable
private float _hitPositionCorrectionDuration; private float _hitPositionCorrectionDuration;
private Vector2 _hitPositionCorrectionStart; private Vector2 _hitPositionCorrectionStart;
private Vector2 _hitPositionCorrectionTarget; 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() private void Awake()
{ {
_health = GetComponent<Health>(); _health = GetComponent<Health>();
@@ -57,12 +82,14 @@ private void Awake()
_originalColor = _spriteRenderer.color; _originalColor = _spriteRenderer.color;
} }
// 이벤트 구독 해제 (Destroy 시 누수 방지).
private void OnDestroy() private void OnDestroy()
{ {
if (_health != null) if (_health != null)
_health.OnDied -= HandleDeath; _health.OnDied -= HandleDeath;
} }
// 매 프레임: hit flash 타이머 + hit reaction 타이머 카운트다운.
private void Update() private void Update()
{ {
if (_flashTimer > 0f) if (_flashTimer > 0f)
@@ -79,12 +106,17 @@ private void Update()
_hitReactionTimer -= Time.deltaTime; _hitReactionTimer -= Time.deltaTime;
} }
// 매 물리 프레임의 메인:
// - 잡힌 상태면 그랩 위치로 강제 이동
// - 그 외엔 피격 위치 보정 진행 + 분리력 적용
// - 벽 반사 계산을 위해 직전 velocity 기록
private void FixedUpdate() private void FixedUpdate()
{ {
if (_rb != null) if (_rb != null)
{ {
if (_isGrabbed) if (_isGrabbed)
{ {
// 잡힌 동안엔 자체 물리 무시하고 플레이어가 지정한 위치로 강제 이동.
_rb.linearVelocity = Vector2.zero; _rb.linearVelocity = Vector2.zero;
_rb.MovePosition(_grabTargetPosition); _rb.MovePosition(_grabTargetPosition);
_lastVelocity = Vector2.zero; _lastVelocity = Vector2.zero;
@@ -97,6 +129,12 @@ private void FixedUpdate()
} }
} }
// 같은 레이어의 다른 적과 너무 가까우면 옆으로 밀어 시각적으로 분리.
// 알고리즘:
// 1) OverlapCircle로 주변 적 검색
// 2) 각 적마다 거리 비례로 push 벡터 누적 (가까울수록 강하게)
// 3) 평균 방향 × Strength × deltaTime 만큼 X축으로만 밀어냄
// (Y로 밀면 공중 부양/점프 효과 생겨서 어색함)
private void ApplySeparation() private void ApplySeparation()
{ {
if (_separationRadius <= 0f || _separationStrength <= 0f) return; if (_separationRadius <= 0f || _separationStrength <= 0f) return;
@@ -117,11 +155,11 @@ private void ApplySeparation()
{ {
Collider2D other = _separationBuffer[i]; Collider2D other = _separationBuffer[i];
if (other == null) continue; if (other == null) continue;
if (other.attachedRigidbody == _rb) continue; if (other.attachedRigidbody == _rb) continue; // 자기 자신 스킵
Vector2 away = _rb.position - (Vector2)other.transform.position; Vector2 away = _rb.position - (Vector2)other.transform.position;
float dist = away.magnitude; float dist = away.magnitude;
if (dist >= _separationRadius) continue; if (dist >= _separationRadius) continue; // 영향권 밖
Vector2 dir; Vector2 dir;
if (dist < 0.001f) if (dist < 0.001f)
@@ -134,6 +172,7 @@ private void ApplySeparation()
dir = away / dist; dir = away / dist;
} }
// 가까울수록 strength가 1에 가까워짐 (멀어질수록 0).
float strength = 1f - (dist / _separationRadius); float strength = 1f - (dist / _separationRadius);
push += dir * strength; push += dir * strength;
contributors++; contributors++;
@@ -146,6 +185,14 @@ private void ApplySeparation()
_rb.position += new Vector2(push.x, 0f) * (_separationStrength * Time.fixedDeltaTime); _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) 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; 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})"); Debug.Log($"{name} 피격: -{amount} (HP: {_health.CurrentHealth}/{_health.MaxHealth})");
} }
// 플레이어가 잡기 액션 시작 시 호출. 이 적이 잡힘 상태로 진입.
// 이후 매 FixedUpdate에서 _grabTargetPosition으로 강제 이동됨.
public void BeginGrab(string grabbedAnimationState, int solidMask) public void BeginGrab(string grabbedAnimationState, int solidMask)
{ {
if (_health == null || _health.IsDead) return; if (_health == null || _health.IsDead) return;
@@ -205,6 +254,8 @@ public void BeginGrab(string grabbedAnimationState, int solidMask)
_anim.Play(grabbedAnimationState); _anim.Play(grabbedAnimationState);
} }
// 플레이어가 잡힌 적을 매 프레임 이동시킬 때 호출 (예: 들어올렸다 내려치기).
// GetSafeHitTargetPosition으로 벽에 끼이지 않게 안전 위치 계산.
public void UpdateGrabPosition(Vector2 position) public void UpdateGrabPosition(Vector2 position)
{ {
if (!_isGrabbed || _rb == null) return; if (!_isGrabbed || _rb == null) return;
@@ -213,11 +264,16 @@ public void UpdateGrabPosition(Vector2 position)
_grabTargetPosition = GetSafeHitTargetPosition(position, _grabSolidMask); _grabTargetPosition = GetSafeHitTargetPosition(position, _grabSolidMask);
} }
// 잡기 종료. 적은 다시 자체 물리로 돌아감.
public void EndGrab() public void EndGrab()
{ {
_isGrabbed = false; _isGrabbed = false;
} }
// Unity의 OnCollisionEnter2D 콜백. 두 가지 일을 처리:
// 1) 지면 상태 갱신 (착지 감지)
// 2) 넉백 중에 벽 부딪히면 반사 (튀어오르는 효과)
// 플레이어와 부딪히면 반사 안 함 (다른 적과 부딪히는 경우만).
private void OnCollisionEnter2D(Collision2D collision) private void OnCollisionEnter2D(Collision2D collision)
{ {
UpdateGroundedState(collision); UpdateGroundedState(collision);
@@ -228,7 +284,7 @@ private void OnCollisionEnter2D(Collision2D collision)
for (int i = 0; i < collision.contactCount; i++) for (int i = 0; i < collision.contactCount; i++)
{ {
Vector2 normal = collision.GetContact(i).normal; Vector2 normal = collision.GetContact(i).normal;
if (Mathf.Abs(normal.x) < 0.5f) continue; if (Mathf.Abs(normal.x) < 0.5f) continue; // 수평 충돌만 처리 (천장/바닥 무시)
BounceOffWall(normal); BounceOffWall(normal);
return; return;
@@ -245,6 +301,8 @@ private void OnCollisionExit2D(Collision2D collision)
_isGrounded = false; _isGrounded = false;
} }
// 공중 피격은 항상 위로 띄우는 효과 적용 (격투게임 표준).
// 지상 피격은 hitVelocity 그대로 사용.
private Vector2 GetHitReactionVelocity(Vector2 hitVelocity) private Vector2 GetHitReactionVelocity(Vector2 hitVelocity)
{ {
// 공중 추가타는 고정된 세로 속도를 쓰되, 이전 물리 속도는 절대 이어받지 않는다. // 공중 추가타는 고정된 세로 속도를 쓰되, 이전 물리 속도는 절대 이어받지 않는다.
@@ -281,6 +339,8 @@ private void BeginHitTargetPositionCorrection(Vector2? hitTargetPosition, bool c
_hitPositionCorrectionTarget = targetPosition; _hitPositionCorrectionTarget = targetPosition;
} }
// 매 FixedUpdate에서 진행 중인 위치 보정 보간 한 스텝 실행.
// SmoothStep으로 부드러운 진입/이탈 곡선 사용.
private void ApplySmoothHitPositionCorrection() private void ApplySmoothHitPositionCorrection()
{ {
if (!_isHitPositionCorrecting || _rb == null) return; if (!_isHitPositionCorrecting || _rb == null) return;
@@ -299,6 +359,8 @@ private void ApplySmoothHitPositionCorrection()
_isHitPositionCorrecting = false; _isHitPositionCorrecting = false;
} }
// 목표 위치까지 cast해서 벽에 막히지 않는 최대 거리까지의 위치 반환.
// 위치 보정/잡기 이동 시 적이 벽에 끼이는 걸 방지.
private Vector2 GetSafeHitTargetPosition(Vector2 targetPosition, int solidMask) private Vector2 GetSafeHitTargetPosition(Vector2 targetPosition, int solidMask)
{ {
if (solidMask == 0) return targetPosition; if (solidMask == 0) return targetPosition;
@@ -311,12 +373,14 @@ private Vector2 GetSafeHitTargetPosition(Vector2 targetPosition, int solidMask)
Vector2 direction = moveDelta / distance; Vector2 direction = moveDelta / distance;
float closestDistance = GetClosestBodyCastDistance(direction, distance + HitPositionSkinWidth, solidMask); float closestDistance = GetClosestBodyCastDistance(direction, distance + HitPositionSkinWidth, solidMask);
if (closestDistance >= distance + HitPositionSkinWidth) if (closestDistance >= distance + HitPositionSkinWidth)
return targetPosition; return targetPosition; // 막힘 없음 → 목표 그대로
float allowedDistance = Mathf.Max(closestDistance - HitPositionSkinWidth, 0f); 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) private float GetClosestBodyCastDistance(Vector2 direction, float distance, int solidMask)
{ {
if (_bodyColliders == null || _bodyColliders.Length == 0) if (_bodyColliders == null || _bodyColliders.Length == 0)
@@ -347,6 +411,8 @@ private float GetClosestBodyCastDistance(Vector2 direction, float distance, int
return closest; return closest;
} }
// 접촉 노멀의 Y가 0.5보다 크면 (위쪽 방향이면) 지면 위로 판정.
// 비스듬한 경사도 어느정도 지면으로 인정.
private void UpdateGroundedState(Collision2D collision) private void UpdateGroundedState(Collision2D collision)
{ {
for (int i = 0; i < collision.contactCount; i++) 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) private void BounceOffWall(Vector2 wallNormal)
{ {
// 피격 반응 중 옆벽에 부딪히면 현재 넉백 속도를 반사한다. // 피격 반응 중 옆벽에 부딪히면 현재 넉백 속도를 반사한다.
@@ -366,9 +436,10 @@ private void BounceOffWall(Vector2 wallNormal)
? _lastVelocity ? _lastVelocity
: _rb.linearVelocity; : _rb.linearVelocity;
if (Mathf.Abs(incomingVelocity.x) < _wallBounceMinXVelocity) return; if (Mathf.Abs(incomingVelocity.x) < _wallBounceMinXVelocity) return; // 너무 느린 충돌 무시
Vector2 bouncedVelocity = Vector2.Reflect(incomingVelocity, wallNormal) * _wallBounceVelocityMultiplier; Vector2 bouncedVelocity = Vector2.Reflect(incomingVelocity, wallNormal) * _wallBounceVelocityMultiplier;
// 반사 후 Y가 너무 낮으면 위로 튀어오르게 강제 (지면에 깔리지 않도록).
if (bouncedVelocity.y < _wallBounceUpwardVelocity) if (bouncedVelocity.y < _wallBounceUpwardVelocity)
bouncedVelocity.y = _wallBounceUpwardVelocity; bouncedVelocity.y = _wallBounceUpwardVelocity;
@@ -376,6 +447,8 @@ private void BounceOffWall(Vector2 wallNormal)
_hitReactionTimer = _hitReactionDuration; _hitReactionTimer = _hitReactionDuration;
} }
// Health.OnDied 이벤트 콜백. 사망 처리.
// 현재는 단순 Destroy. 나중에 풀링/드롭/이펙트 추가하려면 여기에 확장.
private void HandleDeath() private void HandleDeath()
{ {
Debug.Log($"{name} 사망"); Debug.Log($"{name} 사망");

View File

@@ -1,42 +1,59 @@
using System.Collections.Generic; using System.Collections.Generic;
using UnityEngine; 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 public class EnemySpawner : MonoBehaviour
{ {
// ─── 스폰 설정 ───────────────────────────────────────────────────────
[Header("Spawn Configuration")] [Header("Spawn Configuration")]
[SerializeField] private Enemy _enemyPrefab; [SerializeField] private Enemy _enemyPrefab; // 스폰할 적 프리팹
[SerializeField] private int _maxAliveCount = 3; [SerializeField] private int _maxAliveCount = 3; // 동시에 살아있을 수 있는 최대 수
[SerializeField] private float _spawnInterval = 2f; [SerializeField] private float _spawnInterval = 2f; // 스폰 간격 (초)
[SerializeField] private float _initialDelay = 0f; [SerializeField] private float _initialDelay = 0f; // 첫 스폰 전 대기 시간
// ─── 위치 설정 ───────────────────────────────────────────────────────
[Header("Spawn Position")] [Header("Spawn Position")]
[SerializeField] private Transform[] _spawnPoints; [SerializeField] private Transform[] _spawnPoints; // 비워두면 자기 위치, 여러 개면 랜덤 선택
[SerializeField] private float _spawnRadius = 0f; [SerializeField] private float _spawnRadius = 0f; // 0보다 크면 spawn point 주변 무작위 분산
[SerializeField] private Transform _enemyParent; [SerializeField] private Transform _enemyParent; // 스폰된 적을 묶을 부모 (Hierarchy 정리용)
// ─── 동작 옵션 ───────────────────────────────────────────────────────
[Header("Behavior")] [Header("Behavior")]
[SerializeField] private bool _spawnOnStart = true; [SerializeField] private bool _spawnOnStart = true; // Start에서 자동 스폰 시작
[SerializeField] private bool _respawnOnDeath = true; [SerializeField] private bool _respawnOnDeath = true; // 죽으면 자동 리스폰 (false면 일회성)
[SerializeField] private int _totalSpawnLimit = 0; [SerializeField] private int _totalSpawnLimit = 0; // 누적 스폰 한도 (0이면 무제한)
private readonly List<Enemy> _aliveEnemies = new(); private readonly List<Enemy> _aliveEnemies = new(); // 현재 살아있는 적들 (자동 정리됨)
private float _nextSpawnTime; private float _nextSpawnTime; // 다음 스폰 가능 시각
private int _totalSpawned; private int _totalSpawned; // 지금까지 누적 스폰 수
private bool _active; private bool _active; // 스폰 활성 상태
private void Start() private void Start()
{ {
if (_spawnOnStart) BeginSpawning(); if (_spawnOnStart) BeginSpawning();
} }
// 외부 트리거에서 스폰 시작 (예: 플레이어가 영역 진입).
public void BeginSpawning() public void BeginSpawning()
{ {
_active = true; _active = true;
_nextSpawnTime = Time.time + _initialDelay; _nextSpawnTime = Time.time + _initialDelay;
} }
// 스폰 일시 중지. 살아있는 적은 그대로 유지.
public void StopSpawning() => _active = false; public void StopSpawning() => _active = false;
// 스폰 + 살아있는 적 모두 제거 + 카운터 초기화. 재시작용.
public void ResetSpawner() public void ResetSpawner()
{ {
for (int i = _aliveEnemies.Count - 1; i >= 0; i--) for (int i = _aliveEnemies.Count - 1; i >= 0; i--)
@@ -53,18 +70,21 @@ private void Update()
{ {
if (!_active) return; if (!_active) return;
// Destroy된 적 참조는 null이 됨. 자동 정리.
_aliveEnemies.RemoveAll(e => e == null); _aliveEnemies.RemoveAll(e => e == null);
if (!_respawnOnDeath && _aliveEnemies.Count == 0 && _totalSpawned > 0) return; // 조기 종료 조건들:
if (_totalSpawnLimit > 0 && _totalSpawned >= _totalSpawnLimit) return; if (!_respawnOnDeath && _aliveEnemies.Count == 0 && _totalSpawned > 0) return; // 일회성이고 다 죽었으면 끝
if (_aliveEnemies.Count >= _maxAliveCount) return; if (_totalSpawnLimit > 0 && _totalSpawned >= _totalSpawnLimit) return; // 누적 한도 도달
if (Time.time < _nextSpawnTime) return; if (_aliveEnemies.Count >= _maxAliveCount) return; // 동시 최대치 도달
if (Time.time < _nextSpawnTime) return; // 다음 스폰 시간 안 됨
if (_enemyPrefab == null) return; if (_enemyPrefab == null) return;
SpawnOne(); SpawnOne();
_nextSpawnTime = Time.time + _spawnInterval; _nextSpawnTime = Time.time + _spawnInterval;
} }
// 1마리 즉시 스폰. 외부에서 직접 호출 가능 (예: 이벤트 트리거).
public Enemy SpawnOne() public Enemy SpawnOne()
{ {
if (_enemyPrefab == null) return null; if (_enemyPrefab == null) return null;
@@ -76,6 +96,10 @@ public Enemy SpawnOne()
return enemy; return enemy;
} }
// 스폰 위치 결정:
// 1) Spawn Points 있으면 그중 랜덤 선택
// 2) 없으면 자기 위치
// 3) Spawn Radius > 0이면 위 위치에서 ±radius 무작위 오프셋 추가
private Vector3 GetSpawnPosition() private Vector3 GetSpawnPosition()
{ {
Vector3 basePos; Vector3 basePos;
@@ -99,6 +123,8 @@ private Vector3 GetSpawnPosition()
return basePos; return basePos;
} }
// Scene 뷰에서 스폰 위치/반경 시각화.
// 각 spawn point마다 시안색 원(위치)과 반경 원(분산) 표시.
private void OnDrawGizmosSelected() private void OnDrawGizmosSelected()
{ {
Color pointColor = Color.cyan; Color pointColor = Color.cyan;

View File

@@ -2,19 +2,32 @@
using System.Collections.Generic; using System.Collections.Generic;
using UnityEngine; 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")] [CreateAssetMenu(fileName = "WaveData", menuName = "Combat/WaveData")]
public class WaveData : ScriptableObject public class WaveData : ScriptableObject
{ {
public string WaveName; public string WaveName; // UI/로그용 식별자
public List<SpawnEntry> Spawns = new(); public List<SpawnEntry> Spawns = new(); // 스폰할 적 종류와 수량 목록
public float TimeLimit = 30f; public float TimeLimit = 30f; // 이 시간 안에 모두 잡지 못하면 패배 (초)
public float SpawnInterval = 0.3f; public float SpawnInterval = 0.3f; // 적 한 마리씩 스폰하는 시간 간격
public float StartDelay = 0f; public float StartDelay = 0f; // 웨이브 시작 전 대기 시간 (인트로/카운트다운 시간)
} }
// 한 종류의 적을 몇 마리 스폰할지 표현.
[Serializable] [Serializable]
public class SpawnEntry public class SpawnEntry
{ {
public Enemy EnemyPrefab; public Enemy EnemyPrefab; // 스폰할 Enemy 프리팹
public int Count = 1; public int Count = 1; // 마리 수
} }

View File

@@ -3,41 +3,63 @@
using System.Threading; using System.Threading;
using UnityEngine; using UnityEngine;
// ============================================================================
// WaveManager
// ----------------------------------------------------------------------------
// 시간 제한 웨이브 시스템. 각 웨이브를 순서대로 진행하며, 시간 내 클리어 못 하면 패배.
//
// 흐름:
// StartWaves() → for each wave:
// - StartDelay 대기
// - OnWaveStart 발화
// - 적 스폰 시작 (백그라운드 비동기)
// - 타이머 시작
// - 매 프레임: 적 다 잡았는지 / 타이머 만료됐는지 체크
// - 클리어 → OnWaveCleared → IntermissionDuration 대기 → 다음 웨이브
// - 타임아웃 → OnDefeat → 살아있는 적 정리 → 종료
// 모든 웨이브 클리어 → OnAllWavesCleared
//
// UI 연동: 외부 시스템이 4개 이벤트 구독하여 UI/사운드 트리거.
// ============================================================================
public class WaveManager : MonoBehaviour public class WaveManager : MonoBehaviour
{ {
// ─── 웨이브 데이터 ───────────────────────────────────────────────────
[Header("Waves")] [Header("Waves")]
[SerializeField] private List<WaveData> _waves = new(); [SerializeField] private List<WaveData> _waves = new(); // 순서대로 진행할 웨이브들
[SerializeField] private float _intermissionDuration = 2f; [SerializeField] private float _intermissionDuration = 2f; // 웨이브 사이 대기 시간 ("Wave Clear!" 표시 시간)
[SerializeField] private bool _startOnAwake = true; [SerializeField] private bool _startOnAwake = true; // Start에서 자동 시작
// ─── 스폰 위치 설정 (모든 웨이브 공통) ───────────────────────────────
[Header("Spawn Position")] [Header("Spawn Position")]
[SerializeField] private Transform[] _spawnPoints; [SerializeField] private Transform[] _spawnPoints; // 비워두면 자기 위치
[SerializeField] private float _spawnRadius = 0f; [SerializeField] private float _spawnRadius = 0f; // 각 spawn point 주변 무작위 분산
[SerializeField] private Transform _enemyParent; [SerializeField] private Transform _enemyParent; // 스폰된 적을 묶을 부모 (Hierarchy 정리)
[Header("On Defeat")] [Header("On Defeat")]
[SerializeField] private bool _destroyAliveEnemiesOnDefeat = true; [SerializeField] private bool _destroyAliveEnemiesOnDefeat = true; // 패배 시 남은 적 정리
public event Action<int, WaveData> OnWaveStart; // ─── 외부에서 구독할 이벤트들 (UI/사운드 연동용) ─────────────────────
public event Action<int> OnWaveCleared; public event Action<int, WaveData> OnWaveStart; // 웨이브 시작: (index, data)
public event Action OnAllWavesCleared; public event Action<int> OnWaveCleared; // 웨이브 클리어: (index)
public event Action<int> OnDefeat; public event Action OnAllWavesCleared; // 모든 웨이브 끝 (승리)
public event Action<int> OnDefeat; // 패배: 어느 웨이브에서 실패했는지
// ─── 외부 읽기 전용 상태 (UI 연동용) ────────────────────────────────
public int CurrentWaveIndex { get; private set; } public int CurrentWaveIndex { get; private set; }
public int TotalWaveCount => _waves != null ? _waves.Count : 0; public int TotalWaveCount => _waves != null ? _waves.Count : 0;
public WaveData CurrentWave => public WaveData CurrentWave =>
_waves != null && CurrentWaveIndex >= 0 && CurrentWaveIndex < _waves.Count _waves != null && CurrentWaveIndex >= 0 && CurrentWaveIndex < _waves.Count
? _waves[CurrentWaveIndex] ? _waves[CurrentWaveIndex]
: null; : null;
public float TimeRemaining { get; private set; } public float TimeRemaining { get; private set; } // 현재 웨이브 남은 시간
public int AliveCount => _aliveEnemies.Count; public int AliveCount => _aliveEnemies.Count; // 살아있는 적 수
public int RemainingToSpawn { get; private set; } public int RemainingToSpawn { get; private set; } // 아직 스폰 안 한 적 수
public bool IsRunning { get; private set; } public bool IsRunning { get; private set; } // 웨이브 진행 중
public bool IsDefeated { get; private set; } public bool IsDefeated { get; private set; } // 패배 상태
public bool IsVictory { get; private set; } public bool IsVictory { get; private set; } // 모든 웨이브 클리어
private readonly List<Enemy> _aliveEnemies = new(); private readonly List<Enemy> _aliveEnemies = new();
private CancellationTokenSource _waveCts; private CancellationTokenSource _waveCts; // 전체 웨이브 진행 취소 토큰 (OnDestroy/StopWaves에서 취소)
private void Start() private void Start()
{ {
@@ -50,18 +72,22 @@ private void OnDestroy()
_waveCts?.Dispose(); _waveCts?.Dispose();
} }
// 외부에서 호출해 웨이브 시작 (예: UI 버튼, 트리거).
public void StartWaves() public void StartWaves()
{ {
if (_waves == null || _waves.Count == 0) return; if (_waves == null || _waves.Count == 0) return;
RunAllWaves(); RunAllWaves();
} }
// 진행 중인 웨이브 강제 중단. 게임 오버/메뉴 진입 등에 사용.
public void StopWaves() public void StopWaves()
{ {
_waveCts?.Cancel(); _waveCts?.Cancel();
IsRunning = false; IsRunning = false;
} }
// 모든 웨이브를 순차 실행하는 메인 비동기 루프.
// try/catch로 토큰 취소 시 예외 흡수 → OnDestroy 시 깔끔하게 종료.
private async void RunAllWaves() private async void RunAllWaves()
{ {
_waveCts?.Cancel(); _waveCts?.Cancel();
@@ -106,12 +132,20 @@ private async void RunAllWaves()
catch (OperationCanceledException) { } catch (OperationCanceledException) { }
} }
// 한 웨이브를 처리.
// 반환값: true = 클리어, false = 타임아웃(패배)
// 본 루프는 매 프레임:
// 1) 죽은 적 자동 정리
// 2) 모두 스폰 완료 + 살아있는 적 0 → 클리어
// 3) 타이머 0 도달 → 패배
// 4) 타이머 감소
private async Awaitable<bool> RunWave(WaveData wave, CancellationToken token) private async Awaitable<bool> RunWave(WaveData wave, CancellationToken token)
{ {
IsRunning = true; IsRunning = true;
TimeRemaining = wave.TimeLimit; TimeRemaining = wave.TimeLimit;
_aliveEnemies.Clear(); _aliveEnemies.Clear();
// 이번 웨이브에서 스폰할 총 적 수 계산.
int totalToSpawn = 0; int totalToSpawn = 0;
foreach (var entry in wave.Spawns) foreach (var entry in wave.Spawns)
if (entry != null) totalToSpawn += Mathf.Max(entry.Count, 0); if (entry != null) totalToSpawn += Mathf.Max(entry.Count, 0);
@@ -125,6 +159,8 @@ private async Awaitable<bool> RunWave(WaveData wave, CancellationToken token)
token.ThrowIfCancellationRequested(); token.ThrowIfCancellationRequested();
_aliveEnemies.RemoveAll(e => e == null); _aliveEnemies.RemoveAll(e => e == null);
// 클리어 조건: 모든 적이 스폰됐고 + 살아있는 적이 0.
// (스폰 중에 이미 죽었다고 클리어 인정하면 안 되니까 RemainingToSpawn=0도 같이 체크)
if (RemainingToSpawn == 0 && _aliveEnemies.Count == 0) if (RemainingToSpawn == 0 && _aliveEnemies.Count == 0)
{ {
IsRunning = false; IsRunning = false;
@@ -135,13 +171,15 @@ private async Awaitable<bool> RunWave(WaveData wave, CancellationToken token)
if (TimeRemaining <= 0f) if (TimeRemaining <= 0f)
{ {
IsRunning = false; IsRunning = false;
return false; return false; // 패배
} }
await Awaitable.NextFrameAsync(token); await Awaitable.NextFrameAsync(token);
} }
} }
// 적을 SpawnInterval 간격으로 스폰. 비동기로 흘려보내고 본 루프와 병렬 실행.
// (스폰 중에도 본 루프의 타이머 카운트다운 + 클리어 체크가 계속 돌아감)
private async void SpawnWaveEnemiesAsync(WaveData wave, CancellationToken token) private async void SpawnWaveEnemiesAsync(WaveData wave, CancellationToken token)
{ {
try try
@@ -208,6 +246,7 @@ private void DestroyAliveEnemies()
_aliveEnemies.Clear(); _aliveEnemies.Clear();
} }
// 디버그용 OnGUI 표시. 실제 게임 UI는 별도로 구성하고 이건 빠르게 확인용.
private void OnGUI() private void OnGUI()
{ {
GUIStyle style = new GUIStyle(GUI.skin.box) GUIStyle style = new GUIStyle(GUI.skin.box)

View File

@@ -2,14 +2,31 @@
using UnityEngine; using UnityEngine;
using UnityEngine.InputSystem; 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 public class InputManager : MonoBehaviour, GameInput.IPlayerActions
{ {
// 외부에서 InputManager.Instance.OnXxx_Event += handler 형태로 구독.
public static InputManager Instance { get; private set; } public static InputManager Instance { get; private set; }
private GameInput _input; private GameInput _input;
public event Action<Vector2> OnMove_Event; // ─── 입력 이벤트들 (PlayerController 등이 구독) ──────────────────────
public event Action OnJump_Event; public event Action<Vector2> OnMove_Event; // 매 프레임 (방향키 값)
public event Action OnJump_Event; // 한 번씩 (눌렀을 때)
public event Action OnPunch_Event; public event Action OnPunch_Event;
public event Action OnKick_Event; public event Action OnKick_Event;
public event Action OnDash_Event; public event Action OnDash_Event;
@@ -17,6 +34,7 @@ public class InputManager : MonoBehaviour, GameInput.IPlayerActions
public event Action OnBackDash_Event; public event Action OnBackDash_Event;
public event Action OnGrabSmash_Event; public event Action OnGrabSmash_Event;
// 싱글톤 초기화 + GameInput 생성 + 콜백 등록.
private void Awake() private void Awake()
{ {
if (Instance != null && Instance != this) if (Instance != null && Instance != this)
@@ -27,20 +45,24 @@ private void Awake()
Instance = this; Instance = this;
_input = new GameInput(); _input = new GameInput();
// IPlayerActions의 OnMove/OnJump/... 메서드를 인풋 액션의 콜백으로 자동 연결.
_input.Player.SetCallbacks(this); _input.Player.SetCallbacks(this);
} }
// GameInput은 활성/비활성 토글이 필요한 자원. ?. 처리로 Awake보다 OnEnable이 먼저 호출되는 경우 보호.
private void OnEnable() => _input?.Player.Enable(); private void OnEnable() => _input?.Player.Enable();
private void OnDisable() => _input?.Player.Disable(); private void OnDisable() => _input?.Player.Disable();
private void OnDestroy() => _input?.Dispose(); private void OnDestroy() => _input?.Dispose();
// ─── 콜백 구현 (IPlayerActions 인터페이스) ───────────────────────────
// Move는 값을 그대로 전달 (정규화/디지털화는 PlayerController가 결정).
public void OnMove(InputAction.CallbackContext ctx) public void OnMove(InputAction.CallbackContext ctx)
{ {
OnMove_Event?.Invoke(ctx.ReadValue<Vector2>()); OnMove_Event?.Invoke(ctx.ReadValue<Vector2>());
} }
// 버튼 타입 입력들은 Started phase에서만 발화 → "눌렀을 때 1회".
// 계속 누르고 있어도 추가 발화 안 됨. canceled (뗌)는 무시.
public void OnJump(InputAction.CallbackContext ctx) public void OnJump(InputAction.CallbackContext ctx)
{ {
if (ctx.phase == InputActionPhase.Started) if (ctx.phase == InputActionPhase.Started)
@@ -51,7 +73,6 @@ public void OnPunch(InputAction.CallbackContext ctx)
{ {
if (ctx.phase == InputActionPhase.Started) if (ctx.phase == InputActionPhase.Started)
OnPunch_Event?.Invoke(); OnPunch_Event?.Invoke();
} }
public void OnKick(InputAction.CallbackContext ctx) public void OnKick(InputAction.CallbackContext ctx)

View File

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

View File

@@ -1,13 +1,29 @@
using UnityEngine; 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 public class HpBar : MonoBehaviour
{ {
[SerializeField] private Health _health; [SerializeField] private Health _health; // 비워두면 _autoFindHealthInParent로 자동 검색
[SerializeField] private Transform _fill; [SerializeField] private Transform _fill; // 채움 sprite transform (스케일 조정 대상)
[SerializeField] private bool _autoFindHealthInParent = true; [SerializeField] private bool _autoFindHealthInParent = true; // _health가 null이면 부모에서 Health 자동 검색
[SerializeField] private bool _hideWhenFull = true; [SerializeField] private bool _hideWhenFull = true; // HP 풀이거나 0일 때 HpBar 자동 숨김
[SerializeField] private float _smoothSpeed = 0f; [SerializeField] private float _smoothSpeed = 0f; // 0이면 즉시 반영, 0보다 크면 보간 (units/sec)
// ─── 임계값별 색상 ──────────────────────────────────────────────────
// ratio > _midThreshold → _highColor (정상)
// _lowThreshold < ratio <= _midThreshold → _midColor (주의)
// ratio <= _lowThreshold → _lowColor (위험)
[Header("Color Thresholds")] [Header("Color Thresholds")]
[SerializeField] private Color _highColor = new Color(0.2f, 0.9f, 0.3f, 1f); [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); [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 _midThreshold = 0.5f;
[SerializeField, Range(0f, 1f)] private float _lowThreshold = 0.2f; [SerializeField, Range(0f, 1f)] private float _lowThreshold = 0.2f;
private Vector3 _baseFillScale; private Vector3 _baseFillScale; // 풀체력 시 Fill 스케일 (Awake에 캐싱, 이후 ratio 곱해서 사용)
private SpriteRenderer _fillRenderer; private SpriteRenderer _fillRenderer; // Fill의 색상 변경용 SpriteRenderer
private float _currentRatio = 1f; private float _currentRatio = 1f; // 현재 표시된 HP 비율 (보간 진행 시 _targetRatio로 수렴)
private float _targetRatio = 1f; private float _targetRatio = 1f; // 도달해야 할 HP 비율
private void Awake() private void Awake()
{ {
@@ -32,6 +48,8 @@ private void Awake()
} }
} }
// 활성/비활성 토글 시 자동 구독·해제 (메모리 누수 방지).
// 활성 시 즉시 한 번 갱신해서 현재 HP 상태 반영.
private void OnEnable() private void OnEnable()
{ {
if (_health == null) return; if (_health == null) return;
@@ -46,6 +64,7 @@ private void OnDisable()
_health.OnHealthChanged -= HandleHealthChanged; _health.OnHealthChanged -= HandleHealthChanged;
} }
// 보간 모드일 때만 매 프레임 스케일 갱신.
private void Update() private void Update()
{ {
if (_smoothSpeed <= 0f) return; if (_smoothSpeed <= 0f) return;
@@ -55,10 +74,12 @@ private void Update()
ApplyScale(); ApplyScale();
} }
// Health.OnHealthChanged 이벤트 콜백. ratio 계산 → 스케일/색상 갱신 → 숨김 처리.
private void HandleHealthChanged(int current, int max) private void HandleHealthChanged(int current, int max)
{ {
_targetRatio = max > 0 ? (float)current / max : 0f; _targetRatio = max > 0 ? (float)current / max : 0f;
// 즉시 모드면 바로 스케일 반영. 보간 모드면 Update에서 점진적으로.
if (_smoothSpeed <= 0f) if (_smoothSpeed <= 0f)
{ {
_currentRatio = _targetRatio; _currentRatio = _targetRatio;
@@ -67,6 +88,7 @@ private void HandleHealthChanged(int current, int max)
ApplyColor(_targetRatio); ApplyColor(_targetRatio);
// 풀체력(1)이거나 사망(0)이면 HpBar 자체를 숨김 (UI 정리 + GameObject 부하 감소).
if (_hideWhenFull && _fill != null) if (_hideWhenFull && _fill != null)
{ {
bool shouldShow = _targetRatio < 1f && _targetRatio > 0f; 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() private void ApplyScale()
{ {
if (_fill == null) return; if (_fill == null) return;
@@ -84,6 +107,7 @@ private void ApplyScale()
_fill.localScale = scale; _fill.localScale = scale;
} }
// 비율 구간 매핑으로 색상 결정. 임계값 교차 시 즉시 바뀜 (보간 안 함).
private void ApplyColor(float ratio) private void ApplyColor(float ratio)
{ {
if (_fillRenderer == null) return; if (_fillRenderer == null) return;