Pong – Devlog 6: Yapay Zeka Yapamayan Zeka


Projeye başlayalım dediğimiz günden beri tam 6 hafta geçmiş. Tolga’yı daha sanki dün aramış ve projeyi heyecanlı heyecanlı anlatmıştım. Çok garip duygular içindeyim. Haftaya, projeyi tamamlıyoruz! Bu hafta son devlogumu yazıyorum. Tabii ki proje kapsamında 2 yazı daha yazacağım ama …

Bu güzel yolculukta bana eşlik ettiğin için çok teşekkür ederim. Hem itchio hem de kendi sitemde yazılarımı her hafta yaklaşık 50 kişinin okuduğunu görmek… Ayırdığınız vakit, gösterdiğiniz ilgi için çok minnettarım hepinize. Umarım sonraki projelerimde de birlikte oluruz. Varlığınız bana güç ve motivasyon katar. Şimdi olduğu gibi!

Haftanın Özeti

Geçen hafta bahsettiğimiz gibi bu hafta tasarıma odaklandığımız bir hafta oldu. Tolga, bu hafta ilk seslerin implemantasyonunu yapmakla birlikte kapsamlı bir Sound Manager sınıfı oluşturdu. GUI testleri yapıp bug kontrolü yaptı. Ben leaderboarda son halini verdim, yapay zekayı biraz daha düzenledim. Tron esintili bölümün tasarımına başladım. Kamera ve post processing effectlerle oynadım. Bol miktarda shader denemesi yaptım (Bu projede ilk kez kendi shaderlarımı yazmaya başladım). Kızımın adını verdiğim bir “Hanne Games” logo ekranı hazırladım.

Önümüzdeki hafta Tolga Çöl bölümünü ve seslerin implemantasyonunu tamamlarken ben de power up’ların particle effectleri ve Tron bölümünü tamamlayacağım. Bununla birlikte UI hala ham halde, pazartesi günü birlikte ona odaklanacağız: Font seçimleri, arka plan görüntüleri, spritelar vs. Bu hafta sana 3 bölümümüzün ekran görüntülerini paylaşmak istiyorum. Ufak değişiklikler yapmayı planlıyor olsak bile büyük ölçüde içimize sindi bölümler.

UI kısmı açıkçası biraz kafamı kurcalıyor. Çünkü tema olarak birbirinden farklı 4 bölüm var ama işin sonunda hepsi aynı oyunun bir parçası. Önünümde her bölüm için ayrı bir UI tasarımı kullanmak ve tek genel bir UI tasarımınına odaklanmak seçenekleri mevcut. İlk seçenek ayrı ayrı bölümlerde güzel dursa da oyunun bir bütün olduğu hissini kıracak gibi duruyor. Daha çok birbirinden bağımsız 4 oyunu bir çatıda toplamışız gibi olacak. Ki bunu hiç istemiyorum. Ama genel bir UI tasarımı kurgulasam bu sefer de bölümlerdeki tema ile uyuşmayacak. Bu, sanırım kabul edeceğim bir fedakarlık olacak. Hala kafamda net bir karar yok ne yazıkki! Bakalım pazartesi ne karar vereceğiz.

Her zamanki gibi haftalık ayırdığımız süreyi sizinle paylaşmak istiyorum. Bunu niye eklediğimle alakalı bir soru aldım özelden. Belki sen de merak etmişsindir diye paylaşmak istiyorum: Haftaya yazımda maliyet ve kar hesaplarına girdiğimizde buradaki süreleri kullanarak oyunun başarısı için bir hedef belirleyeceğiz. Tabii ki bu proje ücretsiz olacak ama piyasada satıyor olsaydık durum ne olurdu onu konuşacağız. Bu ilk sebebim. İkincisi ileride siz de kendi projenizi yapmak istediğinizde elinizde referans alacağınız (basit de olsa) bir örnek olmasını istiyorum. Ne yazıkki Türk oyun geliştiricileri bu tarz konularda çok ketüm. Yurt dışında bir çok örnek var bu konulara değinen. Ama bizim paylaşım kültürümüz biraz sıkıntılı. Hele para meseleleri… Aman aman! Bu konu cinsellikten de daha büyük bir tabu Türkiye’de. Fark ettiğin üzere ben tarafımı çoktan seçtim. İstiyorum ki hevesli Türk indie oyun geliştiricilerilerine gücüm yettiğince destek olayım. O yüzden paylaşıyorum. Gelecekteki ticari projelerimde de bunu yapmayı hedefliyorum.


HaftaDokümantasyon (Blog, GDD vs)ToplantıGeliştirme (Ömer – Tolga)Toplam
Hafta 1
8 sa 30 dk 8.5 sa
Hafta 2
2.5 sa 1 sa 2 sa – 30 dk 6 sa
Hafta 3
3 sa 2.5 sa 1.5 sa – 2.5 sa 9.5 sa
Hafta 4
3 sa 4.5 sa 11.5 sa – 2 sa 21 sa
Hafta 5
3 sa 3.5 sa 4.5 sa – 2.5 sa 13.5 sa
Hafta 6
3 sa 1.5 sa 5 sa – 3 sa 12.5 sa
Toplam22.5 sa 13.5 sa
35 sa
71 sa

Bu haftaki toplantımız yarıda kaldı. Başka işlerimiz çıkınca pazartesi gününe erteledik. Json serializer ile alakalı yaşadığımız bir sorun olmuştu. Kısaca ondan bahsedip asıl konumuza girmek istiyorum. Json kayıtlarını oluştururken buna özel bir sınıf oluşturmamız gerektiğini hep unuturum nedense. Bu hafta da leaderboardtaki kayıtlarda bu sorunu yaşadık. Verileri serileştirirken manager sınıfları içinde listeler oluşturup bunu json formatında kaydedip açmak istedik. Fakat beklediğin gibi hata verdi. Bunu çözmek için leaderboard verilerini içeren basit bir sınıf oluşturduk:

 public class Leaderboard
 {
     public List<LeaderBoardBase> scoreEntryList;
 }

Serilizasyon işlerimiz için bu sınıfı referans aldık hep. Örnek metodu aşağıda paylaşayım:

     public void GetLeaderBoard()
     {
         if (PlayerPrefs.HasKey(GameManager.leaderBoardPref))
         {
             string jsonString = PlayerPrefs.GetString(GameManager.leaderBoardPref);
             leaderboard = JsonUtility.FromJson<Leaderboard>(jsonString);
             scoreEntryList = leaderboard.scoreEntryList;
  ...
         }
     }

Burada gördüğün gibi JsonUtility.FromJson metodunda List<LeaderBoardBase> parametresini direkt almadık. Leaderboard.cs sınıfını aldık ve o sınıftan listeye ulaştık. Sen de xaml ve json formatında verileri kaydedeceğin zaman bu detaya dikkat etmeyi unutma. Hadi konumuza başlayalım.

Yapay Zeka 101

Yapay zeka hakkında binlerce makale ve blog yazısı mevcut. Orada teknik detaylar ve sınıflandırmalar hakkında benim seninle burada paylaşabileceğimden çok çok daha fazlasını bulabilirsin. Kaldı ki bu alan benim de öğrencisi olduğum bir alan. Burada ben daha çok bu oyunda uyguladığımız basit bir AI kodlaması üzerinden genel AI hakkında bilmen gereken temel şeylerin bir kaçını paylaşacağım.

Oyun açısından baktığımızda yapay zeka (YZ) kavramı genel olarak iki ana gruba ayırılıyor. Bunlar deterministik ve non-deterministik YZ.

Deterministik YZ tahmin edilebilir ve spesifik bir perfomans gösterir. Standart bir değerlendirme mekanizmasına sahiptir ve sonunda belli bir tepki verir. Yıllardır oyunlarda gördüğümüz bu YZ implemantasyonu kolay olsa bile uygulanacağı senaryoların tasarımı gayet zordur. Oyunun sunduğu aktivite/karar verme imkanı arttıkça bu YZ’nın tasarlanması da zorlaşmaya başlar. Çünkü ön göremediğin bir senaryoda YZ beklenen insani tepkiyi veremez. Saçmalar veya kilitlenir. Oyunlarda gördüğümüz aptal düşmanlar bunun eseridir aslında. Fakat yine de yaygın bir şekilde kullanılır. Bizim Pong oynumuzda olduğu gibi..

Non-Deterministik YZ ise aksine öğrenen bir yapıya sahiptir. Her öğrenim sürecinde de verdiği tepki değişir. Burada Bayesian teknikleri, derin öğrenme, nöral network, genetik algoritm gibi bir çok teknik kullanılır. Ama işin sonunda öğrenme yeteneği de tasarımıyla sınırlıdır. Total war, civilization serilerinde ülkelerin davranışları mesela bu metodlar ile kurgulanmıştır. Tabii, kusursuz değillerdir ama hayranlık bırakırlar yine de.

Kurgulanması nasıl olursa olsun, YZ’nın genel basamakları ve çıktıları birbirlerine çok benzer aslında. Bütün yapay zekalar;

  • Önce bir uyarı veya veriyi algılarlar,
  • Bu veriye göre karar verme mekanizmalarını kullanırlar,
  • Verilen kararı uygularlar.

Yapay zekaları birbirinden ayıran şeyde burada tasarlanan karar verme mekanizmalarıdır. Verdikleri karar açısından da navigasyon ciddi bir yer tutar. Optimal path, Path nodes, navmesh kavramlarını sıklıkla ilgili makalelerde görürsün.

Pong oyunumuzda da çok temel bir düzeyde navigasyon kararları verdirdik YZ’mıza. Buna ek olarak power up kullanımda da bu tekniklerden kısıtlı bir şekilde faydalandık. Burada tam anlamıyla bir karar verme mekanizması kurgulamadık. Çünkü gerek yoktu. Ama diğer projelerde bu deterministik modellerden bir kaçını (FSM, behavior tree, GOAP vb.) kullanacağız. İleride kendime non deterministik bir YZ’ya sahip oyun yapma hedefini çoktan verdim bile.

Kodlara geçmeden önce bahsetmek istediğim son şey ise randomizasyon ve olasılık. Bunlar, YZların hata yapmasını sağlamak ve oyuncuya her oyununda farklı bir tecrübe sunmak için sıklıkla kullanılır. Biz de oyunumuzda bunu bolca kullandık. Hatta oyundaki farklı YZ zorluklarını da temelde olasılık üzerinden kurguladık. Bunları aşağıda net bir şekilde göreceksin.

Algılama

Doğal olarak YZ’nın bir bilinci yok. Oyundaki gelişmeleri gerçek bir insan gibi takip etmesi imkansız. Tabii bir algılama sistemi kurgulamazsan. İşe başlamadan önce algılama sistemini tasarlarken üzerine düşünmen gereken bir kaç soru var:

  1. YZ hangi veri / bilgileri algılayacak?
  2. YZ bu verileri ne kadar sıklıkta algılayacak? Aralıksız bir veri akışı mı olacak yoksa bu akış belli durumlarda başlayıp kesilecek mi?
  3. YZ bu verileri hangi doğruluk / keskinlikte algılayacak?
  4. YZ bu verileri nasıl algılayacak?
  5. YZ’nın takip ettiği bu veri YZ için ne anlama geliyor? Buradan ne çıktı sağlanacak?

Bizim oyunumuzda YZ’nın bilmesi gereken 2 bilgi mevcuttu. Birincisi topun bulunduğu konum; ikincisi sahip olduğu power uplar.

Top için veri akışını, topun YZ yönünde ilerlediği zaman başlatarak aksi yönde ise keserek sağladık. Power uplar kısmını ise her 3 sn’de bir power up slotunu taramasıyla sınırladık. Her iki veriyi de %100 doğrulukta almasını istedik. Zorluk düzeyleri ile ilgili hesaplamaları bundan sonraki aşamada yani karar verme aşamasında düzenledik.

Power up kullanımı için kendine ayrılan slotları taramak adına PowerupManager sınıfı ile iletişim kurmasını sağlarken top için farklı bir metod kullandık. Onu biraz açalım. Bölüm açıldığında oyun alanın biraz dışında (12,0,0 ve -12,0,0) bir düzlem yarattık. Top YZ yönünde ilerlemeye başladığında o yöne doğru sürekli RayCast yaparak Z düzlemindeki pozisyonunu aldık ve bu bilgileri YZ’nın algılamasını sağladık.

public class BallManager : MonoBehaviour
{
 ...
     public PlayerLine movementDirection;
     public Vector3 sideTarget;
     public Plane rightPlane, leftPlane;
 ...
     void Start()
     {
 ...
         rightPlane = new Plane(Vector3.left, new Vector3(-12f, 0f, 0f));
         leftPlane = new Plane(Vector3.right, new Vector3(12f, 0f, 0f));
 ...
     }
     void Update()
     {
         GetTargetInfo();
     }
     void GetTargetInfo()
     {
         Vector3 direction;
         Plane basePlane;
         if (rb.velocity.x > 0)
         {
             movementDirection = PlayerLine.right;
             direction = Vector3.right;
             basePlane = leftPlane;
          }
         else
         {
             movementDirection = PlayerLine.left;
             direction = Vector3.left;
             basePlane = rightPlane;
         }
  ...
         float enter = 0f;
         Ray ray = new Ray(transform.position, direction);
         if (basePlane.Raycast(ray, out enter))
         {
             sideTarget = ray.GetPoint(enter);
         }
     }
}

Yukarıda BallManager sınıfında algılama ile ilgili bu kodları paylaştım. Gördüğünüz gibi her update sekansında topun olduğu yerden gittiği yöne doğru bir ışın gönderiyor ve ışın sonucunda Z pozisyonun yerini sideTarget şeklinde kaydediyoruz. Burada bizim için önemli olan vektörün z parametresi yoksa işin sonunda Plane‘nin bulunduğu X noktası da kaydedilmiş oluyor. Debug yaparken (doğru yere ışın gidiyor mu diye) bizim çok işimize yaradı bu Vector3 değişkeni.

Son olarak aldığımız bu veri bizim ne işimize yarayacak kısmına geldik. Top için durum aslında basit buradaki z pozisyonunu YZ’nın gideceği hedef noktayı belirlemek için kullanıyoruz. Power up’da ise kullanıp kullanmayacağımıza karar vermede kullanıyoruz.

Karar Alma Ve Uygulama

Veriler geldikten sonra YZ bir karar vermek durumunda. Topun verilerinden gideceği nihai hedefi belirleme kararı verirken power up’da varsa-kullan kararı veriyor. Aslında ikincisi daha çok bir refleks. Gerçek anlamda bir karar verme durumu yok. Ama ikisini de ele alacağız. Önce top!

public class PlayerManager : MonoBehaviour 
{
     public Vector3 movement;
     public bool isBallContacted;
     float marginValue = 0;
     void Update()
     {
         if (lm.isInputEnabled)
         {
             GetMovementInput();
             ActivatePowerUp();
         }
     }
     void FixedUpdate()
     {
         movement = movement * reactionRate * Time.fixedDeltaTime;
         rb.MovePosition(new Vector3(transform.position.x, transform.position.y,
            Mathf.Clamp(transform.position.z + movement.z, -currentBorder, currentBorder)));
     }
     void GetMovementInput()
     {
 ...
             else
             {
                 if (isBallContacted)
                 {
                     marginValue = MarginOfError(playerType);
                     isBallContacted = false;
                      if (inputModifier == -1)
                     {
                         marginValue *= 1.5f;
                     }
                 }
                 if (player.line == ballObject.GetComponent<BallManager>().movementDirection)
                 {
                     movement = new Vector3(
                         transform.position.x, transform.position.y,
                         (ballObject.GetComponent<BallManager>().sideTarget.z - transform.position.z + marginValue));
                 }
                 else
                 {
                     movement = new Vector3(transform.position.x, transform.position.y, (0f - transform.position.z + marginValue));
                 }
             }
             movement.Normalize();
         }
     }
     float MarginOfError(PlayerType type)
     {
         float value;
         switch (type)
         {
             case PlayerType.aiEasy:
                 value = Random.Range(-0.6f, 0.6f);
                 break;
             case PlayerType.aiNormal:
                 value = Random.Range(-0.5f, 0.5f);
                 break;
             case PlayerType.aiHard:
                 value = Random.Range(-0.4f, 0.4f);
                 break;
             default:
                 value = 0;
                 break;
         }
         return value;
     }
 } 

Burada klasik hareket metodlarını Update ve FixedUpdate‘de görüyorsunuz zaten. Bizim odaklanacağımız nokta aslında GetMovementInput() ve MarginOfError() metodları bunları inceleyelim.

GetMovementInput: Oyuncu tarafında bu bilgi klavye inputları ile gelirken YZ’da bu bilgiler topun pozisyonundan geliyor. Aslında yukarıda gördüğünüz gibi topun RayCast göndermesi her saniye olurken YZ sadece kendi yönündeki verileri algılayabiliyor:

if (player.line == ballObject.GetComponent<BallManager>().movementDirection)

Buna göre birazdan bahsedeceğimiz bir hata payıyla birlikte topun geldiği yöne pivotunu ilerletmeye başlıyor. Eğer top diğer yönde ise ortaya geri dönüyor. Buradaki basit karar alma mekanizması aslında tüm oyun YZ’larında ortak olan şey. Misal bir ork’un görüş alanına gelen oyuncuya doğru koşması, oyuncu kaçtığında / görüş alanından uzaklaştığında eski pozisyonuna geri dönmesi tamamen aynı mantık!

Burada ek olarak isBallcontacted booluna değinmek istiyorum. Tecrübeliler bunu zaten anlayacaktır ama kısaca bahsedersek: Malum her Update döngüsünde GetMovementInput methodu çağrılıyor. Bununla birlikte her seferinde MarginOfError metodu da çağrılmış oluyor. Bu her seferinde topun hata yapma olasılığını yeniden hesaplamak demek. Bunu her topa çarptığında bir defa çağırmadığımızda bu sefer YZ bir aşağı bir yukarı hareket etmeye başlıyor. Çünkü topun varacağı Z noktası belli olsa da hata payı değiştiği için nihai hedef de değişmiş oluyor. Bunu engellemek için biz her topa değdiğinde bir sonraki hata payını hesaplatıp bir sonraki topa değişine kadar bunu sabit tuttuk. Bu sayedede objenin hareketi düzelmiş oldu.

MarginOfError: Bu ise YZ’nın bilinçli olarak hata yapmasını istediğimiz metod. Gördüğünüz gibi zorluk seviyesi arttıkça hata payı da azalıyor. Hiç hata yapmasa zaten topun olduğu yere gider ve kusursuz bir oyun çıkarırdı. Buradaki rakamlar tamamen deneme yanılma yöntemiyle ortaya çıktı. Başka bir oyun/bölüm tasarımında tamamen değişecektir.

Gelelim power up’ımıza… Dediğim gibi burası daha çok bir refleks. Ama gayet tatmin edici sonuçlar verdi. Malum her Update metodunda ActivatePowerUp() metodunu çağırıyoruz. Hadi bu metodu biraz inceleyelim.

     void ActivatePowerUp()
     {
 ...
         else
         {
             if (Time.time > nextActiontime)
             {
                 nextActiontime = Time.time + powerUpPeriod;
                  if (player.line == PlayerLine.left)
                 {
                     UsePowerUps(pm.leftFirstPowerUp, pm.leftSecondPowerUp, 0);
                 }
                 if (player.line == PlayerLine.right)
                 {
                     UsePowerUps(pm.rightFirstPowerUp, pm.rightSecondPowerUp, 2);
                 }
             }
         }
     }

Hareket metoduna benzer bir şekilde oyuncu ile ilgili inputlar klavyeden geliyor. Onları çıkardım yukarıda. Burada yaptığımız YZ için şu oldu: Her 3 snde bir (powerUpPeriod) power up’ımız var mı diye PowerUpManager‘ı sorguladık. Power up varsa; önce ilk slotu o yoksa ikinci slotu UsePowerUp metodu ile kullandık. Burada metoddaki ikinci parametre tamamen enum indeksindeki yerlerini tespit etmek için ekledik. Doğal olarak birinci slotun indeksi 0 iken üçüncü slotun indeksi 2.

     void UsePowerUps(PowerUpBase first, PowerUpBase second, int addedValue)
     {
         float randomValue = UnityEngine.Random.Range(0f, 2f);
         if (randomValue < 1)
         {
             if (first != null)
             {
                 pm.ActivatePowerUp((PowerUpIndex)addedValue);
                 return;
             }
             if (second != null)
             {
                 addedValue++;
                 pm.ActivatePowerUp((PowerUpIndex)addedValue);
                 return;
             }
         }
     }

UsePowerUp metodu basit bir şekilde var olan power up’ı kullanan bir metod. Burada yine randomizasyon kullanarak olaya biraz çeşni kattık. Artık yapay zeka slotu her dolu gördüğünde kullanmıyor. %50 ihtimalle kullanıyor. Mekanizma çok basit olmakla birlikte oyunumuzda istediğimiz etkiyi fazlasıyla veriyor.

Gördüğü gibi aslında başlıkta ipucu verdiğim gibi yapay zeka değil, yapamayan bir zeka kodlaması yapmış olduk. Burada yapay zekanın en temel metodlarını kullandık ve bu oyun için bize fazlasıyla yetti! Burada gördüklerinden sonra sana verebileceğim en önemli tavsiye ise randomizasyon ve olasılığın gücünü sakın küçümseme. Oyununa biraz renk katmak için birebirler. Her ne kadar bu projemizde bu implemantasyon yetmiş olsa bile ileriki projelerimizde çok daha kompleks metodlarına ihtiyaç duyacağız. Şimdiden heyecanla onlara başlamayı bekliyorum. Sen de partiyi kaçırma, tamam mı?

Haftaya, mali meseleleri ve oyun yayınlamayı konuşacağız. Sonrasında son bir postmortem yazısı ile bu projeyi tamamlamış olacağız. Benim için çok eğlenceli bir yolculuktu bu. Benimle olduğun için tekrar tekrar tekrar teşekkür ederim. Bir sonraki görüşmemize kadar muhteşem bir hafta geçirmen temennilerimle…

Get Just Another Game: Pong

Leave a comment

Log in with itch.io to leave a comment.