[Unity / C#] 적 만들기 #1

주예성·2025년 7월 3일

📋 목차

  1. AI 상태 분류
  2. 플레이어 감지
  3. 중간 결과 1
  4. 추적 상태
  5. 중간 결과 2
  6. 공격 판정
  7. 최종 결과

🤖 AI 상태 분류

AI에게는 여러 행동들이 있습니다. 일단 행동들의 부모가 될 추상 클래스 EnemyState스크립트와 IdleState(대기), PatrolState(순찰), ChaseState(추적), AttackState(공격)스크립트를 생성합니다.

// EnemyState.cs

public abstract class EnemyState
{
    protected Enemy enemy;

    public EnemyState(Enemy enemy)
    {
        this.enemy = enemy;
    }

    public abstract void Enter();
    public abstract void Update();
    public abstract void Exit();
}

// IdleState.cs

public class IdleState : EnemyState
{
    // 생성자 문법
    // public 생성자(매개변수) : 부모생성자호출 {생성자 내용}
    // 생성자 내용이 빈 이유는 부모 생성자가 이미 다 해줘서
    public IdleState(Enemy enemy) : base(enemy) { }
    public override void Enter()
    {
        Debug.Log("적이 대기 상태로 진입");
    }

    public override void Update()
    {
        Debug.Log("적이 대기 행동을 하는 중");
    }

    public override void Exit()
    {
        Debug.Log("적이 대기 행동을 벗어남");
    }
}

// PatrolState.cs

public class PatrolState : EnemyState
{
    public PatrolState(Enemy enemy) : base(enemy) { }
    public override void Enter()
    {
        Debug.Log("적이 순찰 상태로 진입");
    }

    public override void Update()
    {
        Debug.Log("적이 순찰 행동을 하는 중");
    }

    public override void Exit()
    {
        Debug.Log("적이 순찰 행동을 벗어남");
    }
}

// ChaseState.cs

public class ChaseState : EnemyState
{
    public ChaseState(Enemy enemy) : base(enemy) { }
    public override void Enter()
    {
        Debug.Log("적이 추적 상태로 진입");
    }

    public override void Update()
    {
        Debug.Log("적이 추적 행동을 하는 중");
    }

    public override void Exit()
    {
        Debug.Log("적이 추적 행동을 벗어남");
    }
}

// AttackState.cs

public class AttackState : EnemyState
{
    public AttackState(Enemy enemy) : base(enemy) { }
    public override void Enter()
    {
        Debug.Log("적이 공격 상태로 진입");
    }

    public override void Update()
    {
        Debug.Log("적이 공격 행동을 하는 중");
    }

    public override void Exit()
    {
        Debug.Log("적이 공격 행동을 벗어남");
    }
}

// Enemy.cs

private EnemyState currentState;

protected override void Start()
{
	// 처음은 대기상태
	ChangeState(new IdleState(this));
    base.Start();
}

private void Update()
{
    currentState?.Update();
}

// EnemyState를 바꿔주는 함수
public void ChangeState(EnemyState newState)
{
    currentState?.Exit();		// 현재 상태에서 벗어나고
    currentState = newState;	// 새로운 상태를 할당
    currentState?.Enter();		// 새로운 상태에 진입
}

처음 생성될땐 대기 상태로 진입했다는 로그를 볼 수 있습니다.

임의로 2초 이상이 흐를 경우 대기 행동을 벗어나도록 했습니다. 그러면 2초간 Update함수가 호출되다가 Exit하게 됩니다.


👾 플레이어 감지

플레이어가 일정범위안에 들어가고, 적이 볼 수 있는 시야(각도)내에 들어와야 합니다. 또한, 장애물이 있을 경우 감지하지 않은 것으로 처리될겁니다.

// Enemy.cs

private GameObject player;

private float fieldOfViewAngle = 120f;
public float sightRange = 10f;    
public float attackRange = 2f;

protected override void Start()
{
    player = GameObject.Find("Player");
    maxHealth = enemyData.maxHealth;
    ChangeState(new IdleState(this));
    base.Start();
}
private void Update()
{
    if (CanSeePosition(player.transform) && !IsObstacleBetween(player.transform) && IsInDistance(player.transform))
    {
        Debug.Log("플레이어 발견!!");
    }
    else
    {
        Debug.Log("찾는 중.....");
    }
}

// 플레이어를 볼 수 있는 각도인지
public bool CanSeeAngle(Transform target)
{
    Vector3 directionToTarget = (target.position - transform.position).normalized;
    float angleToTarget = Vector3.Angle(transform.forward, directionToTarget);
    // 각도는 좌우 대칭이므로 나누기 2
    return angleToTarget <= fieldOfViewAngle / 2f;
}

// Raycast에 닿은 것이 플레이어인지 사물인지 (벽이나 다른 사물이라면 true)
public bool IsObstacleBetween(Transform target)
{
    Vector3 direction = (target.position - transform.position).normalized;
    float distance = Vector3.Distance(transform.position, target.position); 

    if (Physics.Raycast(transform.position, direction, out RaycastHit hit, distance))
    {
        return hit.transform != target;
    }
    return false;
}

// 감지 가능 거리 안에 있는지
public bool IsInDistance(Transform target)
{
    return Vector3.Distance(transform.position, target.position) <= sightRange; 
}

🎮 중간 결과 1

각도, 거리, 충돌에 따라 감지하는 로그가 띄워집니다.


🏃 추적 상태

적의 시야내에 플레이어가 온다면 추적 상태로 바꾸는 로직을 짜겠습니다.

1. ChangeState

// Enemy.cs

private bool isChase = false;

private void Update()
{
    if (CanSeeAngle(player.transform) && !IsObstacleBetween(player.transform) && IsInDistance(player.transform))
    {
        if (!isChase)
        {
        	// 시야내에 있다면 ChaseState로 변경
            ChangeState(new ChaseState(this));
            isChase = true;
        }
    }
    else
    {
    	// 추적 중 사라진다면 IdleState로 변경
        if (isChase)
		{
    		ChangeState(new IdleState(this));
    		isChase = false;
		}
    }

	// isChase가 true일때 isChase의 Update를 실행함
    if (isChase)
    {
        currentState.Update();
    }
}

2. 이동

플레이어를 향해 회전하고 이동하는 코드를 짜봅시다.

// ChaseState.cs

private void Moving()
{
    Vector3 direction = (enemy.target.transform.position - enemy.transform.position).normalized;
    // y는 0으로 해서 수직상승을 하지 않게 함
    enemy.rb.MovePosition(enemy.transform.position + new Vector3(direction.x, 0, direction.z) * enemy.enemyData.moveSpeed);
    if (direction != Vector3.zero)
    {
    	// y는 0으로 하여 돌아가지 않게 함
        Quaternion targetRotation = Quaternion.LookRotation(new Vector3(direction.x, 0, direction.z));
        enemy.transform.rotation = Quaternion.Slerp(enemy.transform.rotation, targetRotation, enemy.enemyData.rotationSpeed * Time.deltaTime);
    }
}

// Enemy.cs

// 중간에 Enemy가 회전하지 않기 위한 설정
protected override void Start()
{
	rb = GetComponent<Rigidbody>();
    // 질량 증가 - 다른 물체에게 밀리지 않도록 무겁게 만듦
	rb.mass = 10f;                    
    // 공기 저항 - 이동 시 점점 느려지게 해서 부드러운 정지
	rb.drag = 10f;                    
    // 회전 저항 - 회전 시 빠르게 멈추게 해서 흔들림 방지
	rb.angularDrag = 20f;             
    // X, Z축 회전 고정 - 앞뒤/좌우로 넘어지지 않게 (Y축 회전만 허용)
	rb.constraints = RigidbodyConstraints.FreezeRotationX | RigidbodyConstraints.FreezeRotationZ;  
	// 무게중심을 아래쪽으로 이동 - 바닥에 가깝게 해서 안정성 증가                 
    rb.centerOfMass = new Vector3(0, -0.5f, 0);                   
}

3. 상태 전환

적 상태를 효율적으로 관리하기 위해 StateType을 생성합시다.

// StateType.cs

public enum StateType
{
    Idle,
    Chase,
    Attack,
    Patrol,
    Dead
}

// EnemyState.cs

public abstract StateType CurrentStateType { get; }

// IdleState.cs

public override StateType CurrentStateType => StateType.Idle;

// PatrolState.cs

public override StateType CurrentStateType => StateType.Patrol;

// ChaseState.cs

public override StateType CurrentStateType => StateType.Chase;

// AttackState.cs

public override StateType CurrentStateType => StateType.Attack;
// Enemy.cs

private void Update()
{
    switch(currentState.CurrentStateType)
    {
        case StateType.Idle:
            DoIdle();
            break;
        case StateType.Patrol:
            DoPatrol();
            break;
        case StateType.Chase:
            DoChase();
            break;
        case StateType.Attack:
            DoAttack();
            break;
        case StateType.Dead:
            break;
        default:
            break;
    }
    currentState.Update();
}

// Idle일때 목표를 포착하면 Chase로
private void DoIdle()
{
    if (CanSeeAngle(target.transform) && !IsObstacleBetween(target.transform) && IsInDistance(target.transform))
    {
        ChangeState(new ChaseState(this));
    }
}

// Patrol일때 목표를 포착하면 chase로
private void DoPatrol()
{
    if (CanSeeAngle(target.transform) && !IsObstacleBetween(target.transform) && IsInDistance(target.transform))
    {
        ChangeState(new ChaseState(this));
    }
}

// Chase일때 공격범위 내에 들어오면 Attack으로, 나간다면 Idle로
private void DoChase()
{
    if (IsInAttackRange(target.transform))
    {
        ChangeState(new AttackState(this));
    }
    else if (!IsInDistance(target.transform))
    {
        ChangeState(new IdleState(this));
    }

}

// Attack일때 공격범위에서 나간다면 Idle로
private void DoAttack()
{
    if (!IsInAttackRange(target.transform))
    {
        ChangeState(new IdleState(this));
    }
}

🎮 중간 결과 2

플레이어를 포착하면 따라오고, 공격범위 내에 들어온다면 멈춰서 공격하는 모습입니다.


🗡️ 공격 판정

실제로 공격을 하고 데미지를 받는 것을 처리해봅시다. 여러가지 방법이 있지만, 전 OnTriggerEnter을 이용했습니다.

1. AttackPoint

적이 공격하는 범위를 설정합니다.
AttackPoint라는 빈 오브젝트를 만들어 Collider를 넣고 IsTrigger를 체크합니다.

2. AttackCollider

충돌을 관리할 AttackCollider 스크립트를 생성합시다.

// AttackCollider.cs

public class AttackCollider : MonoBehaviour
{
    private Enemy enemy;

    void Start()
    {
        enemy = GetComponentInParent<Enemy>();

        // 시작할 때는 콜라이더 비활성화
        GetComponent<Collider>().enabled = false;
    }

    void OnTriggerEnter(Collider other)
    {
        if (other.CompareTag("Player"))
        {
            other.gameObject.GetComponent<PlayerState>().ModifyHealth(-enemy.enemyData.damage);
			Debug.Log($"플레이어 체력 {enemy.enemyData.damage} 감소!");
        }
    }
}

2. 쿨타임을 적용한 공격

만약 공격범위내에 있다면 적은 공격을 시도하게 됩니다. 그 시도는 Collider활성화 하는 것으로 적용됩니다.

// AttackState.cs

private float currentCooldown;

public override void Enter()
{
	currentCooldown = enemy.enemyData.attackCooldown;
}

public override void Update()
{
	if (CanAttack())
    {
    	enemy.PerformAttack();
    }
}

private bool CanAttack()
{
	currentCooldown -= Time.deltaTime;
    if (currentCooldown <= 0)
    {
    	Debug.Log("공격!");
        currentCooldown = enemy.enemyData.attackCooldown;
        return true;
    }
    return false;
}

🎮 최종 결과

투명한 원 부분이 적의 공격 범위 입니다.


📚 오늘의 배운 점

  • State 변환으로 몹 상태 변경
  • AI 추격 시스템
  • Collider를 이용한 공격 판정

🎯 다음 계획

다음 글에서는:

  1. 다양한 몹 제작
  2. AI시스템 추가 및 구체화, 수정
profile
Unreal Engine & Unity 게임 개발자

0개의 댓글