GameMaker oyununu tarayıcıya taşımak: GML çizimini Canvas 2D'ye çevirmek
Portföyümdeki Inkway ve Shadow Lock önce GameMaker'da Android için yazıldı. Inkway'de parmağınızla yol çiziyorsunuz ve bir mürekkep topu üzerinden yuvarlanıyor. Shadow Lock'ta dönerek düşen bir şekli gölgesiyle aynı açıya geldiği anda yuvasına oturtuyorsunuz. İkisini de sonradan tarayıcıya taşıdım: TypeScript, Canvas 2D ve Web Audio, oyun motoru ya da çizim kütüphanesi olmadan.
Hedefim iki sürümün birebir aynı davranmasıydı. Bu yüzden web kodu, GML kodunu satır satır takip edecek şekilde yazıldı. Orijinal proje de aynı depoda duruyor, bir fonksiyonun iki hâli yan yana açılıp karşılaştırılabiliyor. Aşağıda yolda karşıma çıkan asıl sorunları ve nasıl çözdüğümü anlatıyorum.
Önce GML'ye benzeyen ince bir çizim katmanı
GML'de çizim durum tabanlı çalışır: önce draw_set_colour ve draw_set_alpha ile rengi ayarlarsınız, sonra draw_circle ya da draw_line_width çağırırsınız. Canvas 2D de durum tabanlı, ama isimler ve parametreler farklı. Her çağrıyı yerinde çevirmek yerine src/engine/gfx.ts içinde küçük bir Gfx sınıfı yazdım. setColour, setAlpha, circle, roundrect, sprite gibi metotları var, böylece oyun kodu GML'ye çok yakın okunuyor. Inkway'de dosyanın tamamı 165 satır.
Çevirirken dikkat etmeniz gereken küçük farklar var:
| GML | Canvas 2D karşılığı | Dikkat |
|---|---|---|
draw_ellipse(x1, y1, x2, y2) | ctx.ellipse(cx, cy, rx, ry, …) | GML sınır kutusu alır, merkez ve yarıçapa çevirin |
draw_roundrect_ext(…, xr, yr) | ctx.roundRect(x, y, w, h, r) | GML'deki köşe değeri çap, Canvas yarıçap ister: ikiye bölün |
draw_sprite_ext(…, rot, col, a) | translate, rotate, scale, drawImage | GML'de açı derece ve saat yönünün tersine: rotate(-rot * Math.PI / 180) |
draw_sprite_tiled | createPattern(img, "repeat") | Deseni önbelleğe alın, her karede oluşturmayın |
dsin / dcos | Math.sin(a * Math.PI / 180) | Tüm açıları derece bırakıp yardımcı fonksiyon yazmak daha az hata çıkarır |
Renkler de GML'deki gibi paketlenmiş sayı (0xRRGGBB) olarak tutuluyor ve çizim anında rgba(...) metnine çeviren tek bir yardımcı var. merge_colour gibi fonksiyonlar sayılar üzerinde birebir aynı çalışıyor.
Köşe renkli primitifler
GameMaker'da draw_primitive_begin ile üçgen şeridi çizip her köşeye ayrı renk ve saydamlık verebilirsiniz. GPU aradaki renkleri kendisi karıştırır. Canvas 2D'de böyle bir şey yok: bir yolu tek bir fillStyle ile doldurursunuz. Inkway'deki yağmur bölgesi GML'de şöyle çiziliyor:
draw_primitive_begin(pr_trianglestrip);
draw_vertex_colour(rz[0], 60, global.col_sky, 0.32);
draw_vertex_colour(rz[1], 60, global.col_sky, 0.32);
draw_vertex_colour(rz[0], INK_H, global.col_sky, 0.08);
draw_vertex_colour(rz[1], INK_H, global.col_sky, 0.08);
draw_primitive_end();
Dikkatli bakınca renk yalnızca yukarıdan aşağıya değişiyor. Bu durumda aynı görüntüyü doğrusal gradyanla elde edebilirsiniz:
const gr = ctx.createLinearGradient(0, 60, 0, INK_H);
gr.addColorStop(0, css(COL.sky, 0.32));
gr.addColorStop(1, css(COL.sky, 0.08));
ctx.fillStyle = gr;
ctx.fillRect(rz[0], 60, rz[1] - rz[0], INK_H - 60);
Kural şöyle çıktı: tepeden tabana renk değiştiren şeritler (tepeler, yağmur, dikenler, mürekkep göstergesi) doğrusal gradyana, merkezden kenara renk değiştiren yelpazeler (top, damla, yıldız, kalp) radyal gradyana dönüşüyor. Shadow Lock'taki kabartma kenarlar da her kenar için ayrı bir doğrusal gradyanla çiziliyor.
Bu yöntem, rengin tek bir eksen boyunca değiştiği durumlarda işe yarıyor. Bir üçgenin üç köşesine üç farklı renk verdiyseniz Canvas 2D bunu birebir çizemez. Öyle bir durumda ya görüntüyü bir kez dokuya çizip saklarsınız ya da o kısım için WebGL'e geçersiniz. İki oyunda da buna gerek kalmadı.
Sprite'lar ve karışım modları
Köşe renklerinden sonra en çok uğraştıran iki konu, sprite'ların renklendirilmesi ve ışık efektleri için kullanılan karışım modu oldu.
Renkle çarpılan sprite'lar
draw_sprite_ext son parametre olarak bir renk alır ve dokunun her pikselini bu renkle çarpar. Beyaz bir dokuyu kırmızıyla çizerseniz kırmızı olur. Canvas'ta drawImage için böyle bir parametre yok. Çözüm, her doku ve renk çifti için bir kez boyanmış kopya üretip saklamak:
cv.width = t.w; cv.height = t.h;
const x = cv.getContext("2d")!;
x.drawImage(t.img, 0, 0);
x.globalCompositeOperation = "multiply";
x.fillStyle = css(col);
x.fillRect(0, 0, t.w, t.h);
x.globalCompositeOperation = "destination-in";
x.drawImage(t.img, 0, 0);
multiply rengi çarpıyor, ama saydam bölgeleri de boyuyor. Son adımdaki destination-in görüntüyü dokunun kendi alfa kanalıyla kırpıp saydamlığı geri getiriyor. Beyaz renk geldiğinde hiç kopya üretilmiyor, orijinal doku kullanılıyor.
Bir uyarı: rengi her karede değişen bir sprite'ınız varsa (yavaşça kızaran bir uyarı ikonu gibi) bu önbellek sınırsız büyür. O durumda rengi birkaç kademeye yuvarlayın ya da çarpma işlemini çizim anında multiply ile yapın.
Shadow Lock'taki dokulu parçalar biraz daha karmaşık. GameMaker'da her şekil, malzeme dokusu köşe koordinatlarına gerilmiş bir üçgen yelpazesi. Web'de şeklin kendisi kırpma alanı yapıldı ve doku aynı UV eşlemesiyle (0.5 + x · 0.47) döndürülüp ölçeklenerek içine çizildi. Ortası açık, kenarı koyu renk geçişi de üstüne multiply ile eklendi.
Toplamalı karışım
GameMaker'daki gpu_set_blendmode(bm_add), çizilen rengi alttaki renge ekler. Işıltılar, halkalar ve ışık hüzmeleri böyle çizilir. Canvas'ta karşılığı globalCompositeOperation = "lighter" ve sonuç birebir aynı. Shadow Lock'un Gfx sınıfında bunu tek satırlık bir metoda bağladım:
additive(on: boolean) {
this.ctx.globalCompositeOperation = on ? "lighter" : "source-over";
}
GML'de olduğu gibi burada da işiniz bitince normale dönmeyi unutmayın. Unutursanız sonraki her şey parlamaya başlar ve hatanın nereden geldiğini bulmak zaman alır.
Ekran ve zaman
Inkway 1280×720, Shadow Lock 720×1280 bir "oda" üzerinde çalışıyor. Oyun kodu yalnızca bu koordinatları biliyor. Ekrana sığdırma işi tek bir dönüşümle yapılıyor: ölçek Math.min(w / ODA_W, h / ODA_H), kalan boşluk iki yana eşit bölünüyor. Tuval gerçek piksel sayısıyla (innerWidth × devicePixelRatio, oran en fazla 2) oluşturuluyor ve setTransform(scale * dpr, 0, 0, scale * dpr, offX * dpr, offY * dpr) ile çiziliyor. Böylece yüksek çözünürlüklü telefonlarda yazılar bulanıklaşmıyor. Dokunma koordinatları da tersine çevriliyor: (clientX - offX) / scale.
Zaman tarafında orijinal kod delta_time kullanıyor ve adımı 1/30 saniyeyle sınırlıyor. Web sürümü aynı sınırı requestAnimationFrame üzerinde uyguluyor, bu sayede oyun 60 Hz ve 120 Hz ekranlarda aynı hızda akıyor. Shadow Lock'taki vuruş anı donması da aynen korundu: kilitte ve ıskalamada oyun kısa bir süre %5 hızla akıyor, sonra devam ediyor.
Ses ve dokunmatik giriş
GameMaker'ın Android çıktısında sesi ve dokunmayı motor hallediyor. Tarayıcıda ikisinin de kendine özgü kuralları var.
Ses: ilk dokunuşu bekleyin
Tarayıcılar kullanıcı bir yere dokunmadan ses çalmaya izin vermiyor. Bunu baştan planlamanız gerekiyor. Bu iki oyundaki düzen şöyle:
- Açılışta ses dosyaları
fetchileArrayBufferolarak indiriliyor ama çözülmüyor, çünkü çözmek için birAudioContextgerekiyor. - İlk dokunuşta ya da tuşa basışta
AudioContextoluşturuluyor ve bütün dosyalar çözülüyor. - Bu andan önce istenen müzik "bekleyen" olarak not ediliyor ve ses açılır açılmaz başlıyor.
audio_play_soundkarşılığı her çalışta yeni birAudioBufferSourceNode. PitchplaybackRate, gain ise araya bağlanan birGainNode.- Sekme arka plana geçince
visibilitychangeile ses bağlamı askıya alınıyor, geri gelince devam ediyor.
Dokunmatik giriş
GML'de mouse_check_button_pressed yalnızca basılan karede true döner. Oyun mantığı buna göre yazıldığı için web'de de aynı anlamı korudum: pressed ve released bayrakları olay geldiğinde açılıyor, karenin adım ve çizim işleri bitince sıfırlanıyor.
Olaylar için Pointer Events kullandım, fare, dokunma ve kalem aynı kodla çalışıyor. Birkaç ayrıntı fark yaratıyor:
- Aynı anda tek bir parmak takip ediliyor. İkinci parmak ilkinin çizgisini bozmasın diye yok sayılıyor.
setPointerCaptureile parmak tuvalin dışına kaysa bile çizim sürüyor.- Tuvale CSS'te
touch-action: noneveriliyor, sayfa kaymıyor ve yakınlaşmıyor.contextmenuolayı engelleniyor, uzun basınca menü açılmıyor. - İlk dokunuş aynı zamanda sesin kilidini açan hareket.
Testler neden tarayıcısız çalışıyor
Taşımanın bir yan kazancı da test edilebilirlik oldu. Çizim Gfx sınıfına, ses ve giriş kendi modüllerine ayrıldığı için oyun mantığı tarayıcı olmadan çalışabiliyor. Vitest, Inkway'de 22, Shadow Lock'ta 14 testi Node ortamında, tohumlu bir rastgele sayı üreteciyle koşturuyor. Shadow Lock testlerinden biri, şekil doğru açıya geldiğinde dokunan bir oyuncu gibi dört bölümü baştan sona bitirip skorun kurallara göre çıktığını doğruluyor.
Kendi GameMaker oyununuzu taşıyacaksanız önerim şu: önce çizim, ses ve giriş için GML'ye benzeyen ince bir katman yazın, sonra oyun kodunu olabildiğince mekanik biçimde çevirin. Bir şey farklı göründüğünde iki sürümü yan yana açıp karşılaştırabilmek her şeyi kolaylaştırıyor. Elinizde tarayıcıya taşınmayı bekleyen bir oyun varsa bana yazabilirsiniz.