Giriş
Haziran 2010'da, yerel bir yayıncılık "zindanı" olan Boing Boing'un oyun geliştirme yarışması düzenlediği dikkatimizi çekti.
Bu durumu, JavaScript ve <canvas>
'te hızlı ve basit bir oyun geliştirmek için mükemmel bir fırsat olarak gördük ve hemen çalışmaya başladık. Yarışmadan sonra aklımızda hâlâ birçok fikir vardı ve başladığımız işi tamamlamak istedik. Sonuç olarak ortaya çıkan Onslaught! Arena.
Retro, pikselli görünüm
Chiptune'a dayalı bir oyun geliştirmeyi amaçlayan yarışma teması göz önüne alındığında, oyunumuzun retro bir Nintendo Entertainment System oyunu gibi görünmesi ve hissettirmesi önemliydi. Çoğu oyunda bu şart yoktur ancak öğe oluşturmanın kolaylığı ve nostaljik oyunculara doğal çekiciliği nedeniyle bu sanatsal stil, özellikle bağımsız geliştiriciler arasında yaygındır.
Bu sprite'ların ne kadar küçük olduğunu göz önünde bulundurarak piksellerimizi ikiye katlamaya karar verdik. Yani 16x16 boyutundaki bir sprite artık 32x32 piksel olacak. Başından beri, ağır işleri tarayıcıya yaptırmak yerine öğe oluşturma konusunda daha fazla çaba gösteriyorduk. Bu yöntemin uygulanması daha kolaydı ancak görünüm açısından da bazı avantajları vardı.
Göz önünde bulundurduğumuz bir senaryo:
<style>
canvas {
width: 640px;
height: 320px;
}
</style>
<canvas width="320" height="240">
Sorry, your browser is not supported.
</canvas>
Bu yöntem, öğe oluşturma tarafında sprite'ları ikiye katlamak yerine 1x1 sprite'lardan oluşur. Ardından CSS devreye girer ve kanvası yeniden boyutlandırır. Karşılaştırmalarımız, bu yöntemin daha büyük (iki katı) resimleri oluşturmaktan yaklaşık iki kat daha hızlı olabileceğini gösterdi ancak maalesef CSS yeniden boyutlandırma işlemi, kenarlık yumuşatma içeriyor. Bu sorunu önlemenin bir yolunu bulamadık.
Tek tek pikseller çok önemli olduğundan bu durum oyunumuz için önemli bir sorundu. Ancak tuvalinizi yeniden boyutlandırmanız gerekiyorsa ve projeniz için kenar yumuşatma uygunsa performans nedeniyle bu yaklaşımı kullanabilirsiniz.
Eğlenceli kanvas numaraları
<canvas>
'ün yeni trend olduğunu hepimiz biliyoruz ancak bazen geliştiriciler DOM'u kullanmayı hâlâ öneriyor. Hangisini kullanacağınıza karar veremiyorsanız <canvas>
'ün bize nasıl çok zaman ve enerji kazandırdığını gösteren bir örneği aşağıda bulabilirsiniz.
Saldırı! Arena, kırmızı renkte yanıp söner ve kısa bir süreliğine "ağrı" animasyonu gösterir. Oluşturmamız gereken grafik sayısını sınırlandırmak için "acı çeken" düşmanları yalnızca aşağı bakan şekilde gösteriyoruz. Bu, oyun içinde kabul edilebilir görünüyor ve sprite oluşturma konusunda çok zaman kazandırdı. Ancak patron canavarlarda, büyük bir sprite'ın (64x64 piksel veya daha büyük) acı karesi için aniden sola veya yukarı baktığından aşağı bakmaya başlaması can sıkıcıydı.
Bunun için en kolay çözüm, her patron için sekiz yönde birer acı çerçevesi çizmektir. Ancak bu çok zaman alıcı bir işlem olur. <canvas>
sayesinde koddaki bu sorunu çözebildik:
Önce canavarı gizli bir "arabelleğe" <canvas>
çizeriz, üzerine kırmızı renk uygularız ve sonucu ekrana geri oluştururuz. Kod şu şekilde görünür:
// Get the "buffer" canvas (that isn't visible to the user)
var bufferCanvas = document.getElementById("buffer");
var buffer = bufferCanvas.getContext("2d");
// Draw your image on the buffer
buffer.drawImage(image, 0, 0);
// Draw a rectangle over the image using a nice translucent overlay
buffer.save();
buffer.globalCompositeOperation = "source-in";
buffer.fillStyle = "rgba(186, 51, 35, 0.6)"; // red
buffer.fillRect(0, 0, image.width, image.height);
buffer.restore();
// Copy the buffer onto the visible canvas
document.getElementById("stage").getContext("2d").drawImage(bufferCanvas, x, y);
Oyun Döngüsü
Oyun geliştirme, web geliştirmeden bazı önemli yönleriyle farklıdır. Web yığınında, gerçekleşen etkinliklere etkinlik işleyiciler aracılığıyla tepki vermek yaygındır. Bu nedenle, başlatma kodu giriş etkinliklerini dinlemekten başka bir şey yapamaz. Oyunların mantığı farklıdır çünkü sürekli olarak güncellenmesi gerekir. Örneğin, bir oyuncu hareket etmemiş olsa bile goblinler onu yakalayabilir.
Oyun döngüsü örneğini aşağıda görebilirsiniz:
function main () {
handleInput();
update();
render();
};
setInterval(main, 1);
İlk önemli fark, handleInput
işlevinin aslında hemen bir şey yapmamasıdır. Kullanıcı, tipik bir web uygulamasında bir tuşa basarsa istenen işlemin hemen yapılması mantıklıdır. Ancak bir oyunda, olayların doğru şekilde ilerlemesi için kronolojik sıraya göre gerçekleşmesi gerekir.
window.addEventListener("mousedown", function(e) {
// A mouse click means the players wants to attack.
// We don't actually do that yet, but instead tell the rest
// of the program about the request.
buttonStates[e.button] = true;
}, false);
function handleInput() {
// Here is where we respond to the click
if (buttonStates[LEFT_BUTTON]) {
player.attacking = true;
delete buttonStates[LEFT_BUTTON];
}
};
Artık girişi biliyoruz ve oyun kurallarının geri kalanına uyacağını bilerek update
işlevinde bunu dikkate alabiliriz.
function update() {
// Check for collisions, states, whatever else is needed
// If after that the player can still attack, do it!
if (player.attacking && player.canAttack()) {
player.attack();
}
};
Son olarak, tüm hesaplamalar yapıldıktan sonra ekranı yeniden çizme zamanı gelmiştir.
DOM'da bu ağır işi tarayıcı üstlenir. Ancak <canvas>
kullanırken bir şey olduğunda (genellikle her karede) manuel olarak yeniden çizmeniz gerekir.
function render() {
// First erase everything, something like:
context.clearRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Draw the player (and whatever else you need)
context.drawImage(
player.getImage(),
player.x, player.y
);
};
Zamana Dayalı Modelleme
Zamana dayalı modelleme, sprite'ların son kare güncellemesinden bu yana geçen süreye göre hareket ettirilmesi kavramıdır. Bu teknik, sprite'ların tutarlı hızlarda hareket etmesini sağlarken oyununuzun olabildiğince hızlı çalışmasını sağlar.
Zamana dayalı modelleme kullanmak için son karenin çizilmesinden bu yana geçen süreyi yakalamamız gerekir. Bunu izlemek için oyun döngümüzün update()
işlevini genişletmemiz gerekir.
function update() {
// NOTE: You'll need to initially seed this.lastUpdate
// with the current time when your game loop starts
// this.lastUpdate = Date.now();
// Calculate elapsed time since last frame
var now = Date.now();
var elapsed = (now - this.lastUpdate);
this.lastUpdate = now;
// Do stuff with elapsed
};
Geçen süreyi bildiğimize göre, belirli bir sprite'ın her karede ne kadar hareket etmesi gerektiğini hesaplayabiliriz. Öncelikle sprite nesnesinde birkaç şeyi takip etmemiz gerekir: Mevcut konum, hız ve yön.
var Sprite = function() {
// The sprite's position relative to the top left of the game world
this.position = {x: 0, y: 0};
// The sprite's direction. A positive x value indicates moving to the right
this.direction = {x: 1, y: 0};
// How many pixels the sprite moves per second
this.speed = 50;
};
Bu değişkenleri göz önünde bulundurarak, zamana dayalı modelleme kullanarak yukarıdaki sprite sınıfının bir örneğini nasıl taşıyacağımızı aşağıda görebilirsiniz:
// Determine how far this sprite will move this frame
var distance = (sprite.speed / 1000) * elapsed;
// Apply the movement distance to the sprite's current position
// taking into account its direction
sprite.position.x += (distance * sprite.direction.x);
sprite.position.y += (distance * sprite.direction.y);
direction.x
ve direction.y
değerlerinin normalleştirilmesi gerektiğini unutmayın. Yani bu değerlerin her zaman -1
ile 1
arasında olması gerekir.
Denetimler
Kontroller, Onslaught'u geliştirirken karşılaştığımız en büyük engellerden biri oldu. Arena. İlk demoda yalnızca klavye destekleniyordu. Oyuncular ana karakteri ok tuşlarıyla ekranda hareket ettirip boşluk tuşuyla baktığı yöne ateş ediyordu. Bu sistem biraz sezgisel ve kolay anlaşılır olsa da daha zor seviyelerde oyunu neredeyse oynanamaz hale getiriyordu. Herhangi bir zamanda oyuncuya doğru düzinelerce düşman ve mermi uçarken, her yöne ateş ederken aynı zamanda kötü adamların arasından sıyrılmak çok önemlidir.
Türündeki benzer oyunlarla karşılaştırabilmek için, karakterin saldırılarını hedeflemek için kullanacağı bir hedefleme nişangahını kontrol etmek üzere fare desteği ekledik. Karakter klavyeyle taşınmaya devam edebiliyordu ancak bu değişiklikten sonra aynı anda 360 derecelik herhangi bir yönde ateş edebiliyordu. Bu özellik, sıkı oyuncular tarafından takdir edildi ancak dokunmatik yüzey kullanıcılarını hayal kırıklığına uğratması gibi talihsiz bir yan etkisi de vardı.
Dokunmatik yüzey kullanıcılarına hitap etmek için ok tuşu denetimlerini geri getirdik. Bu kez, basılan yönlerde ateş edilmesine izin veriyoruz. Her türden oyuncuya hitap ettiğimizi düşünüyorduk ancak farkında olmadan oyunumuza çok fazla karmaşıklık ekliyorduk. Daha sonra, çoğu zaman göz ardı edilen eğitim modüllerine rağmen bazı oyuncuların saldırmak için isteğe bağlı fare (veya klavye) kontrollerinden haberdar olmadığını öğrendiğimizde şaşırdık.
Avrupa'da da birçok takipçimiz var. Ancak bu takipçilerimizden, tipik QWERTY klavyelere sahip olmadıkları ve WASD tuşlarını kullanarak yön tuşlarını kullanamadıkları için hayal kırıklığına uğradıklarını duyuyoruz. Sol elini kullanan oyuncular da benzer şikayetlerde bulundu.
Uyguladığımız bu karmaşık kontrol şemasında, mobil cihazlarda oynama sorunu da var. En sık aldığımız isteklerden biri de Onslaught'u yeniden yayınlamamız yönünde. Android, iPad ve diğer dokunmatik cihazlarda (klavye bulunmayan) kullanılabilen Arena. HTML5'in temel güçlü yönlerinden biri taşınabilirliğidir. Bu nedenle, oyunu bu cihazlara taşımak kesinlikle mümkündür. Bunun için birçok sorunu (özellikle de kontroller ve performans) çözmemiz yeterlidir.
Bu sorunların çoğunu gidermek için yalnızca fare (veya dokunma) etkileşimini içeren tek girişli bir oyun oynama yöntemiyle oynamaya başladık. Oyuncular ekranı tıkladığında veya ekrana dokunduğunda ana karakter, basılan konuma doğru yürür ve en yakın kötü adama otomatik olarak saldırır. Kod şu şekilde görünür:
// Find the nearest hostile target (if any) to the player
var player = this.getPlayerObject();
var hostile = this.getNearestHostile(player);
if (hostile !== null) {
// Found one! Shoot in its direction
var shoot = hostile.boundingBox().center().subtract(
player.boundingBox().center()
).normalize();
}
// Move towards where the player clicked/touched
var move = this.targetReticle.position.clone().subtract(
player.boundingBox().center()
).normalize();
var distance = this.targetReticle.position.clone().subtract(
player.boundingBox().center()
).magnitude();
// Prevent jittering if the character is close enough
if (distance < 3) {
move.zero();
}
// Move the player
if ((move.x !== 0) || (move.y !== 0)) {
player.setDirection(move);
}
Düşmanları nişan alma zorunluluğunun kaldırılması, bazı durumlarda oyunu kolaylaştırabilir ancak oyuncu için işleri basitleştirmenin birçok avantajı olduğunu düşünüyoruz. Karakteri hedeflemek için tehlikeli düşmanlara yakın konumlandırmak gibi başka stratejiler de ortaya çıkar. Dokunmatik cihazları destekleme özelliğinin değeri de paha biçilmez.
Ses
Onslaught'u geliştirirken kontrol ve performansın yanı sıra en büyük sorunlarımızdan biri de Arena, HTML5'in <audio>
etiketiydi.
Muhtemelen en kötü yönü gecikmesidir: Neredeyse tüm tarayıcılarda .play()
çağrısı ile sesin çalınması arasında bir gecikme vardır. Bu durum, özellikle bizimki gibi hızlı tempolu bir oyun oynarken oyuncuların deneyimini olumsuz yönde etkileyebilir.
Diğer sorunlar arasında "progress" etkinliğinin tetiklenememesi de yer alır. Bu durum, oyunun yükleme akışının süresiz olarak askıya alınmasına neden olabilir. Bu nedenlerle, Flash yüklenemezse HTML5 Audio'ya geçiş yaptığımız "başka bir yönteme geçme" olarak adlandırdığımız bir yöntemi benimsedik. Kod şu şekilde görünür:
/*
This example uses the SoundManager 2 library by Scott Schiller:
http://www.schillmania.com/projects/soundmanager2/
*/
// Default to sm2 (Flash)
var api = "sm2";
function initAudio (callback) {
switch (api) {
case "sm2":
soundManager.onerror = (function (init) {
return function () {
api = "html5";
init(callback);
};
}(arguments.callee));
break;
case "html5":
var audio = document.createElement("audio");
if (
audio
&& audio.canPlayType
&& audio.canPlayType("audio/mpeg;")
) {
callback();
} else {
// No audio support :(
}
break;
}
};
Bir oyunun, MP3 dosyalarını çalmayan tarayıcıları (Mozilla Firefox gibi) desteklemesi de önemli olabilir. Bu durumda destek algılanabilir ve şu kodla Ogg Vorbis gibi bir codec'e geçirilebilir:
/*
Note: you could instead use "new Audio()" here,
but the client will throw an error if it doesn't support Audio,
which makes using "document.createElement" a safer approach.
*/
var audio = document.createElement("audio");
if (audio && audio.canPlayType) {
if (!audio.canPlayType("audio/mpeg;")) {
// Here you know you CANNOT use .mp3 files
if (audio.canPlayType("audio/ogg; codecs=vorbis")) {
// Here you know you CAN use .ogg files
}
}
}
Verileri kaydetme
Yüksek puanlar olmadan atari tarzı bir nişancı oyunu olamaz. Oyun verilerinizin bir kısmının kalıcı olması gerektiğini biliyorduk. Çerezler gibi eski bir yöntem kullanabilirdik ancak eğlenceli yeni HTML5 teknolojilerini keşfetmek istedik. Yerel depolama, oturum depolama ve Web SQL veritabanları dahil olmak üzere seçeneklerden kesinlikle yoksun değilsiniz.
Yeni, harika ve kullanımı kolay olduğu için localStorage
'ü kullanmaya karar verdik. Basit oyunumuz için gereken temel anahtar/değer çiftlerinin kaydedilmesini destekler. Aşağıda, bu özelliğin nasıl kullanılacağına dair basit bir örnek verilmiştir:
if (typeof localStorage == "object") {
localStorage.setItem("foo", "bar");
localStorage.getItem("foo"); // Value is "bar"
localStorage.removeItem("foo");
localStorage.getItem("foo"); // Value is now null
}
Dikkat etmeniz gereken bazı "tuzaklar" vardır. Ne ilettiğiniz fark etmeksizin değerler dize olarak depolanır. Bu durum, bazı beklenmedik sonuçlara yol açabilir:
localStorage.setItem("foo", false);
typeof localStorage.getItem("foo"); // Value is "false" (a string literal)
if (localStorage.getItem("foo")) {
// It's true!
}
// Don't pass objects into setItem
localStorage.setItem("bar", {"key": "value"});
localStorage.getItem("bar"); // Value is "[object Object]" (a string literal)
// JSON stringify and parse when dealing with localStorage
localStorage.setItem("json", JSON.stringify({"key": "value"}));
typeof localStorage.getItem("json"); // string
JSON.parse(localStorage.getItem("json")); // {"key": "value"}
Özet
HTML5 ile çalışmak harika. Çoğu uygulama, grafiklerden oyun durumunu kaydetmeye kadar oyun geliştiricilerin ihtiyaç duyduğu her şeyi yönetir. Büyüme sürecinde bazı sorunlar (ör. <audio>
etiketi sorunları) yaşansa da tarayıcı geliştiricileri hızlı bir şekilde ilerliyor. Şu anda bile çok iyi olan bu durum, HTML5'te oluşturulan oyunlar için gelecekte daha da parlak bir tablonun önünü açıyor.