본문 바로가기

유니티 개발 정보/인공지능

Space Shooter AI(우주 발사체 인공지능) - 3 (마지막)

원문은 이곳에서 보실수 있습니다.
내용이 길어 총 3편으로 나눠서 올리겠습니다.
본문에서 프로젝트 파일을 다운받아서 같이 따라하시면 더 이해가 쉽습니다.:)



숨바꼭질 (first FSM)

우리는 여기서 유한-상태-기계(FSM)이라는 새로운 단어를 소개하려고 한다. 이 화려한 단어 뒤에 있는 것은 무엇인가? 다른 행동 또는 주변 환경에 따른 행동의 집합이다. 이는 당신의 NPC가 자신의 상태와 주변의 환경에 대해서 행동을 수행하고 있다는 것을 의미한다. 여기 이 주제에 대한 상급 튜토리얼이 있지만, 명심해라! 그것은 상급이다.
(역자 주: 해보시면 쉽습니다 ^^)

적 우주선에 대해서, 자신을 보호하기 위해 바위 뒤로 숨으면서, 계속 앞으로 나아가는 간단한 FSM을 만들 것이다. 코드로 이동하기 전에, 항상 당신의 스크립트에 대해 생각하는 시간을 가져보기를 권장한다. 자, 여기서는 어떤 일이 발생하는가:

  1. 유저 우주선 미사일을 맞기전까지 계속해서 왼쪽으로 움직인다.

  2. 우주선은 미사일을 쏘고, 피할 곳 뒤에 있지 않다면 피할 곳을 찾아 움직일 것이다.

  3. 우주선은 잠깐 동안 기다리고, 다시 앞으로 이동한다.

  4. 우주선은 반대편에 도달하거나 가는 도중 죽게 된다.

밑에 그래프는 FSM상태와 의사 결정 트리를 보여준다.

이제 스크립트를 보면서 더 많은 의미를 얻을 것이다. 우리는 Enemy.cs 기본 클래스에 public int health;를 추가해야 한다.
(역자 주: 프로젝트를 사용하고 계신분은 이미 Enemy.cs에 추가되어 있기 때문에 public int health;를 추가하지 않으셔도 됩니다. 기존에 Enemy프리팹에 있던 스크립트를 제거하시고 ProtectScript 스크립트를 추가하시면 됩니다.)


자 이제 큰 부분을 보자:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
using UnityEngine;
using System.Collections;
 
public class ProtectScript :Enemy {
 
    Transform cover;
    Transform [] wayout = new Transform[2];
    public int way;
    enum State{Forward,Hiding,Moving};
        State Current;
    public bool hit;
    public override void Start () {
        base.Start ();
        health = 3;
        cover = GameObject.Find ("Cover").GetComponent<Transform>();
        GameObject[] obj = GameObject.FindGameObjectsWithTag("Wayout");
        for(int i = 0;i<obj.Length;i++)wayout[i] = obj[i].GetComponent<Transform>();
        way = -1;
        hit =false;
                Current = State.Forward
                _primaryShoot = _transform.Find ("SpawnLeft").GetComponent<Transform>();
        _secondaryShoot = _transform.Find ("SpawnRight").GetComponent<Transform>();
        InvokeRepeating("Shoot",0.01f,Random.Range(2.0f,4.0f));
    }
 
    public override void Update () {
        switch(Current){
            case State.Forward:
                _transform.Translate ( -velocity.x*Time.deltaTime,0,velocity.z*Time.deltaTime,Space.World);
                break;
            case State.Hiding:
                ToTarget (_cover);
                if(Vector3.Distance (cover.position,_transform.position) <= 0.5f &&way<0 ) {     
                    StartCoroutine(ChangeSwitch(Random.Range (1.0f,3.0f)));
                    way = Random.Range (0,2);
                }
                                break;
            case State.Moving:
                ToTarget (_wayout[way]);
                if(Vector3.Distance (wayout[way].position,_transform.position) < 0.2f) current = State.Forward; 
                break;
        }
    }
 
    IEnumerator ChangeSwitch(float time) {
        yield return new WaitForSeconds(time);
        Current = State.Moving;
    }
 
    public override void OnCollisionEnter(Collision other){
        if(other.gameObject.tag == "ProjectileShip"){
            Destroy(other.gameObject);
            health-=1;
            if(health<=0)Destroy(gameObject);
            if(Current ==State.Forward &&_transform.position.x >cover.position.x )
                             Current =State.Hiding;
        }
    }
    void ToTarget(Transform tr){
        Vector3 direction = tr.position - _transform.position;
        direction.Normalize();
        _transform.Translate ( direction.x*Time.deltaTime,0,direction.z*Time.deltaTime,Space.World);
    }
}

해당 레벨(씬)에 RockPrefab(1개만)을 추가시키고 그것을 적절하게 위치시켜야 한다. cover와 두 wayout가 y좌표 0으로 되도록 하라.
(역자 주: 여기서 cover와 두개의 watout은 RockPrefab의 자식 객체에 객체들을 의미합니다.)

우리는 우주선의 상태를 정의하기 위해서 enum을 사용한다. enum의 장점은 잘못된 값을 할당하지 않는다는 것이다. 여러 이유로 정수로 Swith문을 사용하면, 결국에는 잘못된 값이 들어갈 수도 있게 된다. 이런 일은 enum에서는 발생하지 않는데, 오직 선언된 값만 사용될 수 있기 때문이다.

Update문에서, 우리는 current로 switch문을 사용하였다. 만약 유저가 미사일을 발사했을 때 처음으로 적 우주선이 맞았다면, 상태는 forward에서 hiding으로 변하고 적 우주선은 cover로 움직일 것이다. 
wayout이 선택될 때까지 특정시간동안(0~1초 무작위) 기다린 다음에, 상태는 moving으로 바뀔 것이다.
지금 우주선은 cover밖으로 나오고 있다. wayout에 도착했을 때, 상태는 다시 forward로 돌아갈 것이다. cover point를 지나갔기 때문에, 유저가 미사일을 발사해 적 우주선을 다시 맞춰도 그들의 행동에 더 이상 영향을 주지 못할 것이다.
if(Current ==State.Forward &&_transform.position.x > cover.position.x )current =State.Hiding; 이는 
cover position보다 한번 위치가 더 작아 졌기 때문에, 더 이상 상태가 변경될 수 없다는 것을 의미한다.
(적 우주선은 왼쪽 방향으로 움직이고, x값은 감소한다.)

ToTarget은 적 우주선이 움직여야하는 새로운 벡터를 리턴한다.

게임에서, 만약 당신이 어떤 것도 하지 않는다면, 적 우주선은 왼쪽으로 계속 움직일 것이다. 만약 조기에 적 우주선을 맞춘다면, 적 우주선의 방향을 바뀔 것이고, 다양한 상태가 사용될 것이다. cover가 지난후에 다시 적을 맞춘다면, 그것은 더 이상 적 우주선의 상태변화에 영향을 미치지 못할 것이다.

FSM 튜토리얼에서, 당신은 Update() 안에 약간의 라인을 사용하기 위해서 어떻게 코드를 수정하는지를 보게 될 것이다.


카미카제 우주선

다음 행동은 꽤 간단한데, 우리는 우주선이 그들 스스로 우리와 부딪혀 자살하기를 원한다. 하지만 우리는 약간 몇가지를 추가할 것이다. 우리는 적 우주선이 우리를 때리기 위해 앞 뒤로 왔다 갔다하는 것을 원하지 않는다. 오직 적들이 우리 앞에 있을 때만 원한다.

 이전 단락의 ToTarget 함수를 재사용하기를 원하기 때문에, 필요할 때 마다 어디서든 ToTarget함수를 호출하기 위해서 그것을 다른 다른 파일로 이동시킬 것이다.

프로젝트는 이미 확장 메소드를 포함하는 GameManager 스크립트를 가지고 있다.

1
2
3
4
5
public static void Target(this Transform _transform, Transform target){
    Vector3 direction = target.position - _transform.position;
    direction.Normalize();
    _transform.Translate ( direction.x*Time.deltaTime,0,direction.z*Time.deltaTime,Space.World);
}

우리는 적 우주선(Enemy 프리팹)에 첨부할 새로운 스크립트를 만들 것이다.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
using UnityEngine;
using System.Collections;
 
public class Banzai : Enemy {
 
    Transform target;
    public override void Start () {
        base.Start ();
        _primaryShoot = transform.Find ("SpawnLeft").GetComponent<Transform>();
        _secondaryShoot = transform.Find ("SpawnRight").GetComponent<Transform>();
        target = GameObject.FindGameObjectWithTag("Ship").GetComponent<Transform>();
    }
 
    public override void Update () {
        _transform.Target(_target);
    }
}
만약 게임을 할 때, 적 우주선은 계속해서 우리를 쫓아 다닐 것이다. 앞서 말한대로, 우리는 적 우주선이 우리 앞에 있을 때에만 이것이 작동하기를 원한다.

1
2
3
4
5
6
public override void Update () {
    if(_transform.position.x > _target.position.x)
        _transform.Target(_target);
    else
        base.Update();
}

우리가 적의 왼쪽편에 있다면, 우리는 Target method를 사용할 것이다. 그렇지 않으면 우리는 기본 클래스에있는 base.Update()를 호출 할 것이다.

우리는 손쉽게 이것을 적 미사일에도 적용할 수 있다. 그래서 적 우주선과 적 미사일은 우리를 겨냥할 것이다. 또한 이전의 다른 패턴들을 적 미사일에만 적용할 수도 있다.

당신은 우리가 이전에 사용한 알고리즘들을 Target 함수처럼, 손쉽게 어떤 객체에서든  확장메소드로 변환할 수 있다는 것을 명심해라.

자신의 앞을 볼 수 있는 더 똑똑한 우주선

마지막으로, 마지막 파트에서, 우리는 랜덤 포지션으로 적 우주선을 생성하는 첫번째 알고리즘으로 되돌아 갈 것이다. 또한 Rock을 제거할 것이다. 기억해라 각각의 우주선은 다른 속도을 가지고 있다. 그 결과 몇몇 우주선은 다른 우주선 보다 더 빠르다. 이것은, 그들중 일부는 다른 우주선 위에 있을 수 있다는 것을 의미한다. 나는 이것을 원하지 않는다. 그래서 우리는 그들 앞에 무엇이 있는지 확인하고 상황에 따라 방향을 수정할 수 있게, 적 우주선에게 약간의 눈(eye)을 줄 것이다.

우리가 다시 만들어야 하는 것은 조준선이다. 밑의 그림은 기본 규칙을 보여준다. 하늘색 화살표는 transform.forward이며, 우리는 초록색 원으로 정의된 범위 안에 이 벡터의 45도 각도의 지역안에 무엇이 있는지를 확인하면 된다.
(역자 주: 원래 그림이 있는 부분으로 예상이 되는데, 원문에도 그림이 없어 올리지 못한점 양해 바랍니다)

이 규칙을 달성하기 위해서 다양한 방법들이 있다. 우리가 사용하는 것은 유니티가 제공하는 물리엔진을 사용하는 것이다.  나는 sphere colider를 enemy 프리팹에 추가할 것이고, 반지름을 3으로 설정할 것이다. box colider가 이미 첨부되어있다고 경고 메시지를 받겠지만, 그냥 첨부하면 된다. 이 sphere collider를 isTrigger옵션을 선택하여 trigger로 만들어라.
(역자 주: 프로젝트를 다운받아 사용하시는 분들은 이미 sphere colider가 추가되어 있어서, 따로 추가하지 않으셔도 됩니다.)


자, 간단한 스크립트를 만들었다:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
using UnityEngine;
using System.Collections;
 
public class SeeFront : Enemy {
 
    float angle =45;
    public override void Start () {
        base.Start ();
    }
 
    public override void Update () {
        base.Update ();
    }
 
    void OnTriggerEnter(Collider other){
        if(other.gameObject.tag == "EnemyShip"){
            if(Vector3.Angle(other.gameObject.transform.position - _transform.position, transform.forward) <= angle){
                SeeFront sc = other.GetComponent<SeeFront>();
                velocity = sc.velocity;
            }
        }
    }
}

우리가 해야하는 것은 교통을 통제하는 것이다. 이 같은 경우에, 하나의 우주선은 뒤에 있는 다른 우주선과 충돌한다. 그리고 45도 각도보다 작은지를 확인한 다음, 다른 우주선의 속도를 얻어서 자기 자신에게 적용할 것이다. 나는 그들에게 미사일을 발사하는 능력을 주지 않았지만, 당신은 지금쯤이면 어떻게 해야하는지 반드시 알아야 한다.

이것은 SimCity 또는 GTA같은 교통 시뮬레이션 게임에서 사용될 수 있다.

결론

드디어 이 튜토리얼의 끝에 도달했다. 바라건대, 우리는 당신의 게임에 적용할 수 있는 약간의 팁을 배웠다. 우리는 단위원으로 기본적인 삼각법과, sin과 cos을 통해서 어떻게 그 특징들을 사용하는지를 살펴봤다. 우리는 화려한 타원모양과 원 모양을 만들어 봤다. 그리고 두 벡터간의 각도와 두 점간의 거리를 구하는 벡터연산 배웠다. 우리는 또한 상속, override, virtual 키워드, 다형성 같은 기본적인 객체 지향 프로그래밍을 살펴봤다.

이제 지금까지 본 다양한 특징들을 혼합하고, 많은 행동을 가지고 있는 NPC를 만드는 것은 당신에게 달렸다. AI는 CPU에 많은 비용이 든다. 아마 매 초마다 계산하기 위해서는 InvokeRepeting같은 쉬운 것을 원할 지도 모른다.

대체로, 나는 이것이 유용하고, 더 개선되기를 바란다.