From e7c13307543136580465e1b748311769132f8cdd Mon Sep 17 00:00:00 2001 From: "DESKTOP-VVOCIJO\\PC" Date: Wed, 20 May 2026 13:19:32 +0900 Subject: [PATCH] =?UTF-8?q?2026-05-20=20=EB=AA=AC=EC=8A=A4=ED=84=B0AI?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Assets/02_Scripts/Enemy/Enemy.cs | 6 + Assets/02_Scripts/Enemy/EnemyAI.cs | 360 ++++++++++++++++++ Assets/02_Scripts/Enemy/EnemyAI.cs.meta | 2 + .../ColorMan/Prefabs/BlackMan.prefab | 4 +- 4 files changed, 370 insertions(+), 2 deletions(-) create mode 100644 Assets/02_Scripts/Enemy/EnemyAI.cs create mode 100644 Assets/02_Scripts/Enemy/EnemyAI.cs.meta diff --git a/Assets/02_Scripts/Enemy/Enemy.cs b/Assets/02_Scripts/Enemy/Enemy.cs index c2098c0..a7d75e8 100644 --- a/Assets/02_Scripts/Enemy/Enemy.cs +++ b/Assets/02_Scripts/Enemy/Enemy.cs @@ -74,6 +74,12 @@ public class Enemy : MonoBehaviour, IDamageable private Vector2 _grabTargetPosition;// 잡힌 동안 강제로 위치할 좌표 private int _grabSolidMask; // 잡혀서 이동할 때 벽에 끼이지 않게 검사할 솔리드 레이어 + public bool IsDead => _health != null && _health.IsDead; + public bool IsGrabbed => _isGrabbed; + public bool IsInHitReaction => _hitReactionTimer > 0f || _isHitPositionCorrecting || _flashTimer > 0f; + public bool CanUseAI => !IsDead && !_isGrabbed && !IsInHitReaction; + + // 컴포넌트 캐싱 + Health.OnDied 구독 (사망 시 HandleDeath 자동 호출). private void Awake() { diff --git a/Assets/02_Scripts/Enemy/EnemyAI.cs b/Assets/02_Scripts/Enemy/EnemyAI.cs new file mode 100644 index 0000000..8573328 --- /dev/null +++ b/Assets/02_Scripts/Enemy/EnemyAI.cs @@ -0,0 +1,360 @@ +using UnityEngine; + +// Simple 2D enemy brain: +// detects the player, chases on the X axis, and deals damage after an attack windup. +[RequireComponent(typeof(Enemy))] +[RequireComponent(typeof(Rigidbody2D))] +public class EnemyAI : MonoBehaviour +{ + private enum AIState + { + Idle, + Chase, + Attack + } + + [Header("Target")] + [SerializeField] private Transform _target; + [SerializeField] private bool _autoFindPlayer = true; + [SerializeField] private LayerMask _targetLayer = 1 << 7; // Player layer + [SerializeField] private float _detectRange = 6f; + [SerializeField] private float _detectVerticalRange = 2.5f; + [SerializeField] private float _loseRange = 8f; + [SerializeField] private float _loseVerticalRange = 4f; + + [Header("Movement")] + [SerializeField] private float _moveSpeed = 2f; + [SerializeField] private float _stopDistance = 0.75f; + + [Header("Attack")] + [SerializeField] private float _attackRange = 1f; + [SerializeField] private float _attackVerticalTolerance = 1f; + [SerializeField] private int _attackDamage = 10; + [SerializeField] private float _attackWindup = 0.25f; + [SerializeField] private float _attackLockDuration = 0.45f; + [SerializeField] private float _attackCooldown = 1f; + [SerializeField] private Vector2 _attackHitVelocity = new Vector2(3f, 0f); + [SerializeField] private string _targetHitReactionAnimationState; + + [Header("Animation")] + [SerializeField] private string _idleAnimationState = ""; + [SerializeField] private string _runAnimationState = ""; + [SerializeField] private string _attackAnimationState = ""; + + private Enemy _enemy; + private Health _health; + private Rigidbody2D _rb; + private Animator _anim; + private SpriteRenderer _spriteRenderer; + private Health _targetHealth; + private IDamageable _targetDamageable; + + private AIState _state = AIState.Idle; + private bool _hasAggro; + private bool _isAttacking; + private bool _hasAppliedAttackDamage; + private float _attackTimer; + private float _attackCooldownTimer; + private float _facingDirection = 1f; + private string _activeAnimationState; + + private void Awake() + { + _enemy = GetComponent(); + _health = GetComponent(); + _rb = GetComponent(); + _anim = GetComponentInChildren(); + _spriteRenderer = GetComponentInChildren(); + } + + private void OnEnable() + { + _state = AIState.Idle; + _hasAggro = false; + _isAttacking = false; + _hasAppliedAttackDamage = false; + _attackTimer = 0f; + _attackCooldownTimer = 0f; + ResolveTarget(); + } + + private void Update() + { + TickCooldown(); + + if (_health != null && _health.IsDead) + { + StopHorizontalMovement(); + enabled = false; + return; + } + + ResolveTarget(); + + if (_target == null) + { + if (_isAttacking) + CancelAttack(); + + _state = AIState.Idle; + PlayStateAnimation(); + return; + } + + if (!CanControl()) + { + if (_isAttacking) + CancelAttack(); + + if (_enemy != null && !_enemy.CanUseAI) + return; + + _state = AIState.Idle; + PlayStateAnimation(); + return; + } + + RefreshAggro(); + UpdateFacing(); + + if (_isAttacking) + { + TickAttack(); + PlayStateAnimation(); + return; + } + + if (!_hasAggro) + { + _state = AIState.Idle; + PlayStateAnimation(); + return; + } + + if (IsTargetInAttackRange() && _attackCooldownTimer <= 0f) + BeginAttack(); + else + _state = IsTargetInAttackRange() ? AIState.Idle : AIState.Chase; + + PlayStateAnimation(); + } + + private void FixedUpdate() + { + if (_rb == null) return; + + if (_health != null && _health.IsDead) + { + StopHorizontalMovement(); + return; + } + + if (_enemy != null && !_enemy.CanUseAI) + return; + + if (!CanMove()) + { + StopHorizontalMovement(); + return; + } + + Vector2 velocity = _rb.linearVelocity; + float dx = _target.position.x - transform.position.x; + + if (Mathf.Abs(dx) <= _stopDistance) + velocity.x = 0f; + else + velocity.x = Mathf.Sign(dx) * _moveSpeed; + + _rb.linearVelocity = velocity; + } + + private void ResolveTarget() + { + if (_target != null) + { + CacheTargetComponents(); + return; + } + + if (!_autoFindPlayer) return; + + PlayerController player = FindFirstObjectByType(); + if (player == null) return; + + _target = player.transform; + CacheTargetComponents(); + } + + private void CacheTargetComponents() + { + if (_target == null) + { + _targetHealth = null; + _targetDamageable = null; + return; + } + + _targetHealth = _target.GetComponent(); + _targetDamageable = _target.GetComponent(); + } + + private bool CanControl() + { + if (_enemy != null && !_enemy.CanUseAI) return false; + if (_targetHealth != null && _targetHealth.IsDead) return false; + + return true; + } + + private bool CanMove() + { + return _rb != null + && _target != null + && CanControl() + && _hasAggro + && !_isAttacking + && _state == AIState.Chase + && !IsTargetInAttackRange(); + } + + private void RefreshAggro() + { + if (_target == null) + { + _hasAggro = false; + return; + } + + Vector2 delta = _target.position - transform.position; + float absX = Mathf.Abs(delta.x); + float absY = Mathf.Abs(delta.y); + + if (_hasAggro) + { + _hasAggro = absX <= _loseRange && absY <= _loseVerticalRange; + return; + } + + _hasAggro = absX <= _detectRange && absY <= _detectVerticalRange; + } + + private bool IsTargetInAttackRange() + { + if (_target == null) return false; + + Vector2 delta = _target.position - transform.position; + return Mathf.Abs(delta.x) <= _attackRange + && Mathf.Abs(delta.y) <= _attackVerticalTolerance; + } + + private void BeginAttack() + { + _state = AIState.Attack; + _isAttacking = true; + _hasAppliedAttackDamage = false; + _attackTimer = 0f; + _attackCooldownTimer = Mathf.Max(_attackCooldown, _attackLockDuration); + StopHorizontalMovement(); + UpdateFacing(); + } + + private void TickAttack() + { + _attackTimer += Time.deltaTime; + StopHorizontalMovement(); + + if (!_hasAppliedAttackDamage && _attackTimer >= _attackWindup) + { + _hasAppliedAttackDamage = true; + TryApplyAttackDamage(); + } + + if (_attackTimer >= _attackLockDuration) + { + _isAttacking = false; + _state = _hasAggro && !IsTargetInAttackRange() ? AIState.Chase : AIState.Idle; + } + } + + private void CancelAttack() + { + _isAttacking = false; + _hasAppliedAttackDamage = false; + _attackTimer = 0f; + } + + private void TryApplyAttackDamage() + { + if (!IsTargetInAttackRange()) return; + + if (_targetDamageable == null) + CacheTargetComponents(); + + if (_targetDamageable == null) return; + if (!IsTargetLayerValid()) return; + + Vector2 hitVelocity = new Vector2(_attackHitVelocity.x * _facingDirection, _attackHitVelocity.y); + _targetDamageable.TakeDamage(_attackDamage, hitVelocity, _targetHitReactionAnimationState); + } + + private bool IsTargetLayerValid() + { + if (_target == null) return false; + if (_targetLayer.value == 0) return true; + + return (_targetLayer.value & (1 << _target.gameObject.layer)) != 0; + } + + private void TickCooldown() + { + if (_attackCooldownTimer > 0f) + _attackCooldownTimer -= Time.deltaTime; + } + + private void StopHorizontalMovement() + { + if (_rb == null) return; + + Vector2 velocity = _rb.linearVelocity; + velocity.x = 0f; + _rb.linearVelocity = velocity; + } + + private void UpdateFacing() + { + if (_target == null) return; + + float dx = _target.position.x - transform.position.x; + if (Mathf.Abs(dx) <= 0.01f) return; + + _facingDirection = Mathf.Sign(dx); + if (_spriteRenderer != null) + _spriteRenderer.flipX = _facingDirection < 0f; + } + + private void PlayStateAnimation() + { + string nextState = _state switch + { + AIState.Chase => _runAnimationState, + AIState.Attack => _attackAnimationState, + _ => _idleAnimationState + }; + + if (_anim == null || string.IsNullOrEmpty(nextState)) return; + if (_activeAnimationState == nextState) return; + + _anim.Play(nextState); + _activeAnimationState = nextState; + } + + private void OnDrawGizmosSelected() + { + Gizmos.color = Color.yellow; + Gizmos.DrawWireSphere(transform.position, _detectRange); + + Gizmos.color = Color.red; + Gizmos.DrawWireSphere(transform.position, _attackRange); + } +} diff --git a/Assets/02_Scripts/Enemy/EnemyAI.cs.meta b/Assets/02_Scripts/Enemy/EnemyAI.cs.meta new file mode 100644 index 0000000..b52b6fe --- /dev/null +++ b/Assets/02_Scripts/Enemy/EnemyAI.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d66bbb8d57f14ff1a1d85622f7d0e4f3 diff --git a/Assets/03_Character/ColorMan/Prefabs/BlackMan.prefab b/Assets/03_Character/ColorMan/Prefabs/BlackMan.prefab index d1888aa..015bcbc 100644 --- a/Assets/03_Character/ColorMan/Prefabs/BlackMan.prefab +++ b/Assets/03_Character/ColorMan/Prefabs/BlackMan.prefab @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c69a84958ffc64e4492a354e12d7e6535b92382d24b676412e502d62437436ac -size 12528 +oid sha256:e0bf271281d824632294a7864608c63657d23755d2db63d80d575cfd277196cd +size 13483