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(); _boss = GetComponent(); _health = GetComponent(); _rb = GetComponent(); _anim = GetComponentInChildren(); _spriteRenderer = GetComponentInChildren(); 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(); if (player == null) return; _target = player.transform; CacheTargetComponents(); } private void CacheTargetComponents() { _targetDamageable = _target != null ? _target.GetComponent() : 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); } }