Game Development

Unity'de Behaviour Tree Nasıl Kodlanır?

Bu yazıda oyun geliştirmede önemli tasarım desenlerinden biri olan behaviour tree (davranış ağacı) kavramından bahsedeceğiz ve Unity içerisinde örnek bir behaviour tree kodlayacağız.

Selim Şahin

Full-Stack Developer

Günlük hayattaki davranışlarımızı, rutinlerimizi somut bir modele dönüştürmek isteseydik acaba nasıl görünürdü?

Eminim durup dururken kimsenin aklına böyle bir soru gelmemiştir (yani eğer delirmek üzere değilseniz!). Peki, geliştirdiğimiz oyundaki karakterin yaşam döngüsünü nasıl kurgularız? Belli bir davranış kümesini nasıl modelleriz? Bu yazıda bunu yapmanın bir yönteminden bahsedeceğiz.

Anahtar kelimemiz Behaviour Tree yani Davranış Ağacı

Behaviour Tree'ler ilk olarak oyunlarda görülmeye başlamış olsalar da sonraları robotik, bilgisayar bilimleri ve yapay zeka alanlarında sıklıkla kullanılmıştır.

 

Meraklısı için Behaviour Tree ve FSM arasındaki farklar

BT’leri Finite State Machine’lere nazaran ön plana çıkaran nokta, behaviour tree'lerin çok daha modüler ve ölçeklenebilir olmasıdır. FSM’de ise durum pek böyle sayılmaz. Karakter davranışlarındaki çeşitlilik arttıkça üstesinden gelmesi daha da zor bir yapıyla karşılaşmaya başlarız. FSM’lerde kullanılan durumlar (yani state’ler) birbirlerinden izole yapıdadır. Davranış ağaçlarında ise ağacın düğümleri arasında bilgi akışı sağlama olanağımız vardır.

  character.PNG

 

Souls-like Karakterimizin Davranış Ağacı

Yazının makul bir uzunlukta olması için sahne kurulumları ve animasyonlara değinmeyeceğim. Başlamadan önce Github adresinden projenin kaynak kodlarına erişebilir ve daha detaylı inceleyebilirsiniz.

github.com/selimsahindev/behaviour-tree-unity-demo

Karanlıklar içindeki karakterimizin sahneye yerleştirdiğimiz rünler arasında devriye gezmesini istiyoruz. Sonraki aşamada karşısına çıkan düşmanı tespit edebilmesi ve saldırabilmesi için davranış ağacına eklemeler yapacağız ⚔️

 

Behaviour Tree

İlk etapta farklı ağaçlar oluştururken kullanacağımız jenerik ağaç yapımızı oluşturalım.

 

“Node” Sınıfı

Adım adım sınıfımızdaki alanları ve metodları yazmaya başlayalım.

namespace BehaviorTree
{
  public enum NodeState { RUNNING, SUCCESS, FAILURE }
  
  public class Node
  {
    // [Kasıtlı olarak boş bırakılmıştır. ]
  }
}

Düğümümüzün parent ve child’larına referanslarla birlikle, davranış düğümümüzün durum bilgisini saklamak için bir state alanı ekledik.

public class Node
{
    protected NodeState state;
    
    public Node parent;
    protected List<Node> children = new List<Node>();
}

Constructor içerisinde ve her bir child düğümü listeye ekliyoruz.

public class Node
{
    public Node parent;
    protected List<Node> children = new List<Node>();

    protected NodeState state;

    public Node()
    {
        parent = null;
    }

    public Node(List<Node> children)
    {
        foreach (Node child in children)
        {
            Attach(child);
        }
    }

    private void Attach(Node node)
    {
        node.parent = this;
        children.Add(node);
    }
}

Evaluate() metodumuzu tanımlayalım. Bu sınıftan türetilen davranış düğümlerinin her biri, virtual tanımlı Evaluate() metodunu override ederek kendi özgün davranış mantıklarını çalıştıracaklar.

public class Node
{
    // ...

    public virtual NodeState Evaluate() => NodeState.FAILURE;
}

Ağaç içerisinde veri akışı sağlayabildiğimizden bahsetmiştik. Verileri anahtar-değer ikilileri halinde tutmak için bir Dictionary’den yararlancağız. Ağacın düğümlerini dolaşarak veriyi döndüren GetData ve veri ekleyen SetData metodlarını yazıyoruz.

public class Node
{
    // ...
    
    private Dictionary<string, object> dataContext = new Dictionary<string, object>();

    // ...
    
    public void SetData(string key, object value)
    {
        dataContext[key] = value;
    }

    public object GetData(string key)
    {
        object val = null;
        if (dataContext.TryGetValue(key, out val))
        {
            return val;
        }

        Node node = parent;
        if (node != null)
        {
            val = node.GetData(key);
        }

        return val;
    }
}

Unutmadan veriyi silmek için:

public class Node
{
    // ...
    public bool ClearData(string key)
    {
        bool cleared = false;
        if (dataContext.ContainsKey(key))
        {
            dataContext.Remove(key);
            return true;
        }

        Node node = parent;
        if (node != null)
        {
            cleared = node.ClearData(key);
        }

        return cleared;
    }
}

 

“Tree” Sınıfı

Tree sınıfı, oluşturacağımız spesifik bir karakterin ya da varlığın davranışlarını yürüten tüm ağaçların kalıtıldığı sınıftır. Burada daha sonra child class’lardan override edeceğimiz SetupTree ile oyun başında ağacı tanımlayacak ve Evaluate metodu ile ağacın yaşam döngüsünü her frame’de kontrol edip çalıştıracağız.

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

namespace BehaviorTree
{
    public abstract class Tree : MonoBehaviour
    {
        private Node root = null;

        protected void Start()
        {
            root = SetupTree();
        }

        private void Update()
        {
            if (root != null)
            {
                root.Evaluate();
            }
        }

        protected abstract Node SetupTree();
    }
}

 

Yardımcı Düğümler

Bu bölümde ağaçtaki hangi düğümleri ne koşullarda çalıştıracağımızı belli bir mantık çerçevesinde tanımlamak için işimize yarayacak iki yardımcı düğüm sınıfı tanımlayacağız. Bunlar, Selector (seçici) ve Sequence (sekans ya da sıra) düğümler.

 

Sequence (Sekans, Sıra Düğümü)

Sequence, bir AND kapısı gibi çalışıyor diyebiliriz. Yani yalnızca ona bağlı tüm child düğümler başarılı sonuç döndürürse o da başarılı olduğu sonucunu kendi bağlı olduğu düğüme döndürecektir.

using System.Collections.Generic;

namespace BehaviorTree
{
    public class Sequence : Node
    {
        public Sequence() : base() { }
        public Sequence(List<Node> children) : base(children) { }

        public override NodeState Evaluate()
        {
            bool anyChildIsRunning = false;

            foreach (Node node in children)
            {
                switch (node.Evaluate())
                {
                    case NodeState.FAILURE:
                        state = NodeState.FAILURE;
                        return state;
                    case NodeState.SUCCESS:
                        continue;
                    case NodeState.RUNNING:
                        anyChildIsRunning = true;
                        continue;
                    default:
                        state = NodeState.SUCCESS;
                        return state;
                }
            }

            state = anyChildIsRunning ? NodeState.RUNNING : NodeState.SUCCESS;
            return state;
        }
    }
}

 

Selector (Seçici Düğüm)

Selector, OR kapısı gibi çalışır. Sequence sınıfı ile çok benzediğini görebilirsiniz. Burada farklı olan tek şey, kendisine bağlı olan düğümleri çalıştırırken devam etmekte olan ya da başarılı değer döndüren ilk düğümle karşılaştığı anda kendisi de başarılı değeri döndürecektir.

using System.Collections.Generic;

namespace BehaviorTree
{
    public class Selector : Node
    {
        public Selector() : base() { }
        public Selector(List<Node> children) : base(children) { }

        public override NodeState Evaluate()
        {
            foreach (Node node in children)
            {
                switch (node.Evaluate())
                {
                    case NodeState.FAILURE:
                        continue;
                    case NodeState.SUCCESS:
                        state = NodeState.SUCCESS;
                        return state;
                    case NodeState.RUNNING:
                        state = NodeState.RUNNING;
                        return state;
                    default:
                        continue;
                }
            }

            state = NodeState.FAILURE;
            return state;
        }
    }
}

 

Karakterimizin Davranış Ağacı

 

Yazının çok uzamaması adına SlayerBT ismini verdiğimiz (karakterimizin ismi Slayer olduğu için bu adı tercih ettik) ağacın kullanıma hazır halini paylaşarak ağacı oluşturan farklı davranış node’larını açıklamalarıyla birlikte özetleyeceğim.

Projeyi bu repo’dan indirip daha detaylı inceleyebileceğinizi ve denemeler yapabileceğinizi hatırlatayım:

github.com/selimsahindev/behaviour-tree-unity-demo

 

patrol.gif

 

Buradaki waypoints dizisi karakterimizin dolaşacağı lokasyonları belirtmek için kullanıldı. Inspector’den bu alanı doldurduğumuzdan emin olalım.

SetupTree metodunu override ederek ilk davranışımızı ağaca ekleyelim:

using UnityEngine;
using BehaviorTree;
using Tree = BehaviorTree.Tree;

public class SlayerBT : Tree
{
    public Transform[] waypoints;
    public static float walkSpeed = 2f;

    protected override Node SetupTree()
    {
        Node root = new TaskPatrol(transform, waypoints);
        return root;
    }
}

 

“TaskPatrol.cs”

Constructor’a ilettiğimiz waypoints dizisinde bulunan konumları sırasıyla, aralarda rastgele sürelerle bekleyerek dolaşmayı sağlayan ilk task’imizin görünüşü bu şekilde.

using UnityEngine;
using BehaviorTree;

public class TaskPatrol : Node
{
    private Transform transform;
    private Animator animator;
    private Transform[] waypoints;

    private int currentWaypointIndex = 0;

    private float waitTime = 1f; // Saniye cinsinden
    private float waitCounter = 0f;
    private bool isWaiting = true;

    public TaskPatrol(Transform transform, Transform[] waypoints)
    {
        this.transform = transform;
        animator = transform.GetComponent<Animator>();
        this.waypoints = waypoints;
    }

    public override NodeState Evaluate()
    {
        if (isWaiting)
        {
            waitCounter += Time.deltaTime;
            if (waitCounter >= waitTime)
            {
                isWaiting = false;
                animator.SetBool("IsWalking", true);
            }
        }
        else
        {
            Transform wp = waypoints[currentWaypointIndex];
            
            // Karakterin orijinal Y pozisyonunu muhafaza edelim.
            Vector3 targetPos = wp.position;
            targetPos.y = transform.position.y;

            if (Vector3.Distance(transform.position, targetPos) < 0.01f)
            {
                // Bekleme zamanlayıcısını sıfırla ve rastgele
                // yeni bir bekleme süresi belirle.
                waitTime = Random.Range(3f, 6f);
                waitCounter = 0f;
                isWaiting = true;

                currentWaypointIndex = (currentWaypointIndex + 1) % waypoints.Length;
                animator.SetBool("IsWalking", false);
            }
            else
            {
                // Hareketi ve rotasyonu güncelle.
                transform.position = Vector3.MoveTowards(
                    transform.position,
                    targetPos,
                    SlayerBT.walkSpeed * Time.deltaTime
                );
                
                transform.LookAt(targetPos);
            }
        }

        state = NodeState.RUNNING;
        return state;
    }
}

 

Ağacı Genişletelim

Karakterimizin davranış ağacına birkaç ekleme yapalım. SlayerBT sınıfına runSpeed (koşma hızı), fovRange (düşmanı görme mesafesi) ve attackRange (karakterin saldırabileceği uzaklık) gibi yeni değişkenler tanımladık. Ayrıca SetupTree içerisinde yeni ekleyeceğimiz davranış düğümlerinden birer örnek oluşturarak ağaç yapımıza dahil ettik.

"SlayerBT.cs"

using System.Collections.Generic;
using UnityEngine;
using BehaviorTree;
using Tree = BehaviorTree.Tree;

public class SlayerBT : Tree
{
    public Transform[] waypoints;

    public static float walkSpeed = 2f;
    public static float runSpeed = 3f;
    public static float fovRange = 6f;
    public static float attackRange = 2f;

    protected override Node SetupTree()
    {
        Node root = new Selector(new List<Node> {
            new Sequence(new List<Node> {
                new CheckEnemyInAttackRange(transform),
                new TaskAttack(transform),
            }),
            new Sequence(new List<Node> {
                new CheckEnemyInFOVRange(transform),
                new TaskGoToTarget(transform),
            }),
            new TaskPatrol(transform, waypoints),
        });

        return root;
    }
}

“CheckEnemyInAttackRange.cs”

using UnityEngine;
using BehaviorTree;

public class CheckEnemyInAttackRange : Node
{
    private Transform transform;
    private Animator animator;

    public CheckEnemyInAttackRange(Transform transform)
    {
        this.transform = transform;
        animator = transform.GetComponent<Animator>();
    }

    public override NodeState Evaluate()
    {
        // Ağaç üzerinde target anahtarına atanan değeri ara.
        object t = GetData("target");

        // Eğer herhangi bir target yoksa FAILURE durumunu döndür.
        if (t == null)
        {
            state = NodeState.FAILURE;
            return state;
        }

        // Veriler ağaçta object tipinde tutulduğundan
        // bulduğumuz değeri Transform tipine cast ediyoruz.
        Transform target = (Transform)t;
        float distance = Vector3.Distance(transform.position, target.position);

        // Eğer karakter hedefe yeterince yakınsa animasyonları
        // düzenle ve SUCCESS durumunu döndür.
        if (distance <= SlayerBT.attackRange)
        {
            animator.SetBool("IsAttacking", true);
            animator.SetBool("IsRunning", false);

            state = NodeState.SUCCESS;
            return state;
        }

        // Yukarıdaki koşullardan hiçbiri karşılanmadıysa 
        // FAILURE durumunu döndür.
        state = NodeState.FAILURE;
        return state;
    }

}

 

"TaskAttack.cs”

using UnityEngine;
using BehaviorTree;

public class TaskAttack : Node
{
    private Animator animator;

    private Transform lastTarget;
    private EnemyManager enemyManager;

    private float attackTime = 1f;
    private float attackCounter = 0f;

    public TaskAttack(Transform transform)
    {
        animator = transform.GetComponent<Animator>();
    }

    public override NodeState Evaluate()
    {
        // Target anahtarına sahip veriyi bul.
        Transform target = (Transform)GetData("target");

        // Eğer target null değilse EnemyManager komponentini al,
        // lastTarget referansını target değeri olarak güncelle
        if (target != lastTarget)
        {
            enemyManager = target.GetComponent<EnemyManager>();
            lastTarget = target;
        }

        attackCounter += Time.deltaTime;

        // Periyodik olarak saldırmak için attackCounter değerini kontrol et.
        if (attackCounter >= attackTime)
        {
            // Düşmana hasar vermek için TakeHit metodunu çağır.
            bool enemyIsDead = enemyManager.TakeHit();

            if (enemyIsDead)
            {
                // Eğer düşmenı etkisiz hale getirmişsek animasyonları güncelle,
                // ağaçtaki target datasını temizle.
                ClearData("target");
                animator.SetBool("IsAttacking", false);
                animator.SetBool("IsWalking", true);
            }
            else
            {
                attackCounter = 0f;
            }
        }

        // Düğüm çalışmaya devam ediyorsa RUNNING durumunu döndür.
        state = NodeState.RUNNING;
        return state;
    }

}

 

“CheckEnemyInFOVRange.cs”

using UnityEngine;
using BehaviorTree;

public class CheckEnemyInFOVRange : Node
{
    private static int enemyLayerMask = 1 << 6; // "Enemy" Katmanı

    private Transform transform;
    private Animator animator;

    public CheckEnemyInFOVRange(Transform transform)
    {
        this.transform = transform;
        animator = transform.GetComponent<Animator>();
    }

    public override NodeState Evaluate()
    {
        object t = GetData("target");

        if (t == null)
        {
            // Enemy katmanındaki objeleri tespit etmek için,
            // Physics.OverlapSphere metodunu kullanıyoruz.
            Collider[] colliders = Physics.OverlapSphere(
                transform.position,
                SlayerBT.fovRange,
                enemyLayerMask
            );

            // En az bir enemy objesi varsa target verisini güncelliyoruz,
            // animasyonları oynatıyoruz ve SUCCESS durumunu döndürüyoruz.
            if (colliders.Length > 0)
            {
                parent.parent.SetData("target", colliders[0].transform);
                animator.SetBool("IsWalking", false);
                animator.SetBool("IsRunning", true);
                state = NodeState.SUCCESS;
                return state;
            }

            // Belli mesafede bir düşman yoksa FAILURE durumunu döndür.
            state = NodeState.FAILURE;
            return state;
        }

        // Halihazırda bir target verisi varsa SUCCESS döndür.
        state = NodeState.SUCCESS;
        return state;
    }

}

 

Burada kullandığımız “Enemy” katmanını Unity içerisindeki Layer penceresinden seçmeyi unutmayalım.

 

“TaskGoToTarget.cs”

Bu script ağaçtaki target verisine erişerek ona doğru sabit koşma hızında hareket etme işini gerçekleştirir.

using UnityEngine;
using BehaviorTree;

public class TaskGoToTarget : Node
{
    private Transform transform;

    public TaskGoToTarget(Transform transform)
    {
        this.transform = transform;
    }

    public override NodeState Evaluate()
    {
        Transform target = (Transform)GetData("target");

        if (Vector3.Distance(transform.position, target.position) > 0.01f)
        {
            transform.position = Vector3.MoveTowards(
                    transform.position,
                    target.position,
                    SlayerBT.runSpeed * Time.deltaTime);
            
            transform.LookAt(target.position);
        }

        state = NodeState.RUNNING;
        return state;
    }

}

Bu eklemelerle birlikte karakterimiz, rünler arasında devriye gezdiği esnada görüş menziline bir düşman girer girmez ona doğru koşmaya ve yeteri kadar yaklaştığında da saldırmaya başlıyor.

attack-short.gif

 

Sonuç

Behaviour Tree'ler, tasarladığımız oyunun dünyasında kendi yaşam döngülerine, kendi rutinlerine sahip ve çevreyle benzersiz etkileşimlerde bulunan varlıklar oluşturmada ölçeklemesi ve yönetmesi kolay, anlaşılır bir yol sunuyor. Bir oyunu tamamlamanın onlarca yolu olsa da bu gibi tasarım desenlerinden istifade etmek, ilerleyen aşamalarda aşılması güç sorunlardan uzak kalmak ve daha yüksek kaliteli işler çıkarabilmek için oldukça önemli.

Bu yazıyı yazmak için ilham veren bir Unity kanalını aşağıya ekliyorum. youtube.com/@minapecheux

Teknolojik gelişmeleri daha yakından izlemek etmek için Fabrikod'u takip edebilirsiniz.

We are the partners you’ve been searching for.

Tell us about your project.