İçeriğe geç
Teknik YazıOyun

Dungeon Mates: Her Kat Farklı, Hiçbiri Bozuk Değil

Prosedürel zindan üretmek kolay. Zor olan, üretilen zindanın gezilebilir olduğundan emin olmak. BSP ile nasıl çözdüğüme ve yol boyunca neyi patlattığıma dair notlar.

14 Şubat 20265 dk okuma

İlk çalışan sürümde arkadaşımla oyuna girdik ve iki dakika boyunca bir koridorda dönüp durduk. Harita üretilmişti, odalar vardı, canavarlar vardı — ama merdivenin bulunduğu odaya giden hiçbir yol yoktu. Rastgeleliğin sorunu tam olarak bu: çoğu zaman çalışıyor.

Dungeon Mates tarayıcıda çalışan, 2-4 kişilik kooperatif bir zindan oyunu. Her kat yeniden üretiliyor. Bu yazı, "her seferinde farklı" ile "her seferinde oynanabilir" arasındaki gerilimi nasıl çözdüğüme dair.

Neden Tamamen Rastgele Olmuyor

İlk yaklaşımım şuydu: rastgele yerlere rastgele boyutta dikdörtgenler koy, sonra hepsini birbirine bağla. İki sorun çıktı.

  • Odalar üst üste biniyordu. Çakışma kontrolü ekledim; bu sefer 40 denemede yerleştirilemeyen odalar oldu ve harita boş kaldı.
  • Bağlantı grafiği garanti bağlı değildi. İki oda kümesi oluşup birbirine hiç bağlanmayabiliyordu — yukarıdaki koridor hikâyesi tam olarak buydu.

BSP bu iki sorunu birden çözüyor. Alanı ikiye böl, her yarıyı yine ikiye böl, en alttaki parçalara birer oda koy. Odalar aynı parçanın içinde olduğu için asla çakışmıyor. Ve bağlarken ağacın kendisini takip ediyorsun, yani bağlılık yapıdan geliyor.

Bölme Yönünü Kim Seçiyor

Yönü tamamen rastgele seçince haritada 4×60 boyutunda koridor gibi parçalar çıkıyor. İçine oda sığmıyor. Çözüm basit: parçanın oranına bak, uzun kenarı böl.

server/dungeon/DungeonGenerator.ts
private splitNode(node: BSPNode, depth: number): void {
  if (depth > 5) return

  const canSplitH = node.height >= this.minBspSize * 2
  const canSplitV = node.width  >= this.minBspSize * 2
  if (!canSplitH && !canSplitV) return

  let splitHorizontally: boolean
  if (canSplitH && canSplitV) {
    // Kare değilse uzun kenarı böl; kareyse yazı tura.
    splitHorizontally =
      node.height > node.width ? true
      : node.width > node.height ? false
      : Math.random() > 0.5
  } else {
    splitHorizontally = canSplitH
  }
  // ...
}

Buradaki `depth > 5` sınırı deneme yanılmayla oturdu. 7'de harita hücre gibi görünüyordu — çok fazla küçük oda, hepsi birbirinin aynı. 4'te ise oda sayısı yetersizdi. 5 iyi bir denge verdi.

Bir de `minBspSize` var ve tanımı ilginç:

server/dungeon/DungeonGenerator.ts
// Oda maksimum boyutu + 4: bir parçaya oda konduğunda kenarlarda
// koridorların geçebileceği en az 2'şer birim boşluk kalsın.
this.minBspSize = this.roomMaxSize + 4

Bu satır olmadan odalar parçanın kenarına yapışıyordu ve koridorlar duvarların içinden geçmek zorunda kalıyordu. Zindan "yanlış" görünüyordu ama nedenini uzun süre bulamadım.

Oyuncu Sayısı Haritayı Değiştiriyor

Tek kişilik oyunda 72×72'lik bir harita çok büyük — dakikalarca boş koridorda yürüyorsun. Dört kişide 48×48 çok küçük, herkes birbirinin üstünde. Bu yüzden harita boyutu oyuncu sayısına bağlı.

OyuncuHaritaOda boyutu
148 × 486 – 11
256 × 567 – 12
364 × 647 – 13
472 × 728 – 14

Kat numarası da ayrı bir katman: yukarı çıktıkça oda sayısı, canavar canı ve saldırı gücü artıyor. Patronlar 3, 5, 7, 8 ve 10. katlarda. Bu sayıları bir tabloya yazdım ve oynadıkça birkaç kere değiştirdim — özellikle 4. kat uzun süre kolaydı.

Fazla Oda Üretince Ne Oluyor

Bu bölüm koddaki en sinsi hatanın hikâyesi. BSP ağacı bazen hedeflenenden fazla oda üretiyor. Fazlalıkları listeden çıkarmak yetmedi.

server/dungeon/DungeonGenerator.ts
// Fazla odaları listeden çıkarmak YETMİYOR: tile'lar zaten açılmıştı.
// Sadece diziden silince haritada sahipsiz boşluklar kalıyor —
// oyuncu oraya girebiliyor ama orası hiçbir odanın parçası değil.
for (let i = this.rooms.length - 1; i >= targetMax; i--) {
  this.uncarveRoom(this.rooms[i])
}

Semptom şuydu: bazı oyunlarda haritada canavarsız, eşyasız, çıkışsız bir boşluk oluyordu. Oyuncular oraya düşünce "burası bug mı" diye soruyordu. Evet, bug'dı. Açılan alanı geri kapatan bir fonksiyon yazmak zorunda kaldım.

Koridorlar

Bağlama işi ağacın iç düğümlerinde yapılıyor: sol alt ağacın bir odasıyla sağ alt ağacın bir odasını birleştir. Kök düğüme kadar çıkınca bütün harita bağlanmış oluyor.

Koridorların şekli L biçimli — önce yatay, sonra dikey (ya da tersi). Diagonal denedim, çok daha hoş görünüyordu ama çarpışma kontrolü ve görüş hattı hesabı iki katına çıktı. Geri aldım.

İpucu

L biçimli koridorda hangi kolun önce çizildiğini rastgele seçmek, tek satırlık bir değişiklik ama haritanın karakterini belirgin şekilde çeşitlendiriyor. Aynı oda çiftini bağlayan iki farklı yol çıkıyor.

Haritayı Kim Üretiyor

Bu, çok oyunculu tarafta pazarlık edilemez bir karar. Harita sunucuda üretiliyor ve herkese aynı sonuç gönderiliyor.

Sadece seed'i paylaşıp herkesin kendi haritasını üretmesi cazip geliyor — ağ trafiği neredeyse sıfır. Ama `Math.random()` uygulamaları arasında fark yaratabiliyor ve daha kötüsü, istemcinin elinde tüm haritanın olması demek: nerede patron var, nerede sandık var, hepsi belli.

Sunucu haritayı üretir, oyuncular yalnızca gördükleri kadarını bilir. Bu kural olmadan keşif diye bir şey kalmıyor.

Rakamlarla

Dungeon Mates · harita üretimi

643
satırlık üretici
5
maksimum BSP derinliği
10
kat, her biri farklı ayarlı
5
patron katı
4
oyuncuya kadar

Geriye Dönüp Bakınca

BSP'yi seçtiğim için memnunum. Ama bugün başlasam üretimden hemen sonra bir doğrulama adımı koyardım: her odadan merdivene ulaşılabiliyor mu diye basit bir dolaşma. Ağaç yapısı bunu garanti ediyor teorik olarak, ama "uncarve" hatasında gördüğüm gibi teori kodun tamamını kapsamıyor.

Hâlâ emin olmadığım şey oda boyutlarının oyuncu sayısına bağlanması. Mantıklı geliyor ama tek kişilik oyun ile dört kişilik oyun artık farklı hissettiriyor — aynı oyun olmalı mıydı, bilmiyorum.