Behavioral Patterns I: Command

Bir diğer tasarım deseni olarak bugün bahsetmek istediğim Command tasarım deseni. Bu sık sık bloglarda, çeşitli yerlerde ”Bir oyun geliştiricisinin bilmesi gereken 5 tasarım deseninden birisi” denilen yazılım tasarım desenlerinden birisi. En basit bir şekilde açıklamaya çalışacak olursak, Command design pattern tam olarak ne? Neye çözüm getiriyor? Command’dan bahsedildiğinde ilk olarak akla gelen, her zaman…

Bir diğer tasarım deseni olarak bugün bahsetmek istediğim Command tasarım deseni. Bu sık sık bloglarda, çeşitli yerlerde ”Bir oyun geliştiricisinin bilmesi gereken 5 tasarım deseninden birisi” denilen yazılım tasarım desenlerinden birisi.

En basit bir şekilde açıklamaya çalışacak olursak, Command design pattern tam olarak ne? Neye çözüm getiriyor?

Command’dan bahsedildiğinde ilk olarak akla gelen, her zaman örnek verildiği üzere butonlar oluyor zira bu tasarım deseni birbirinden farklı işlevi olan ancak neticesinde hepsinin bir buton olduğu gibi farklı işlevdeki aynı objeleri yönetmekte oldukça başarılı bir çözüm üretiyor.

Konuyu biraz daha oyun geliştirme açısından ele alırsak, diyelim ki birden fazla mekanik içeren bir oyun yapıyorsunuz. Oyuncunun hem koşması, ateş etmesi, zıplaması, eğilmesi gerekiyor ve bunların hepsini klavyede farklı bir tuşa bağladınız. W’ya bastığında ileri gidecek, S’ye bastığında geri; Space’e tıkladığında zıplayacak, CTRL ile eğilecek diyelim.

Oldukça temiz bir kod yazdığımızı hayal etsek bile ilk etapta gözümde canlanan görüntü bir Update içerisine gidip If-else bloğu eklemekti, değil mi? Yalan söylemeye gerek yok, en basit görünen yolu bu.

public class PlayerMovement : MonoBehaviour
{
    void Update()
    {
        if (Input.GetKey(KeyCode.W))
            Debug.Log("Forward movement");
        if (Input.GetKey(KeyCode.S))
            Debug.Log("Backward movement");
        if (Input.GetKey(KeyCode.A))
            Debug.Log("Left movement");
        if (Input.GetKey(KeyCode.D))
            Debug.Log("Right movement");
        if (Input.GetMouseButton(0))
            Shoot();
        if (Input.GetKey(KeyCode.Space))
            Jump();
        if (Input.GetKey(KeyCode.LeftControl))
            Bend();
    }

    public void Shoot()
    {
        Debug.Log("Shoot");
    }

    public void Jump()
    {
        Debug.Log("Jump");
    }

    public void Bend()
    {
        Debug.Log("Bend")
    }
}

Bu şekilde bakıldığında bir kaç sorun hemen göze çarpıyor.

  1. Okunması zor.
  2. Yeni bir mekanik eklenmesi zor.
  3. Yeni bir mekanik eklendikçe bu if-else blokları uzayacak. Bu da, PlayerMovement class’ını bir monolit class’a çevirecek.
  4. Bakımı zor.

Burada devreye Command design pattern giriyor. Command şu şekilde işliyor:

Yukarıda yazmış olduğumuz Jump, Bend gibi mekanikleri Command olarak kapsüle edilmiş objelere dönüştürüyoruz.

Bir Command base class’ı oluşturuyoruz. Diğer tüm input’a bağlı oyun komutlarını bu şekilde encapsule edeceğiz.

 class Command
    {
        public virtual void Execute()
        {
        }
    }
    class JumpCommand: Command
    {
        public override void Execute()
        {
            Jump();
        }
        private void Jump()
        {
            Debug.Log("Jump");
        }
    }
    class ForwardCommand: Command
    {
        public override void Execute()
        {
            Forward();
        }
        private void Forward()
        {
            Debug.Log("Forward movement");
        }
    }
    class BendCommand : Command
    {
        public override void Execute()
        {
            Bend();
        }
        private void Bend()
        {
            Debug.Log("Bend");
        }
    }

PlayerMovement class’ını düzenlediğimizde de aşağıdaki gibi oluyor:

 private JumpCommand jumpCommand;
    private ForwardCommand forwardCommand;
    private BendCommand bendCommand;

    void Update()
    {
        if (Input.GetKey(KeyCode.W)) forwardCommand.Execute();
        if (Input.GetKey(KeyCode.Space)) jumpCommand.Execute();
        if (Input.GetKey(KeyCode.LeftControl)) bendCommand.Execute();
    }

Tamam biz bunu aktardık ama asıl sorun burada şu oluyor: Unity nasıl bilecek? Biz Unity içerisindeki karakterimize nasıl ulaşacağız da, onu hareket ettireceğiz kendi hareket fonksiyonlarımız içerisinde?

Command sınıfı içerisinde Execute fonksiyonuna Player’ın transformunu gönderebiliriz, bu şekilde erişilebilir olabilir.

using UnityEngine;

    class Command
    {
        public virtual void Execute(GameObject player)
        {

        }
    }

Şimdi tek tek diğer komutları da düzeltelim.

 class JumpCommand: Command
    {
        public override void Execute(GameObject player)
        {
            Jump();
        }
        private void Jump()
        {
            Debug.Log("Jump");
        }
    }

PlayerMovement class’ını da biraz düzeltelim. Yeni bir class oluşturalım ve Tüm Input’larımızı tutacak, bunu kontrol edecek bir class olsun. Buna da InputController diyelim ve içine Command return eden yeni bir metot yazalım. Update içerisinden genel inputlarımızı ayıklayalım. Böylelikle her input command’ının execute’ını çalıştırmaya çalışmayız.

InputController inputController;
GameObject playerObject;

    public PlayerMovement()
    {
        inputController = new InputController();
    }    


    void Update()
    {
        HandlerInput();
        Command command = HandlerInput();
        if (command != null)
        {
            command.Execute(playerObject);
        }
    }

    void HandlerInput()
    {
        if (Input.GetKey(KeyCode.W)) forwardCommand.Execute();
        if (Input.GetKey(KeyCode.Space)) jumpCommand.Execute();
        if (Input.GetKey(KeyCode.LeftControl)) bendCommand.Execute();
    }

Yeni oluşturduğumuz class ise şu şekilde olacak.

class InputController
{
    private JumpCommand jumpCommand;
    private ForwardCommand forwardCommand;
    private BendCommand bendCommand;

    public Command HandlerInput()
    {
        if (Input.GetKey(KeyCode.W)) return forwardCommand;
        if (Input.GetKey(KeyCode.Space)) return jumpCommand;
        if (Input.GetKey(KeyCode.LeftControl)) return bendCommand;
        return null;
    }
}

Bu yeni input sistemi sayesinde biz kendi hareket fonksiyonlarımızı istediğimiz game objesine uygulayabiliriz. Bu da bize esneklik sağlarken aynı zamanda daha temiz bir komut yapısı oluşturmamıza yardımcı olur.

Player için bu komut sistemi harika bir şekilde çalışır ancak peki oyundaki diğer objeler için yani doğrudan kullanıcının kendisini oynamadığı diğer objeler için de kullanabilir miyiz? Mesela oyun içi AI’lar için?

Burada aslında çok güzel bir plug-in oluşturmuş olduk. Şöyle, diyelim ki beş farklı AI hayal ettik ve beşinin de davranışı birbirinden farklı. Bu durumda tek yapmamız gereken, beş farklı Command tipi oluşturup bunlara AI objesini göndermek olacaktır. Oldukça basit ve kullanışlı.

Bir diğer önemli özelliği ise bu design pattern sayesinde kolayca yapılabilen ancak bunun dışında oyunlarda oldukça zor yapılan ”Undo” özelliği. Bu özellik bir önceki duruma geri dönüş yapabilmemizi sağlıyor. Bizim şu an yazmış olduğumuz kod bunu sağlamaz. Dolayısıyla kod üzerinde biraz oynama yapmamız gerekir.

Diyelim ki bir satranç oyunu yapıyoruz ve piyonlardan birisi mat üzerinde hareket ettirmek istiyoruz. Ancak daha sonra hatalı bir hamle yaptığımızı fark ettik, kendi sıramızı bitirmeden geri almak ve başka bir hamle yapmaya karar verdik. Bunu tam olarak bu pattern ile sağlayabiliriz.

Piyonun hareketi için bir Comman yazıyorum. Buna da PawnUnitMovement diyeceğim.

class PawnUnitMovement: Command
{
    private GameObject _actor;
    private Vector3 _direction;

   public PawnUnitMovement(GameObject actor, Vector3 direction)
    {
        _actor = actor;
        _direction = direction;
    }

    public override void Execute()
    {
        _actor.transform.position = _direction;
    }
}

Burada neden dışarıdan actor almadık? Burada her tuşa basıldığında hareket edilecek bir obje söz konusu değil, dolayısıyla süreklilik yok. Oyuncu ne zaman hareket ettirmeye karar verirse bir piyonu o zaman çalışacak. Spesifik bir actor olduğu için command içerisinde actor’u encapsule ettik.

    public Command HandlerInput()
    {
        GameObject actor = GetSelectedPawn();

        if (Input.GetKey(KeyCode.UpArrow))
        {
            float _y  = actor.transform.position.y + 1;
            return new PawnUnitMovement(actor, new Vector3(actor.transform.position.x, _y));
        }
        if (Input.GetKey(KeyCode.Space))
        {
            float _y = actor.transform.position.y + 2;
            return new PawnUnitMovement(actor, new Vector3(actor.transform.position.x, _y));
        }
        return null;
    }

Üstteki kod ne yapıyor?

Öncelikle GetSelectedPawn ile istediğimiz bir piyonu seçiyoruz. Seçmiş olduğumuz piyonu bir kez yukarı ok tuşuna basarsak tek bir kez yukarı çekiyoruz. Space tuşuna basarsak iki birim yukarı çekiyoruz.

Şimdi hareket etmeyi tamamladık, geri almaya bakalım. İleri piyonu ittik, geri alalım.

Command class’ımızı düzenliyoruz ve yeni bir metot ekliyoruz.

    class Command
    {
        public virtual void Execute()
        {
        }
        public virtual void Undo()
        {
        }
    }

Tabiki buna uygun bir şekilde custom ettiğimiz class’ı da düzenliyoruz.

class PawnUnitMovement: Command
{
    private GameObject _actor;
    private Vector3 _direction;
    private Vector3 _beforeDirection;

   public PawnUnitMovement(GameObject actor, Vector3 direction)
    {
        _actor = actor;
        _beforeDirection = Vector3.zero;
        _direction = direction;
    }

    public override void Execute()
    {
        _beforeDirection = _direction;
        _actor.transform.position = _direction;
    }

    public override void Undo()
    {
        _actor.transform.position = _beforeDirection;
    }
}

Constructor’ın içerisinde bir önceki direction’ı sıfırlama sebebimiz yeni bir state geldiğinde önceki durumun kalmasını istemememiz. Eğer state değiştirmiş isek, bir önceki durumuna dönmemeli. Yani diyelim bir tane piyon ileri hareket ettirdik. Kullanıcı CNTRL-Z’ye basmış ise elbette Undo metotu çalışmalı ama kullanıcı sonra piyonu bir başka istikamete ilerletmiş ya kendi sırasını bitirmiş ise o state temizlenmeli.

Genel olarak en basit haliyle Command tasarım deseni bu şekilde. Bu yolla bir çok farklı oyun input sistemi geliştirilebilir.

Okuduğunuz için teşekkürler.

Referans kaynak: http://itsozge.com/2021/04/behavioral-patterns-i-command/