İçeriğe geç
← STÜDYO NOTLARI

Oyun geliştirme

Mobil bulmaca oyununda seviye zorluğu nasıl ölçülür?

Screwsmith’in 200 seviyesini motor bağımsız bir kural motoru ve sahne açmayan bir çözücüyle nasıl ürettiğimiz, ölçtüğümüz ve insan kayıp oranına göre ayarladığımız.

Screwsmith arayüzü 1
Screwsmith arayüzü 2
Screwsmith — Projenin gerçek arayüzlerini ve kapsamını inceleyin ↗

KISA CEVAP

Mükemmel oyunla değil, makul bir insanın kayıp oranıyla. Screwsmith’te çözücü her seviyeyi 24 kez “eşleşen vidayı rastgele seç” stratejisiyle oynuyor; kaybedilen deneme oranı zorluk puanının ana bileşeni (×11). Ray doluluğu, hamle sayısı ve tek çözüm cezası boşlukları dolduruyor. 200 pişirilmiş seviyenin 157’si hedef banda tam isabet etti, 34’ü bir bant saptı.

Neden zorluğu mükemmel oyunla ölçmek yanıltıcı?

Screwsmith bir vida sökme bulmacası: erişilebilir bir vidaya dokunuyorsunuz, vida aynı renkteki açık kasaya ya da tutucu raya gidiyor (200 seviyenin 161’inde beş, 39’unda dört yuva). Üç vida bir kasayı doldurup mühürlüyor, kuyruktaki sonraki kasa geliyor. Vidası azalan parça oturup alttaki katmanı açıyor ya da tamamen ayrılıyor. Ray dolduğunda ve gelen vidanın gidecek yeri olmadığında seviye kaybediliyor.

İlk akla gelen ölçü “bu seviyeyi bitirmek için en az kaç ray yuvası gerekir” sorusu. Çözücümüz bunu da hesaplıyor; kodda 2 ısınma, 5 hiç pay bırakmayan seviye anlamına geliyor. Sorun şu: neredeyse her seviye mükemmel oyunla geçiliyor. Bu sayı, kasa kuyruğunu üç hamle önceden göremeyen bir oyuncu hakkında hiçbir şey söylemiyor.

Bu yüzden SolveReport iki ayrı sayı taşıyor: mükemmel oyun için MinRailSlotsRequired, insan oyunu için HumanFailRate ve onun yanında denemeler boyunca ulaşılan en kötü ray doluluğunun ortalaması AveragePeakRail. Zorluk eğrisi ikincisine göre ayarlanıyor. İlk sayı ise bir seviyenin bozuk mu yoksa sadece zor mu olduğunu ayırmak için kalıyor.

Kural motorunu Unity’den ayırmak ne kazandırdı?

Oyun mantığı Screwsmith.Core adlı ayrı bir assembly’de ve asmdef dosyasında noEngineReferences: true var. GameSim içinde tek bir UnityEngine tipi yok; konum bile kendi Vec3 yapımızla tutuluyor. LevelData da motor tipi içermiyor, yorum satırının dediği gibi “çözücü sahne açmadan binlerce adayı değerlendirebilsin” diye.

Rastgelelik System.Random değil, xorshift32: _state ^= _state << 13; _state ^= _state >> 17; _state ^= _state << 5;. Gerekçe kodda yazılı: her platformda ve her Unity sürümünde aynı çıktı, böylece bir seviye numarası her oyuncu için aynı tahta anlamına geliyor. Kasa kuyruğu da seviyenin tohumundan bu üreteçle karıştırılıyor.

Ayrımın üç somut getirisi oldu:

  • Seviye üretimi ve ölçümü -batchmode ile, editör açmadan komut satırından koşuyor.
  • Çözücünün derinlik öncelikli araması, oyundaki undo’yu kullanarak geri dönüyor; yani undo’yu test etmeden çözücüye güvenemiyoruz.
  • Günlük Tezgâh telefonda Task.Run ile ana iş parçacığı dışında üretiliyor. Core motora dokunmadığı için bu güvenli; ilk sürümde aynı iş satır içinde yapılınca uygulama düğmeye basmakla tahtanın gelmesi arasında donuyordu.

Çözücü bir seviyeyi adım adım nasıl ölçüyor?

LevelSolver.Solve ucuzdan pahalıya üç aşamada çalışıyor.

Önce kalıcı olarak gömülü vida aranıyor: engelleyen parçası ne bir sonraki poza oturabilen ne de ayrılabilen bir vida zor seviye değil, tasarım hatası. Bu kontrol ucuz olduğu için her şeyden önce koşuyor.

Sonra insan oyunu ölçülüyor. 24 deneme yapılıyor, her biri farklı tohumla (level.Seed + trial * 7919 + 13). Strateji bilinçli olarak sığ: erişilebilir ve kuralca geçerli vidalar arasından bir kasanın kabul edeceklerini tercih et, birden fazlaysa rastgele seç. Koddaki yorum: “Çoğu oyuncu bu kadarını yapar; neredeyse kimse daha ileriyi planlamaz.” Tek bir kazanılmış deneme, seviyenin bitirilebildiğinin kanıtı sayılıyor ve pahalı arama atlanıyor.

Yalnız 24 denemenin hepsi kaybedilirse derinlik öncelikli arama devreye giriyor: 1 yuvadan başlayıp yuva sayısını artırarak, 120.000 düğüm sınırıyla. Burada öğrendiğimiz bir ayrım var: düğüm sınırına takılmak “imkânsız” değil “kanıtlanamadı” demek. İkisini aynı saymak, iyi zor seviyeleri sessizce çöpe atıyordu; şimdi SearchExhausted ayrı bir bayrak.

Bir de durum anahtarı hatası yaşadık. Anahtar başta vida başına yalnız “söküldü mü” bilgisini tutuyordu. Paslı vidaya ilk dokunuş bu yüzden önceki durumla aynı anahtarı üretiyor, arama dalı terk ediyordu. Hiçbir paslı vida ikinci dokunuşa ulaşamadı ve insan denemesinin kaybettiği her paslı seviye pişirme sırasında çözülemez sayılıp atıldı. Artık her vida için üç karakter yazılıyor: söküldü mü, kaç dokunuş aldı, rengi biliniyor mu.

Zorluk puanı nasıl hesaplanıyor ve hangi aday seçiliyor?

DifficultyScore 1 ile 10 arasında bir tamsayı. Formül kısa:

  • taban 1
  • HumanFailRate * 11, baskın terim
  • max(0, AveragePeakRail - 1) * 1.6, hiç kaybedilmeyen iki seviyeyi ray baskısına göre sıralamak için
  • hamle sayısı 45’i geçerse +0,6
  • tek çözüm varsa +1,5

Çözülemeyen seviye doğrudan 10 alıyor. Paslı vida iki dokunuş istediği için hamle tahmini vida sayısı değil dokunuş sayısı; aksi halde tempo eğrisi paslı seviyeleri olduğundan kısa okuyordu.

Aday üretimi GenerateTuned içinde. Her hedef bant için 12 tahta biçimi deneniyor: bandın kendi biçimi, komşu bantlar ve farklı vida yoğunlukları. Pişirme aracı biçim başına 16 tohum kullanıyor, yani bir seviye için en fazla 192 aday. Arada bir düzeltme var: tohum bütçesi eskiden biçim sayısına bölünüyordu, yeni biçim eklemek diğerlerinin aramasını sessizce küçültüyordu. Şimdi tohumlar biçim başına sayılıyor.

Aynı zorluktaki iki aday eşit değil: rayı daha yükseğe iten kazanıyor. Gerekçe kodda: kolay bir seviye, kurtarma her zaman geldiği için kolay olmalı, ray boş durduğu için değil; boş ray görünmez bir oyun demek. Hedefe tam isabet eden ve ortalama ray tepesi en az 3 olan ilk aday aramayı durduruyor. Kazanan aday, gönderilmeden önce kanıt aramasıyla bir kez daha tam olarak doğrulanıyor.

Zorluk eğrisi: 35 rahat seviye, 36’da duvar, sonra yükselen taban

TargetFor her seviye numarasına bir hedef bant veriyor. 1–5 bant 1, 6–15 bant 2, 16–25 bant 3, 26–35 bant 4, seviye 36 ise bilinçli bir duvar: bant 8. Sonrası altı seviyelik bir ritim: taban üzerine -1, -1, 0, +2, +2, 0. İki kolay seviyede oyuncu para biriktiriyor, sıçramada harcıyor.

Ritim ilk sürümde çıplak bir döngüydü (3,3,4,7,7,4) ve 200’e kadar tekrarlanıyordu; 197. seviye 37. seviyenin istediğini istiyordu. Yetkin bir oyuncuya karşı ölçülen kazanma oranı dört elli seviyelik dilimde %94, %94, %88, %88 çıktı: oyun aslında elli seviyeydi ve dört kez oynanıyordu. Çözüm tabanı yükseltmek oldu: 4 + (seviye - 37) * 4 / 163, yani 37’de 4, 200’de 8.

Bantların kendisi de bir merdiven olmak zorundaydı ve değildi: bant 8 12 panelli, bant 7 20 panelli bir tahtaydı, yani “zorluk 8” “zorluk 7”den kolaydı. Şimdi sıra sabit: önce katman ve panel sayısı büyüyor (1 katmandan 4’e), sonra renk sayısı (3’ten 6’ya), en son ray daralıyor (5 yuvadan 4’e, en üst bantta 3’e).

Pişirilmiş setteki sonuçlar levels.json içinde: 1–35 arası ortalama insan kayıp oranı %0,8, seviye 36’da %37,5 (24 denemenin 9’u), 101–200 arasında %34. Sahada sürekli kaybeden oyuncu için ayrıca görünmez bir yardım var: aynı seviyede 3 kayıptan sonra en az kullanılan renk en yaygın renge çevriliyor. Bu değişiklik artık seviyenin bir kopyası üzerinde yapılıyor. İlk sürüm önbellekteki pişirilmiş nesneyi değiştiriyordu ve her denemede bir renk daha eriyordu.

Okunabilirlik neden zorluğun tavanı oldu?

Okunabilirlik kapısının bir yarısı insan sorusu (oyuncu ne olduğunu anladı mı?) ve onu ancak oyun testi yanıtlıyor. Diğer yarısı piksel gerçeği ve ReadabilityAudit bunları oyun testine hiç ulaştırmıyor.

Vuruş hedefi ekran genişliğinin en az %8,5’i, yani yaklaşık 4,7 mm. Apple ve Google’ın ~9 mm önerisinden küçük; gerekçe vidaların yuvarlak olması ve komşuların arasında durması, %8,5 ise yanlış dokunmanın hızla arttığı taban olarak seçildi. Ölçülen şey görünen vida başı değil çarpışma alanı, çünkü dokunuş raycast ile çözülüyor. Denetim tahtayı gerçek cihaz çözünürlüğünde bir RenderTexture’a çiziyor ve seviyeyi oynayarak her durumda tekrar ölçüyor; tahta parçalar oturdukça kalabalıklaşıyor, açılışta değil.

Bu denetim iki gerçek hata yakaladı. Seviye 178, ekran genişliğinin 0,084’ü büyüklüğünde üç vidayla gönderilmişti, sınır 0,085. Seviye 153’te iki üst yüz vidası tam üst üste duruyordu ve biri hiç dokunulamıyordu. İlki zorluk tasarımını değiştirdi: 24 panelli tahtalar ekrana okunaklı sığmadığı için en üst iki bant büyümeyi bırakıp rayı daraltıyor.

Renk tarafında CIE76 ΔE kullanılıyor: normal görüşte en az 11, kırmızı-yeşil renk körlüğü (Brettel/Viénot benzetimi) sonrası en az 9. Engellenmiş vida sönük çizildiği için palet sönük haliyle de ayrışmak zorunda. Altı renkte en yakın çift sönükken ΔE 19,7, parlakken 29,4; yedinci renk sönük değeri tabanın altına çekti. Altı renk tavanı bu yüzden zorluk değil okunabilirlik sınırı.

Undo denetimi neden kendini kırabildiğini kanıtlamak zorunda?

Undo, oyunda tornavida aletine bağlı ve sözleşmesi net: tahta, parçaların pozu dahil, hamle öncesine birebir dönmeli. Çözücü de geri dönüşlerinde aynı undo’yu kullandığı için buradaki bir hata seviye ölçümlerini de bozar.

Bir dönem bozuktu. Undo, bir hamlede çekilen kasaların etkin listenin son elemanları olduğunu varsayıp kasaları konuma göre geri koyuyordu. Bir hamlede çekilen kasa, raydan akan vidalarla aynı hamlenin sonraki turunda dolup mühürlenirse bu varsayım çöküyor: hamleden önce var olan bir kasa siliniyor, hiç var olmamış biri yerine geliyor ve kasa kimliği sayacı hâlâ kullanımda olan kimliklere geri sarıyordu. Kimse fark etmedi, çünkü hiçbir şey tahtayı hamle öncesiyle karşılaştırmıyordu. Düzeltme, hamle kaydına çekilen kasaların kimliklerini ve mühürlenen kasaların boşalttığı yuvaları yazmak oldu.

UndoAudit.Run bu karşılaştırmanın kendisi. 200 seviyenin her birinde 60 hamleye kadar rastgele geçerli hamle yapıyor, %15 olasılıkla anahtar aletini kuruyor ki cıvata yolu da test edilsin. Her hamlede hamle sayısı, vida, parça, kasa ve ray durumunu içeren bir anlık görüntü alıyor, hamleyi yapıp geri alıyor ve iki görüntüyü karşılaştırıyor.

Denetime güvenmeden önce kırılabildiğini görmek istedik. İki düğme var: GameSim.UseLegacyCrateUndo eski hatalı geri yüklemeyi açıyor, corruptSnapshot ise görüntüden kasa kimliklerini düşürüyor. Bozuk simülasyonda temiz rapor veren bir denetim yeterince durum görmüyor demektir.

Sunucusuz Günlük Tezgâh nasıl herkese aynı tahtayı veriyor?

Pişirilmiş 200 seviyenin dışında her gün tek bir “Günlük Tezgâh” var ve herkes aynı makineyi alıyor. Sunucu, hesap ya da indirme yok: tahta tarihten üretiliyor. Tezgâh numarası, oyunun kendi başlangıç tarihi olan 26 Ağustos 2026’dan (UTC) bu yana geçen gün sayısı artı bir. Makine 16 makine arasında numaraya göre dönüyor, böylece günlük tahta hep aynı makine olmuyor.

Baked değil generated olmasının nedeni kodda yazılı: pişirilmiş bir set her gün yeniden yayınlanmak zorunda kalırdı. Üretim deterministik olduğu için aynı tarih her cihazda aynı vidaları veriyor ve “23 hamle” gibi bir sonuç paylaşmaya değer bir sayı oluyor.

Risk de belli: şanssız bir tohum çözülemez bir tahtayı aynı anda herkese gönderir ve bunun etrafından dolaşmanın yolu yoktur. Bu yüzden akış şöyle:

  • Hedef bant sabit 5: gerçek bir bulmaca ama seviye 36 duvarı değil.
  • 12 farklı tohum deneniyor, kanıtla çözülebilir çıkan ilki kullanılıyor.
  • Hiçbiri tutmazsa bant 3, 2, 1 sırasıyla düşülüyor, her bantta 6 tohum, yine kanıtla.
  • Ancak en son çare kanıtsız, en kolay biçimde bir tahta.

Aynı mantık 200’ün ötesi için de geçerli. Orada canlı üretim yerine pişirilmiş setin ikinci yarısı dönüyor; döngü 128 seviye, yani 16 makinenin her birinde dört seviyeyle iki tur. Sebep, telefonda ana iş parçacığında saniyeler süren üretimin açılışta iOS tarafından uygulamanın kapatılmasına yol açabilmesi.

Benzer bir bulmaca oyunu kuracaksanız: kontrol listesi

Screwsmith’ten çıkardığımız, başka bir tür bulmacaya da taşınabilecek kurallar:

  • Kural motorunu motor bağımsız bir assembly’ye koyun ve bunu derleyiciye zorlatın (noEngineReferences). Ölçüm, üretim ve arka plan işleri buna dayanıyor.
  • Platformdan bağımsız, kendi yazdığınız bir rastgele sayı üreteci kullanın; seviye numarası herkes için aynı tahta olmalı.
  • Zorluğu iki sayıyla ölçün: mükemmel oyun bozuk seviyeyi yakalar, sığ stratejili insan benzetimi zorluğu ölçer. Eğriyi ikincisine göre ayarlayın.
  • Arama sınırına takılmayı “imkânsız” saymayın.
  • Durum anahtarına oyunu farklı oynatan her şeyi koyun; eksik bir alan bütün bir mekaniği sessizce yok edebilir.
  • Bantların gerçekten bir merdiven olduğunu doğrulayın ve eğrinin uzun vadede yükseldiğini dilim dilim kazanma oranıyla kontrol edin.
  • Ağır üretimi derlemeye taşıyın, oyuncuya ödetmeyin; pişirilmiş veriyi ölçümleriyle birlikte saklayın.
  • Dokunma hedefi, renk mesafesi ve HUD çakışmasını oyun testinden önce otomatik ölçün.
  • Undo için hamle öncesi ve geri alma sonrası tahtayı karşılaştıran bir denetim yazın ve bozuk bir sürüme karşı başarısız olduğunu görün.
  • Herkese aynı tahtayı veren her şeyi (günlük seviye gibi) göndermeden önce kanıtlayın.

AMG Yazılım olarak Unity ile bu tür veri odaklı, ölçülebilir mobil bulmaca oyunları geliştiriyoruz.

PROJENİZE UYARLAYALIM

Oyun Geliştirme

Unity 6 ile hyper ve hybrid casual mobil oyun. Level üretimi, ekonomi, LiveOps ve mağaza süreci dahil.