2026-05-21 보스추가

This commit is contained in:
2026-05-21 12:12:17 +09:00
parent 7d87b6e007
commit 0f35455ad7
22 changed files with 821 additions and 81 deletions

View File

@@ -0,0 +1,75 @@
using System;
using UnityEngine;
// ============================================================================
// Boss
// ----------------------------------------------------------------------------
// 보스의 페이즈를 관리하는 컴포넌트. 같은 GameObject의 Enemy(몸)·BossAI(두뇌)와
// 함께 동작한다.
// - Health 비율이 임계값 아래로 떨어지면 페이즈 전환
// - 전환 중엔 Enemy.SetInvulnerable(true)로 무적 + 전환 애니메이션 재생
// - CurrentPhase를 BossAI가 읽어 공격 패턴을 바꾼다 (BossAttack.MinPhase)
// ============================================================================
[RequireComponent(typeof(Enemy))]
[RequireComponent(typeof(Health))]
public class Boss : MonoBehaviour
{
[Header("Phase")]
// 페이즈 업이 일어나는 HP 비율 (내림차순). 예: {0.66, 0.33} → 페이즈 0→1→2.
[SerializeField] private float[] _phaseThresholds = { 0.66f, 0.33f };
[SerializeField] private string _phaseTransitionAnimation = "BossPhaseChange";
[SerializeField] private float _phaseTransitionDuration = 1.2f; // 전환(무적) 지속 시간
private Enemy _enemy;
private Health _health;
private Animator _anim;
private int _currentPhase; // 0부터 시작
private bool _isTransitioning; // 페이즈 전환 진행 중
public int CurrentPhase => _currentPhase;
public bool IsTransitioning => _isTransitioning;
public event Action<int> OnPhaseChanged; // 전환 완료 시 발화 (인자 = 새 페이즈)
private void Awake()
{
_enemy = GetComponent<Enemy>();
_health = GetComponent<Health>();
_anim = GetComponentInChildren<Animator>();
}
private void Update()
{
if (_isTransitioning || _health.IsDead) return;
if (_currentPhase >= _phaseThresholds.Length) return; // 이미 마지막 페이즈
// 현재 페이즈의 임계값 아래로 HP가 떨어지면 다음 페이즈로 전환.
if (_health.Ratio <= _phaseThresholds[_currentPhase])
RunPhaseTransition();
}
// 페이즈 전환: 무적 ON → 전환 애니메이션 → 일정 시간 후 무적 OFF + 페이즈 증가.
// 코루틴 대신 Awaitable 사용. destroyCancellationToken으로 파괴 시 자동 취소.
private async void RunPhaseTransition()
{
_isTransitioning = true;
_enemy.SetInvulnerable(true);
if (_anim != null && !string.IsNullOrEmpty(_phaseTransitionAnimation))
_anim.Play(_phaseTransitionAnimation);
try
{
await Awaitable.WaitForSecondsAsync(_phaseTransitionDuration, destroyCancellationToken);
}
catch (OperationCanceledException)
{
return; // 보스가 파괴됨 → 그대로 종료
}
_currentPhase++;
_enemy.SetInvulnerable(false);
_isTransitioning = false;
OnPhaseChanged?.Invoke(_currentPhase);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 574f5c8f5a8ab4e438e2f8f8f1997bbf

View File

@@ -0,0 +1,333 @@
using System;
using UnityEngine;
// ============================================================================
// BossAttack
// ----------------------------------------------------------------------------
// 보스 공격 하나의 설정. 별도 .asset 없이 BossAI 인스펙터에서 배열로 편집.
// ============================================================================
[Serializable]
public class BossAttack
{
public string Name = "Attack"; // 디버그/식별용 이름
public int MinPhase; // 이 페이즈(0부터) 이상에서만 사용 가능
public float Range = 1.5f; // 타겟이 이 X거리 안일 때만 사용
public float Windup = 0.4f; // 공격 시작 ~ 데미지 발생 (선딜)
public float Duration = 0.9f; // 공격 동작 전체 길이 (이 동안 이동 정지)
public float Cooldown = 2f; // 이 공격 자체의 재사용 대기
public int Damage = 15;
public Vector2 HitVelocity = new Vector2(5f, 2f); // 피격자 넉백 (X는 보스 facing 방향으로 부호 적용)
public string AnimationState = "BossAttack";
}
// ============================================================================
// BossAI
// ----------------------------------------------------------------------------
// 보스의 두뇌. EnemyAI와 골격(감지→추격→공격)은 같지만:
// - 여러 공격(BossAttack 배열)을 보유하고, 사거리·쿨다운·페이즈 조건을
// 만족하는 것 중 무작위로 골라 사용
// - 각 공격의 MinPhase로 페이즈 잠금 → Boss.CurrentPhase가 오르면 신규 공격 해금
// 같은 GameObject의 Enemy(몸)·Boss(페이즈)와 함께 동작.
// ============================================================================
[RequireComponent(typeof(Enemy))]
[RequireComponent(typeof(Rigidbody2D))]
public class BossAI : MonoBehaviour
{
private enum AIState { Idle, Chase, Attack }
[Header("Target")]
[SerializeField] private Transform _target; // 비우면 자동으로 플레이어 검색
[SerializeField] private bool _autoFindPlayer = true;
[SerializeField] private float _detectRange = 12f; // 이 X거리 안에 들어오면 인지
[SerializeField] private float _detectVerticalRange = 4f;
[Header("Movement")]
[SerializeField] private float _moveSpeed = 2.5f;
[SerializeField] private float _stopDistance = 1f; // 타겟과 이 거리 안이면 이동 정지
[Header("Attacks")]
[SerializeField] private BossAttack[] _attacks;
[SerializeField] private float _attackVerticalTolerance = 1.5f; // 공격 적중 인정 세로 오차
[SerializeField] private float _globalAttackCooldown = 0.6f; // 공격 종료 후 다음 공격까지 최소 간격
[Header("Animation")]
[SerializeField] private string _idleAnimationState = "Idle";
[SerializeField] private string _runAnimationState = "Run";
private Enemy _enemy;
private Boss _boss;
private Health _health;
private Rigidbody2D _rb;
private Animator _anim;
private SpriteRenderer _spriteRenderer;
private IDamageable _targetDamageable;
private AIState _state = AIState.Idle;
private bool _hasAggro;
private float[] _attackCooldownTimers; // _attacks와 1:1 — 각 공격의 남은 쿨다운
private int[] _attackPickBuffer; // PickAttackIndex 후보 수집용 (GC 회피)
private float _globalCooldownTimer; // 공격 사이 전역 대기
private BossAttack _currentAttack; // 진행 중인 공격
private bool _isAttacking;
private bool _attackDamageApplied; // 이번 공격에서 데미지를 이미 줬는지
private float _attackTimer;
private float _facingDirection = 1f;
private string _activeAnimationState;
private void Awake()
{
_enemy = GetComponent<Enemy>();
_boss = GetComponent<Boss>();
_health = GetComponent<Health>();
_rb = GetComponent<Rigidbody2D>();
_anim = GetComponentInChildren<Animator>();
_spriteRenderer = GetComponentInChildren<SpriteRenderer>();
int count = _attacks != null ? _attacks.Length : 0;
_attackCooldownTimers = new float[count];
_attackPickBuffer = new int[count];
}
private void Update()
{
TickCooldowns();
ResolveTarget();
// 사망 / 행동 불가(피격 경직·잡힘) / 페이즈 전환 중 / 타겟 없음 → 정지.
if (_health.IsDead || !_enemy.CanUseAI
|| (_boss != null && _boss.IsTransitioning) || _target == null)
{
if (_isAttacking) CancelAttack();
StopMoving();
SetState(AIState.Idle);
return;
}
RefreshAggro();
// 공격 중이면 facing을 고정한 채 공격 진행만 처리.
if (_isAttacking)
{
TickAttack();
return;
}
UpdateFacing();
if (!_hasAggro)
{
StopMoving();
SetState(AIState.Idle);
return;
}
// 사용 가능한 공격이 있으면 공격 시작, 없으면 추격.
int attackIndex = _globalCooldownTimer <= 0f ? PickAttackIndex() : -1;
if (attackIndex >= 0)
BeginAttack(attackIndex);
else
SetState(AIState.Chase);
}
private void FixedUpdate()
{
if (_rb == null) return;
// 추격 상태에서만 X축 이동. 그 외 상태에선 손대지 않음
// (정지는 Update에서 상태 전환 시 StopMoving으로 처리).
if (_state != AIState.Chase || _target == null) return;
float dx = _target.position.x - transform.position.x;
Vector2 v = _rb.linearVelocity;
v.x = Mathf.Abs(dx) <= _stopDistance ? 0f : Mathf.Sign(dx) * _moveSpeed;
_rb.linearVelocity = v;
}
// ─── 공격 ────────────────────────────────────────────────────────────
// 현재 사용 가능한 공격 후보 중 무작위 1개의 인덱스 반환. 없으면 -1.
// 조건: 페이즈 해금 + 쿨다운 종료 + 사거리 안.
private int PickAttackIndex()
{
if (_attacks == null || _attacks.Length == 0 || _target == null) return -1;
Vector2 delta = _target.position - transform.position;
if (Mathf.Abs(delta.y) > _attackVerticalTolerance) return -1;
float distanceX = Mathf.Abs(delta.x);
int phase = _boss != null ? _boss.CurrentPhase : 0;
int candidateCount = 0;
for (int i = 0; i < _attacks.Length; i++)
{
BossAttack a = _attacks[i];
if (a == null) continue;
if (phase < a.MinPhase) continue; // 페이즈 미해금
if (_attackCooldownTimers[i] > 0f) continue; // 쿨다운 중
if (distanceX > a.Range) continue; // 사거리 밖
_attackPickBuffer[candidateCount++] = i;
}
if (candidateCount == 0) return -1;
return _attackPickBuffer[UnityEngine.Random.Range(0, candidateCount)];
}
private void BeginAttack(int index)
{
_currentAttack = _attacks[index];
_isAttacking = true;
_attackDamageApplied = false;
_attackTimer = 0f;
_attackCooldownTimers[index] = _currentAttack.Cooldown;
// 공격 동작 길이 + 후딜만큼 다음 공격을 막는다.
_globalCooldownTimer = _currentAttack.Duration + _globalAttackCooldown;
_state = AIState.Attack;
StopMoving();
UpdateFacing(); // 공격 시작 순간 타겟을 바라보고, 이후 공격 끝까지 고정
PlayAnim(_currentAttack.AnimationState);
}
private void TickAttack()
{
_attackTimer += Time.deltaTime;
StopMoving();
// 선딜이 끝나는 순간 데미지 1회 적용.
if (!_attackDamageApplied && _attackTimer >= _currentAttack.Windup)
{
_attackDamageApplied = true;
ApplyAttackDamage();
}
// 공격 동작이 끝나면 종료 — 상태는 다음 Update에서 재판단.
if (_attackTimer >= _currentAttack.Duration)
{
_isAttacking = false;
_currentAttack = null;
}
}
private void CancelAttack()
{
_isAttacking = false;
_currentAttack = null;
}
// 선딜 종료 시점에 호출. 그 사이 플레이어가 사거리를 벗어났으면 빗나감.
private void ApplyAttackDamage()
{
if (_target == null || _currentAttack == null) return;
Vector2 delta = _target.position - transform.position;
if (Mathf.Abs(delta.x) > _currentAttack.Range) return;
if (Mathf.Abs(delta.y) > _attackVerticalTolerance) return;
if (_targetDamageable == null) CacheTargetComponents();
if (_targetDamageable == null) return;
Vector2 knockback = new Vector2(
_currentAttack.HitVelocity.x * _facingDirection,
_currentAttack.HitVelocity.y);
_targetDamageable.TakeDamage(_currentAttack.Damage, knockback);
}
private void TickCooldowns()
{
if (_globalCooldownTimer > 0f)
_globalCooldownTimer -= Time.deltaTime;
for (int i = 0; i < _attackCooldownTimers.Length; i++)
{
if (_attackCooldownTimers[i] > 0f)
_attackCooldownTimers[i] -= Time.deltaTime;
}
}
// ─── 타겟 / 인지 / 방향 ──────────────────────────────────────────────
private void ResolveTarget()
{
if (_target != null)
{
if (_targetDamageable == null) CacheTargetComponents();
return;
}
if (!_autoFindPlayer) return;
PlayerController player = FindFirstObjectByType<PlayerController>();
if (player == null) return;
_target = player.transform;
CacheTargetComponents();
}
private void CacheTargetComponents()
{
_targetDamageable = _target != null ? _target.GetComponent<IDamageable>() : null;
}
// 보스는 한 번 플레이어를 인지하면 계속 추격한다 (인지 해제 없음).
private void RefreshAggro()
{
if (_hasAggro || _target == null) return;
Vector2 delta = _target.position - transform.position;
if (Mathf.Abs(delta.x) <= _detectRange && Mathf.Abs(delta.y) <= _detectVerticalRange)
_hasAggro = true;
}
private void UpdateFacing()
{
if (_target == null) return;
float dx = _target.position.x - transform.position.x;
if (Mathf.Abs(dx) <= 0.05f) return;
_facingDirection = Mathf.Sign(dx);
if (_spriteRenderer != null)
_spriteRenderer.flipX = _facingDirection < 0f;
}
private void StopMoving()
{
if (_rb == null) return;
Vector2 v = _rb.linearVelocity;
v.x = 0f;
_rb.linearVelocity = v;
}
// ─── 상태 / 애니메이션 ───────────────────────────────────────────────
private void SetState(AIState state)
{
_state = state;
if (state == AIState.Idle) PlayAnim(_idleAnimationState);
else if (state == AIState.Chase) PlayAnim(_runAnimationState);
// Attack 상태의 애니메이션은 BeginAttack에서 직접 재생.
}
// 같은 State 중복 재생 방지 (Play를 매 프레임 호출하면 애니가 처음으로 리셋됨).
private void PlayAnim(string state)
{
if (_anim == null || string.IsNullOrEmpty(state)) return;
if (_activeAnimationState == state) return;
_anim.Play(state);
_activeAnimationState = state;
}
private void OnDrawGizmosSelected()
{
Gizmos.color = Color.yellow;
Gizmos.DrawWireSphere(transform.position, _detectRange);
if (_attacks == null) return;
Gizmos.color = Color.red;
for (int i = 0; i < _attacks.Length; i++)
if (_attacks[i] != null)
Gizmos.DrawWireSphere(transform.position, _attacks[i].Range);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 15a49fe6ad5fb2646a39dc8a830433e3

View File

@@ -50,6 +50,10 @@ public class Enemy : MonoBehaviour, IDamageable
[SerializeField] private WeaponData _dropWeapon; // null이면 드랍 안 함
[SerializeField] private WeaponPickup _weaponPickupPrefab; // 픽업 오브젝트 프리팹 (한 종류 공유 가능)
// ─── 잡기 설정 ───────────────────────────────────────────────────────
[Header("Grab")]
[SerializeField] private bool _isGrabbable = true; // false면 플레이어가 잡을 수 없음 (보스 등)
private Health _health; // HP 컴포넌트 (별도 분리 → 플레이어도 같은 컴포넌트 재사용 가능)
private Rigidbody2D _rb;
private Animator _anim;
@@ -79,8 +83,14 @@ public class Enemy : MonoBehaviour, IDamageable
private Vector2 _grabTargetPosition;// 잡힌 동안 강제로 위치할 좌표
private int _grabSolidMask; // 잡혀서 이동할 때 벽에 끼이지 않게 검사할 솔리드 레이어
// ─── 무적 ────────────────────────────────────────────────────────────
// true면 모든 피격을 무시한다 (보스 페이즈 전환 i-frame 등). SetInvulnerable로 토글.
private bool _isInvulnerable;
public bool IsDead => _health != null && _health.IsDead;
public bool IsGrabbed => _isGrabbed;
public bool IsGrabbable => _isGrabbable;
public bool IsInvulnerable => _isInvulnerable;
public bool IsInHitReaction => _hitStunTimer > 0f || _hitReactionTimer > 0f || _isHitPositionCorrecting || _flashTimer > 0f;
public bool CanUseAI => !IsDead && !_isGrabbed && !IsInHitReaction;
@@ -274,6 +284,8 @@ private void ApplySeparation()
public void TakeDamage(int amount, Vector2 hitVelocity = default, string hitReactionAnimationState = null, Vector2? hitTargetPosition = null, bool correctHitTargetY = false, int hitPositionSolidMask = 0, float hitPositionCorrectionDuration = 0f, float hitStunDuration = -1f)
{
if (_health == null || _health.IsDead) return;
// 무적이면 데미지/플래시/넉백 전부 무시.
if (_isInvulnerable) return;
float appliedHitStunDuration = hitStunDuration >= 0f ? hitStunDuration : _hitStunDuration;
_hitStunTimer = Mathf.Max(_hitStunTimer, appliedHitStunDuration);
@@ -306,10 +318,17 @@ public void TakeDamage(int amount, Vector2 hitVelocity = default, string hitReac
Debug.Log($"{name} 피격: -{amount} (HP: {_health.CurrentHealth}/{_health.MaxHealth})");
}
// 무적 토글. 보스 페이즈 전환 중 무적 부여 등에 사용 (Boss 컴포넌트가 호출).
public void SetInvulnerable(bool value)
{
_isInvulnerable = value;
}
// 플레이어가 잡기 액션 시작 시 호출. 이 적이 잡힘 상태로 진입.
// 이후 매 FixedUpdate에서 _grabTargetPosition으로 강제 이동됨.
public void BeginGrab(string grabbedAnimationState, int solidMask)
{
if (!_isGrabbable) return;
if (_health == null || _health.IsDead) return;
_isGrabbed = true;