주석추가
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user