diff --git a/Assets/01_Scenes/BossScene.unity b/Assets/01_Scenes/BossScene.unity new file mode 100644 index 0000000..f770721 --- /dev/null +++ b/Assets/01_Scenes/BossScene.unity @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d62d949614848e721322d14e510710b31069c38ef14364f5772e9ce96ee59d0c +size 82715 diff --git a/Assets/01_Scenes/BossScene.unity.meta b/Assets/01_Scenes/BossScene.unity.meta new file mode 100644 index 0000000..188db8a --- /dev/null +++ b/Assets/01_Scenes/BossScene.unity.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: d3ad68e4d8a6f544eb79d5c180d28868 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/01_Scenes/GameScene.unity b/Assets/01_Scenes/GameScene.unity index d272be2..0fc2f33 100644 --- a/Assets/01_Scenes/GameScene.unity +++ b/Assets/01_Scenes/GameScene.unity @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f6a23f5602ee4e36e8c057eb3b6b49130fc925d9cd1de3ba4de9fcb430130245 -size 79331 +oid sha256:898471b33b5be2c360bf40e3402f5f2577733043ede8fa9c8b2a77f0c3fbd66f +size 83339 diff --git a/Assets/02_Scripts/Combat/Health.cs b/Assets/02_Scripts/Combat/Health.cs index 786ee5d..f4a6fd6 100644 --- a/Assets/02_Scripts/Combat/Health.cs +++ b/Assets/02_Scripts/Combat/Health.cs @@ -1,19 +1,6 @@ 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; @@ -26,8 +13,8 @@ public class Health : MonoBehaviour public bool IsDead => _currentHealth <= 0; // ─── 외부 구독용 이벤트 ────────────────────────────────────────────── - // OnHealthChanged: (current, max). HP바, 데미지 숫자 표시 등에 사용. - // OnDied: 사망 순간 1회만 발화. Enemy.HandleDeath에서 구독. + // OnHealthChanged: (current, max). 데미지 숫자 표시 등에 사용. + // OnDied: 사망 순간 1회만 발화. public event Action OnHealthChanged; public event Action OnDied; @@ -50,7 +37,7 @@ public void TakeDamage(int amount) OnDied?.Invoke(); } - // 회복. 죽은 상태에서는 회복 안 됨 (부활 로직은 별도로 만들어야 함). + // 회복 public void Heal(int amount) { if (amount <= 0 || IsDead) return; diff --git a/Assets/02_Scripts/Enemy/Boss.cs b/Assets/02_Scripts/Enemy/Boss.cs new file mode 100644 index 0000000..3c87929 --- /dev/null +++ b/Assets/02_Scripts/Enemy/Boss.cs @@ -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 OnPhaseChanged; // 전환 완료 시 발화 (인자 = 새 페이즈) + + private void Awake() + { + _enemy = GetComponent(); + _health = GetComponent(); + _anim = GetComponentInChildren(); + } + + 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); + } +} diff --git a/Assets/02_Scripts/Enemy/Boss.cs.meta b/Assets/02_Scripts/Enemy/Boss.cs.meta new file mode 100644 index 0000000..1b334a3 --- /dev/null +++ b/Assets/02_Scripts/Enemy/Boss.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 574f5c8f5a8ab4e438e2f8f8f1997bbf \ No newline at end of file diff --git a/Assets/02_Scripts/Enemy/BossAI.cs b/Assets/02_Scripts/Enemy/BossAI.cs new file mode 100644 index 0000000..3ca5b67 --- /dev/null +++ b/Assets/02_Scripts/Enemy/BossAI.cs @@ -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(); + _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); + } +} diff --git a/Assets/02_Scripts/Enemy/BossAI.cs.meta b/Assets/02_Scripts/Enemy/BossAI.cs.meta new file mode 100644 index 0000000..ae90a2f --- /dev/null +++ b/Assets/02_Scripts/Enemy/BossAI.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 15a49fe6ad5fb2646a39dc8a830433e3 \ No newline at end of file diff --git a/Assets/02_Scripts/Enemy/Enemy.cs b/Assets/02_Scripts/Enemy/Enemy.cs index aee92e7..f4d1dd8 100644 --- a/Assets/02_Scripts/Enemy/Enemy.cs +++ b/Assets/02_Scripts/Enemy/Enemy.cs @@ -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; diff --git a/Assets/02_Scripts/ISceneInitializable.cs b/Assets/02_Scripts/ISceneInitializable.cs new file mode 100644 index 0000000..c8949dc --- /dev/null +++ b/Assets/02_Scripts/ISceneInitializable.cs @@ -0,0 +1,6 @@ +using UnityEngine; + +public interface ISceneInitializable +{ + public void OnSceneLoaded(); +} \ No newline at end of file diff --git a/Assets/02_Scripts/ISceneInitializable.cs.meta b/Assets/02_Scripts/ISceneInitializable.cs.meta new file mode 100644 index 0000000..fe491a4 --- /dev/null +++ b/Assets/02_Scripts/ISceneInitializable.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5f914cb8158f1b04db838af77516820b \ No newline at end of file diff --git a/Assets/02_Scripts/Managers/SceneLoadManager.cs b/Assets/02_Scripts/Managers/SceneLoadManager.cs new file mode 100644 index 0000000..ec9bc07 --- /dev/null +++ b/Assets/02_Scripts/Managers/SceneLoadManager.cs @@ -0,0 +1,116 @@ +using System; +using UnityEngine; +using UnityEngine.SceneManagement; +public class SceneLoadManager : MonoBehaviour +{ + public static SceneLoadManager Instance; + + private void Awake() + { + if (Instance == null) + { + Instance = this; //만들어진 자신을 인스턴스로 설정 + } + else + { + Destroy(gameObject); //이미 인스턴스가 있으면 자신을 파괴 + } + } + + private void Start() + { + SceneManager.sceneLoaded += OnSceneLoaded; + OnSceneLoaded(SceneManager.GetActiveScene(), LoadSceneMode.Single); + } + + private void Update() + { + + } + + //씬이 로드되었을때 호출 + private void OnSceneLoaded(Scene scene, LoadSceneMode mode) + { + if(scene.name == "GameScene") + { + MonoBehaviour[] allObjs = UnityEngine.Object.FindObjectsByType(FindObjectsSortMode.None); + + foreach (var obj in allObjs) + { + if (obj is ISceneInitializable initializable) + { + //씬에서 ISceneInitializable 인터페이스를 가진 오브젝트의 초기화 로직을 실행 + initializable.OnSceneLoaded(); + } + } + } + } + + public void SetSceneLoadingProgressValue(float value) + { + + } + public void SetSceneLoadingProgressValue(float value,string loadingText) + { + + } + + public void RequestSceneChange(string sceneName) + { + _ = SceneChange(sceneName); + } + + private async Awaitable SceneChange(string sceneName) + { + try + { + //로딩바 수치 0으로 설정 + SetSceneLoadingProgressValue(0f); + + AsyncOperation op = SceneManager.LoadSceneAsync(sceneName); + + //자동 전환을 하고 싶지 않을 경우 해당값을 false로 두었다가 true로 바꾸면 그 때 전환됨 + op.allowSceneActivation = false; + + //화면에 보여줄 로딩 수치 + float displayProgress = 0f; + + //op.progress 0.9가 데이터 로딩이 끝난 기준 allowSceneActivation이 트루면 다음으로 넘어가면서 op.isDone이 true가 된다. + while (op.progress < 0.9f) + { + //실제 로딩 수치 + float realProgress = Mathf.Clamp01(op.progress / 0.9f); + + //보여줄 값을 실제값을 향해 부드럽게 이동 + displayProgress = Mathf.MoveTowards(displayProgress, realProgress, Time.deltaTime * 0.5f); + + // 로딩바 UI에 값 적용 + SetSceneLoadingProgressValue(displayProgress); + + //자기자신이 파괴될때 토큰에 취소요청을 보냄 + await Awaitable.NextFrameAsync(this.destroyCancellationToken); + } + + //로딩바 수치 1(100%)로 설정 (데이터 로딩은 이미 끝이기 때문에) + SetSceneLoadingProgressValue(1); + + // 잠시 대기했다가 전환 + await Awaitable.WaitForSecondsAsync(1.0f, this.destroyCancellationToken); + + // 다음씬으로 넘어가도 됨을 알림 + op.allowSceneActivation = true; + + // 씬 활성화가 완전히 끝날 때까지 대기 + // allowSceneActivation가 true가 되고 완전히 전환되기까지는 몇프레임 걸림. op.isDone 은 이 과정이 끝난 뒤에 true가 됨. + while(!op.isDone) + { + await Awaitable.NextFrameAsync(this.destroyCancellationToken); + } + + } + catch (OperationCanceledException) + { + Debug.Log("씬 전환 작업이 취소됨"); + } + } +} \ No newline at end of file diff --git a/Assets/02_Scripts/Managers/SceneLoadManager.cs.meta b/Assets/02_Scripts/Managers/SceneLoadManager.cs.meta new file mode 100644 index 0000000..bdc689d --- /dev/null +++ b/Assets/02_Scripts/Managers/SceneLoadManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 97560af4542644140871240158003267 \ No newline at end of file diff --git a/Assets/02_Scripts/Player/PlayerController.cs b/Assets/02_Scripts/Player/PlayerController.cs index ff39cb8..da1b1f3 100644 --- a/Assets/02_Scripts/Player/PlayerController.cs +++ b/Assets/02_Scripts/Player/PlayerController.cs @@ -860,7 +860,7 @@ private Enemy FindGrabTarget(ActionData data) Vector2 playerPosition = _rb != null ? _rb.position : (Vector2)transform.position; float grabRangeSqr = data.GrabRange * data.GrabRange; - if (_lastHitEnemy != null && _lastHitEnemy.isActiveAndEnabled && + if (_lastHitEnemy != null && _lastHitEnemy.isActiveAndEnabled && _lastHitEnemy.IsGrabbable && GetEnemySqrDistance(_lastHitEnemy, playerPosition) <= grabRangeSqr) { return _lastHitEnemy; @@ -874,6 +874,7 @@ private Enemy FindGrabTarget(ActionData data) { Enemy enemy = hit.GetComponentInParent(); if (enemy == null) continue; + if (!enemy.IsGrabbable) continue; // 잡기 불가 적(보스 등)은 후보에서 제외 float sqrDistance = GetEnemySqrDistance(enemy, playerPosition); if (sqrDistance > grabRangeSqr) continue; diff --git a/Assets/02_Scripts/UI/HpBar.cs b/Assets/02_Scripts/UI/HpBar.cs index 623b338..da1fb09 100644 --- a/Assets/02_Scripts/UI/HpBar.cs +++ b/Assets/02_Scripts/UI/HpBar.cs @@ -4,26 +4,30 @@ // HpBar // ---------------------------------------------------------------------------- // SpriteRenderer 기반 HP바. Canvas보다 가벼움 (Canvas Rebuild 비용 없음). -// 부모(또는 Inspector 할당)의 Health 컴포넌트 이벤트 OnHealthChanged 구독. // -// 동작: -// - Background와 Fill 두 SpriteRenderer로 구성 -// - Fill의 스프라이트 피벗은 LEFT (0, 0.5) 여야 X 스케일 변경 시 왼쪽부터 줄어듦 -// - HP 비율에 따라 _fill.localScale.x 조정 -// - 임계값 기반으로 색상도 자동 변경 (높음/중간/낮음) +// 폴링 방식: +// 이벤트 구독 대신 매 프레임 Update에서 Health.Ratio를 직접 읽어 반영한다. +// - 구독/해제 생명주기가 없어 OnEnable 타이밍 버그가 원천적으로 없음 +// - Update는 항상 모든 Awake 뒤에 실행되므로 Health는 늘 초기화된 상태 +// - 비용: 매 프레임 float 비교 1회 + "비율이 바뀐 프레임"에만 시각 갱신 +// +// 구성: +// - Background와 Fill 두 SpriteRenderer +// - Fill 스프라이트 피벗은 LEFT (0, 0.5) 여야 X 스케일 변경 시 왼쪽부터 줄어듦 +// - HP 비율에 따라 _fill.localScale.x 조정 + 임계값별 색상 변경 // ============================================================================ public class HpBar : MonoBehaviour { [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) + [SerializeField] private bool _hideWhenFull = true; // HP 풀이거나 0일 때 HP바 숨김 (렌더러만 끔) + [SerializeField] private float _smoothSpeed = 0f; // 0이면 즉시 반영, 0보다 크면 보간 (ratio/sec) // ─── 임계값별 색상 ────────────────────────────────────────────────── - // ratio > _midThreshold → _highColor (정상) - // _lowThreshold < ratio <= _midThreshold → _midColor (주의) - // ratio <= _lowThreshold → _lowColor (위험) + // 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); @@ -31,10 +35,11 @@ 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; // 풀체력 시 Fill 스케일 (Awake에 캐싱, 이후 ratio 곱해서 사용) - private SpriteRenderer _fillRenderer; // Fill의 색상 변경용 SpriteRenderer - private float _currentRatio = 1f; // 현재 표시된 HP 비율 (보간 진행 시 _targetRatio로 수렴) - private float _targetRatio = 1f; // 도달해야 할 HP 비율 + private Vector3 _baseFillScale; // 풀체력 시 Fill 스케일 (Awake에 캐싱, 이후 ratio 곱해서 사용) + private SpriteRenderer _fillRenderer; // Fill 색상 변경용 + private SpriteRenderer[] _renderers; // 숨김 처리용 — Background + Fill 전부 + private float _currentRatio = 1f; // 현재 표시 중인 HP 비율 (보간 시 점진적으로 수렴) + private float _appliedRatio = -1f; // 마지막으로 시각에 반영한 비율 (중복 갱신 방지, -1이면 첫 프레임 강제 갱신) private void Awake() { @@ -46,55 +51,30 @@ private void Awake() _baseFillScale = _fill.localScale; _fillRenderer = _fill.GetComponent(); } + + // 숨김 토글 대상: 이 HP바 계층의 모든 SpriteRenderer (Background + Fill). + _renderers = GetComponentsInChildren(true); } - // 활성/비활성 토글 시 자동 구독·해제 (메모리 누수 방지). - // 활성 시 즉시 한 번 갱신해서 현재 HP 상태 반영. - private void OnEnable() + // 매 프레임 Health 상태를 폴링해서 반영. + // 비율이 실제로 바뀐 프레임에만 transform/color를 건드린다 (정지 시 비용 거의 0). + private void Update() { if (_health == null) return; - _health.OnHealthChanged += HandleHealthChanged; - HandleHealthChanged(_health.CurrentHealth, _health.MaxHealth); - } + float target = _health.Ratio; - private void OnDisable() - { - if (_health != null) - _health.OnHealthChanged -= HandleHealthChanged; - } + if (_smoothSpeed > 0f) + _currentRatio = Mathf.MoveTowards(_currentRatio, target, _smoothSpeed * Time.deltaTime); + else + _currentRatio = target; - // 보간 모드일 때만 매 프레임 스케일 갱신. - private void Update() - { - if (_smoothSpeed <= 0f) return; - if (Mathf.Approximately(_currentRatio, _targetRatio)) return; + if (Mathf.Approximately(_currentRatio, _appliedRatio)) return; + _appliedRatio = _currentRatio; - _currentRatio = Mathf.MoveTowards(_currentRatio, _targetRatio, _smoothSpeed * Time.deltaTime); ApplyScale(); - } - - // Health.OnHealthChanged 이벤트 콜백. ratio 계산 → 스케일/색상 갱신 → 숨김 처리. - private void HandleHealthChanged(int current, int max) - { - _targetRatio = max > 0 ? (float)current / max : 0f; - - // 즉시 모드면 바로 스케일 반영. 보간 모드면 Update에서 점진적으로. - if (_smoothSpeed <= 0f) - { - _currentRatio = _targetRatio; - ApplyScale(); - } - - ApplyColor(_targetRatio); - - // 풀체력(1)이거나 사망(0)이면 HpBar 자체를 숨김 (UI 정리 + GameObject 부하 감소). - if (_hideWhenFull && _fill != null) - { - bool shouldShow = _targetRatio < 1f && _targetRatio > 0f; - if (gameObject.activeSelf != shouldShow) - gameObject.SetActive(shouldShow); - } + ApplyColor(_currentRatio); + ApplyVisibility(); } // Fill의 X 스케일 = baseScale.x × ratio. 피벗이 LEFT여야 왼쪽부터 채워짐. @@ -112,14 +92,26 @@ private void ApplyColor(float ratio) { if (_fillRenderer == null) return; - Color color; if (ratio <= _lowThreshold) - color = _lowColor; + _fillRenderer.color = _lowColor; else if (ratio <= _midThreshold) - color = _midColor; + _fillRenderer.color = _midColor; else - color = _highColor; + _fillRenderer.color = _highColor; + } - _fillRenderer.color = color; + // HP가 풀(1)이거나 사망(0)이면 HP바를 숨김. + // GameObject가 아니라 SpriteRenderer의 enabled만 끈다 — GameObject를 끄면 + // Update가 멈춰 폴링이 중단되고 다시 켤 수 없게 되기 때문. + private void ApplyVisibility() + { + if (!_hideWhenFull || _renderers == null) return; + + bool visible = _currentRatio > 0f && _currentRatio < 1f; + for (int i = 0; i < _renderers.Length; i++) + { + if (_renderers[i] != null) + _renderers[i].enabled = visible; + } } } diff --git a/Assets/03_Character/ColorMan/Prefabs/BossMan.prefab b/Assets/03_Character/ColorMan/Prefabs/BossMan.prefab new file mode 100644 index 0000000..870ba18 --- /dev/null +++ b/Assets/03_Character/ColorMan/Prefabs/BossMan.prefab @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ea607652ebfee29770c24295fecc63e9449fc614565b1d4cdeec3c4771e63ca +size 14640 diff --git a/Assets/03_Character/ColorMan/Prefabs/BossMan.prefab.meta b/Assets/03_Character/ColorMan/Prefabs/BossMan.prefab.meta new file mode 100644 index 0000000..eedb645 --- /dev/null +++ b/Assets/03_Character/ColorMan/Prefabs/BossMan.prefab.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 22b71e2307558b84093af390934cdac6 +PrefabImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/03_Character/WhiteMan/Animations/GunWalkFire.anim b/Assets/03_Character/WhiteMan/Animations/GunWalkFire.anim index 6b5ed67..f6b0766 100644 --- a/Assets/03_Character/WhiteMan/Animations/GunWalkFire.anim +++ b/Assets/03_Character/WhiteMan/Animations/GunWalkFire.anim @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2e76093fba5b5a4d42d261d2a1dbf79261d81f0065e925e942a731aca8b8f58d -size 2984 +oid sha256:c3a0965d8e5d5ad8deda19c889b9568844f5a7ca94679f1e4fbb6da8c1abf8ea +size 2129 diff --git a/Assets/05_Data/Wave/WaveData_Boss.asset b/Assets/05_Data/Wave/WaveData_Boss.asset new file mode 100644 index 0000000..e8ebd10 --- /dev/null +++ b/Assets/05_Data/Wave/WaveData_Boss.asset @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8ac9d75fb89545d46aea4bd5592691ceaf5eda20c47772fe08ec816e92a8fb60 +size 625 diff --git a/Assets/05_Data/Wave/WaveData_Boss.asset.meta b/Assets/05_Data/Wave/WaveData_Boss.asset.meta new file mode 100644 index 0000000..7d49ef2 --- /dev/null +++ b/Assets/05_Data/Wave/WaveData_Boss.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3bfeb438ec14d3e4e9c4621d0a47ec31 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/06_Textures/BossZoneButton.png b/Assets/06_Textures/BossZoneButton.png new file mode 100644 index 0000000..e094b4d --- /dev/null +++ b/Assets/06_Textures/BossZoneButton.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:02d5f9699a452a305f704f87b55adea7bb5d0f546757f11da21216333a92b2df +size 20385 diff --git a/Assets/06_Textures/BossZoneButton.png.meta b/Assets/06_Textures/BossZoneButton.png.meta new file mode 100644 index 0000000..5bb397a --- /dev/null +++ b/Assets/06_Textures/BossZoneButton.png.meta @@ -0,0 +1,169 @@ +fileFormatVersion: 2 +guid: 77250519c4adab74e9f2f68094dc2c86 +TextureImporter: + internalIDToNameTable: + - first: + 213: 6839007031424135520 + second: BossZoneButton_0 + externalObjects: {} + serializedVersion: 13 + mipmaps: + mipMapMode: 0 + enableMipMap: 0 + sRGBTexture: 1 + linearTexture: 0 + fadeOut: 0 + borderMipMap: 0 + mipMapsPreserveCoverage: 0 + alphaTestReferenceValue: 0.5 + mipMapFadeDistanceStart: 1 + mipMapFadeDistanceEnd: 3 + bumpmap: + convertToNormalMap: 0 + externalNormalMap: 0 + heightScale: 0.25 + normalMapFilter: 0 + flipGreenChannel: 0 + isReadable: 0 + streamingMipmaps: 0 + streamingMipmapsPriority: 0 + vTOnly: 0 + ignoreMipmapLimit: 0 + grayScaleToAlpha: 0 + generateCubemap: 6 + cubemapConvolution: 0 + seamlessCubemap: 0 + textureFormat: 1 + maxTextureSize: 2048 + textureSettings: + serializedVersion: 2 + filterMode: 1 + aniso: 1 + mipBias: 0 + wrapU: 1 + wrapV: 1 + wrapW: 1 + nPOTScale: 0 + lightmap: 0 + compressionQuality: 50 + spriteMode: 1 + spriteExtrude: 1 + spriteMeshType: 1 + alignment: 0 + spritePivot: {x: 0.5, y: 0.5} + spritePixelsToUnits: 100 + spriteBorder: {x: 0, y: 0, z: 0, w: 0} + spriteGenerateFallbackPhysicsShape: 1 + alphaUsage: 1 + alphaIsTransparency: 1 + spriteTessellationDetail: -1 + textureType: 8 + textureShape: 1 + singleChannelComponent: 0 + flipbookRows: 1 + flipbookColumns: 1 + maxTextureSizeSet: 0 + compressionQualitySet: 0 + textureFormatSet: 0 + ignorePngGamma: 0 + applyGammaDecoding: 0 + swizzle: 50462976 + cookieLightType: 0 + platformSettings: + - serializedVersion: 4 + buildTarget: DefaultTexturePlatform + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Standalone + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: Android + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + - serializedVersion: 4 + buildTarget: iOS + maxTextureSize: 2048 + resizeAlgorithm: 0 + textureFormat: -1 + textureCompression: 1 + compressionQuality: 50 + crunchedCompression: 0 + allowsAlphaSplitting: 0 + overridden: 0 + ignorePlatformSupport: 0 + androidETC2FallbackOverride: 0 + forceMaximumCompressionQuality_BC6H_BC7: 0 + spriteSheet: + serializedVersion: 2 + sprites: + - serializedVersion: 2 + name: BossZoneButton_0 + rect: + serializedVersion: 2 + x: 0 + y: 0 + width: 882 + height: 239 + alignment: 0 + pivot: {x: 0, y: 0} + border: {x: 0, y: 0, z: 0, w: 0} + customData: + outline: [] + physicsShape: [] + tessellationDetail: -1 + bones: [] + spriteID: 0613f6419a809ee50800000000000000 + internalID: 6839007031424135520 + vertices: [] + indices: + edges: [] + weights: [] + outline: [] + customData: + physicsShape: [] + bones: [] + spriteID: 5e97eb03825dee720800000000000000 + internalID: 0 + vertices: [] + indices: + edges: [] + weights: [] + secondaryTextures: [] + spriteCustomMetadata: + entries: [] + nameFileIdTable: + BossZoneButton_0: 6839007031424135520 + mipmapLimitGroupName: + pSDRemoveMatte: 0 + userData: + assetBundleName: + assetBundleVariant: