Game AI

Behavior Tree (3) - Parallel

tsyang 2022. 8. 30. 01:50

이전 글 : 2022.08.27 - [Game AI] - Behavior Tree (2) - 확률, Decorator, 리소스 보호

 

Behavior Tree (2) - 확률, Decorator, 리소스 보호

이전 글 : 2022.08.21 - [Game AI] - Behavior Tree (1) - 기초 Behavior Tree (1) - 기초 2021.12.27 - [Game AI] - 의사 결정 - State machines 의사 결정 - State machines 2021.10.21 - [Game AI] - 의사 결정..

tsyang.tistory.com

 

 

Parallel Task


Parallel Task는 Composite Task의 한 종류이다. Parallel은 Sequence와 유사하다. Child Task들을 가지고 있고, 그 중 하나가 실패할때 까지 그들을 수행한다. 

 

Sequence랑 다른 점이 있다면 Parallel은 모든 child를 동시에 수행한다는 점이다. child마다 하나의 스레드를 생성하고 한 번에 돌리는걸 생각해볼 수 있다. (이건 그냥 일단 그렇게 동작한다고 생각하자는 거지 실제로 스레드를 매번 만들라는게 아님. 그러면 느림. 추후에 scheduling 파트에서 다룰것임)

 

만약 child중 하나가 실패하면 Parallel은 나머지 child task들을 전부 중지시킬 것이다. 그러나 그냥 다짜고짜 child 스레드들을 중지하는 방법은 리소스 해제와 같은 이슈때문에 문제의 소지가 많다.

 

그렇기 때문에 중간에 flag를 하나 두고 자식 스레드들이 flag를 매번 체크하며 알아서 안전하게 종료될 수 있도록 해야한다.

public abstract class Task
{
    //성공 혹은 실패를 반환
    public abstract bool Run();

    //스레드가 죽기 전에 리소스를 해제
    public virtual void Terminate() { }
}
public class Parallel : Task
{
    private Task[] _children;
    private HashSet<Task> _runningChildren = new HashSet<Task> ();

    //Run() 메서드의 최종 결과
    private Result _result;
    
    public override bool Run()
    {
        _result = Result.None;

        foreach (Task child in _children)
        {
            Thread thread = new Thread(()=>RunChild(child));
            thread.Start();
        }

        while (_result == Result.None)
        {
            //결과가 나올때까지 루프 혹은 Sleep()
        }

        return _result == Result.Success;
    }

    private void RunChild(Task child)
    {
        _runningChildren.Add(child);
        bool returned = child.Run();
        _runningChildren.Remove(child);

        if (false == returned)
        {
            _result = Result.Fail;
            Terminate();
        }
        else if (_runningChildren.Count == 0)
            _result = Result.Success;
    }

    public override void Terminate()
    {
        foreach(Task child in _runningChildren)
            child.Terminate();
    }
}

 

Run()메서드에서 결과를 기다리는 반복문 안은 사용하는 언어에 따라 단순히 루프를 돌거나 Thread.Sleep()을 호출하는 것보다 효율적인 방법이 많이 있을태니 찾아보는 게 좋다.

 

이외에도 필요에 따라 다양한 Parallel Task를 정의해서 쓰면 된다.

 

Parallel은 언제쓰나?


동시에 여러 일을 할 때 :

ex) 지원 요청을 하면서 적에게 총을 쏘는 경우


집단의 행동 :

ex) 탱커, 딜러, 힐러로 구성된 팀의 행동. 


시간과 리소스를 사용하는 여러 조건을 동시에 테스트 할 때 :

조건 중 하나가 실패하면 즉시 다른 조건들을 테스트하는걸 멈추고 쓰레드를 종료함. Sequence로 여러 조건을 테스트하는 것과 유사하지만 어떤 테스트가 빨리 끝날 지 알 수 없을 때 유용하다.

 

행동을 수행하는 도중 조건을 체크할 때 :
'지정된 위치에 있으면 컴퓨터를 사용해라' 같은 경우 아래와 같이 Sequence를 사용할 수 있다.

 

그러나 위와 같은 방식은 컴퓨터를 사용하는 도중 자리에 있는지 체크를 할 수 없다는 단점이 있다. 이를 위해 Parallel Task를 쓸 수 있다.

 

 

이런 방법은 State Machine의 동작방식과 꽤 비슷하다. 그러나 비직관적이다. 

 

또한 위와 같은 방법에는 문제가 하나 있는데 ... 그것은 바로 위 Parallel Task가 True를 리턴할 수 없다는 것이다.

 

컴퓨터 사용이 끝나서 True를 리턴했다고 하더라도 플레이어가 자리에 있는 한 조건을 체크하는 쓰레드는 절대 리턴하지 않는다. 그렇다고 컴퓨터 사용이 끝나면 False를 리턴한다? 그러면 Parallel의 상위 Task에서는 이 Parallel이 컴퓨터를 다 쓴건지 아닌지를 판단할 수 없게 된다.

 

 

태스크끼리 상호작용


위에서 다룬 문제를 해결하기 위해 Decorator로 Interrupter라는 것을 만들어서 Task끼리 서로 소통할 수 있게 할 수 있다. 

 

class Interrupter : Decorator
{
    private bool _isRunning;
    private Result _result;

    public override bool Run()
    {
        _result = Result.None;

        Thread thread = new Thread(()=>RunChild(child));
        thread.Start();

        while (_result == Result.None)
        {
            //결과가 나올때까지 루프 혹은 Sleep()
        }

        return _result == Result.Success;
    }

    private void RunChild(Task child)
    {
        _isRunning = true;
        _result = child.Run() ? Result.Success : Result.Fail;
        _isRunning = false;
    }

    public override void Terminate()
    {
        if(_isRunning)
            child.Terminate();
    }

    //외부에서 값을 넣어준다. 자연스레 Run()에서 돌고 있는 루프가 끝나고 리턴하게 된다.
    public void SetResult(bool desiredResult)
    {
        _result = desiredResult ? Result.Success : Result.Fail;
    }
}
    //일단 수행이 되면 연결된 interrupter에 정해진 값을 넣는다.
    class PerformInterruption : Task
    {
        private Interrupter _interrupter;
        private bool _desiredResult;
        
        public override bool Run()
        {
            _interrupter.SetResult(_desiredResult);
            return true;
        }
    }

 

 

 

 

이렇게 되면 컴퓨터 사용이 완료되는 순간 Interruption이 수행되고, 조건을 체크하는 스레드가 True를 리턴함으로써 Parallel 태스크가 모든 하위 동작들을 수행하여 True를 반환할 수 있게 된다.

 

이런 식으로 Decorator와 Action을 이용하여 태스크끼리 상호작용이 가능하다. 그러나 이런 방식도 여전히 한계가 있으며 어떤 동작들은 더 복잡한 상호작용 방식이 필요할 수 있다.


참고 : Ian Millington, AI for GAMES 3rd edition, CRC press, [355~364p]