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