2026-05-20 몬스터AI추가
This commit is contained in:
@@ -74,6 +74,12 @@ public class Enemy : MonoBehaviour, IDamageable
|
|||||||
private Vector2 _grabTargetPosition;// 잡힌 동안 강제로 위치할 좌표
|
private Vector2 _grabTargetPosition;// 잡힌 동안 강제로 위치할 좌표
|
||||||
private int _grabSolidMask; // 잡혀서 이동할 때 벽에 끼이지 않게 검사할 솔리드 레이어
|
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 자동 호출).
|
// 컴포넌트 캐싱 + Health.OnDied 구독 (사망 시 HandleDeath 자동 호출).
|
||||||
private void Awake()
|
private void Awake()
|
||||||
{
|
{
|
||||||
|
|||||||
360
Assets/02_Scripts/Enemy/EnemyAI.cs
Normal file
360
Assets/02_Scripts/Enemy/EnemyAI.cs
Normal file
@@ -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<Enemy>();
|
||||||
|
_health = GetComponent<Health>();
|
||||||
|
_rb = GetComponent<Rigidbody2D>();
|
||||||
|
_anim = GetComponentInChildren<Animator>();
|
||||||
|
_spriteRenderer = GetComponentInChildren<SpriteRenderer>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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<PlayerController>();
|
||||||
|
if (player == null) return;
|
||||||
|
|
||||||
|
_target = player.transform;
|
||||||
|
CacheTargetComponents();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CacheTargetComponents()
|
||||||
|
{
|
||||||
|
if (_target == null)
|
||||||
|
{
|
||||||
|
_targetHealth = null;
|
||||||
|
_targetDamageable = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_targetHealth = _target.GetComponent<Health>();
|
||||||
|
_targetDamageable = _target.GetComponent<IDamageable>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Assets/02_Scripts/Enemy/EnemyAI.cs.meta
Normal file
2
Assets/02_Scripts/Enemy/EnemyAI.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d66bbb8d57f14ff1a1d85622f7d0e4f3
|
||||||
Binary file not shown.
Reference in New Issue
Block a user