Canvas 2D Oyun Döngüsünün Temelleri
Oyun döngüsü, oyununuzun kalbidir. Sürekli çalışarak oyun durumunu günceller ve tarayıcının izin verdiği hızda kareleri çizer. Bunu doğru kurarsanız geri kalan her şey kolaylaşır. Yanlış kurarsanız yalnızca belirli cihazlarda ortaya çıkan hataların peşinden koşarsınız.
Deneyin (ok tuşlarını veya WASD'yi kullanın):
En Basit Sürüm
İşte yalnızca temel unsurlara indirgenmiş bir oyun döngüsü:
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')
function gameLoop() {
update()
render()
requestAnimationFrame(gameLoop)
}
function update() {
// Oyun mantığı buraya
}
function render() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
// Çizim işlemleri buraya
}
gameLoop()Bu çalışır, ancak bir sorunu vardır. update fonksiyonu, son kareden bu yana ne kadar zaman geçtiğini bilmez. 144 Hz'lik bir monitörde, 60 Hz'lik bir monitöre göre iki kat hızlı çalışır. Oyun fiziğiniz farklı cihazlarda farklı davranır.
Delta Time Kare Hızı Sorunlarını Giderir
Oyununuzun her yerde aynı hızda çalışmasını sağlamak için kareler arasında ne kadar zaman geçtiğini takip etmeniz gerekir. Buna delta time denir ve genellikle dt olarak kısaltılır.
let lastTime = 0
function gameLoop(currentTime) {
const dt = (currentTime - lastTime) / 1000 // saniyeye dönüştür
lastTime = currentTime
update(dt)
render()
requestAnimationFrame(gameLoop)
}
function update(dt) {
player.x += player.speed * dt // artık her kare hızında çalışır
}
requestAnimationFrame(gameLoop)requestAnimationFrame geri çağırma fonksiyonu, milisaniye cinsinden geçerli zaman damgasını alır. Matematiği kolaylaştırdığı için bunu 1000'e bölerek saniyeye dönüştürüyorum. 200 hız değeri, saniyede 200 piksel anlamına gelir; bu da anlaşılması kolaydır.
Dikkat edilmesi gereken bir nokta var: İlk karede lastTime 0 olduğundan dt çok büyük olur. Bazı geliştiriciler döngüyü başlatmadan önce lastTime değerini performance.now() ile ayarlar, bazılarıysa ilk karenin güncellemesini atlar.
Neden setInterval Yerine requestAnimationFrame?
Neden setInterval yerine requestAnimationFrame kullandığımızı merak edebilirsiniz. Bunun birkaç nedeni var.
İlk olarak requestAnimationFrame, ekranın yenileme hızıyla eşzamanlanır; böylece yırtılma olmadan akıcı animasyon elde edersiniz. İkinci olarak sekme gizlendiğinde otomatik biçimde duraklar, bu da pil ve işlemci kullanımını azaltır. Üçüncü olarak tarayıcı bunu daha iyi optimize edebilir.
Bu duraklama davranışı genellikle istediğiniz şeydir, ancak kullanıcı başka bir sekmeye geçtiğinde oyununuzun donduğu anlamına gelir. Çok oyunculu bir oyun veya çalışmaya devam etmesi gereken başka bir şey geliştiriyorsanız farklı bir yaklaşıma ihtiyacınız olur.
Fizik İçin Sabit Zaman Adımı
Değişken delta time basit hareketlerde işe yarar, ancak fizik simülasyonlarında sorunlara yol açar. Bir karenin işlenmesi çok uzun sürerse çarpışma kontrolü temas anını kaçırdığı için karakteriniz bir duvarın içinden geçebilir.
Çözüm, sabit zaman adımıdır. Geçen zamanı biriktirir ve karelerin ne kadar hızlı oluşturulduğundan bağımsız olarak fizik güncellemelerini sabit bir hızda çalıştırırsınız.
const FIXED_DT = 1 / 60 // saniyede 60 fizik güncellemesi
let accumulator = 0
let lastTime = 0
function gameLoop(currentTime) {
const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
lastTime = currentTime
accumulator += frameTime
while (accumulator >= FIXED_DT) {
update(FIXED_DT)
accumulator -= FIXED_DT
}
render()
requestAnimationFrame(gameLoop)
}Buradaki Math.min(..., 0.25) önemlidir. Sekme gizlenip yeniden açıldığında frameTime çok büyük olabilir. Bu sınır olmadan while döngüsü arayı kapatmak için yüzlerce güncelleme çalıştırarak oyunu dondurabilir. Değeri 0,25 saniyeyle sınırlamak, kare başına hiçbir zaman 15'ten fazla fizik güncellemesi çalıştırmayacağınız anlamına gelir.
Oyun Durumunu Düzenleme
Oyununuz büyüdükçe tüm verileri koyabileceğiniz bir yere ihtiyaç duyarsınız. Ben her şeyi barındıran tek bir nesne kullanmayı tercih ediyorum:
const game = {
state: 'playing', // 'menu', 'playing', 'paused', 'gameover'
score: 0,
player: {
x: 100,
y: 100,
vx: 0,
vy: 0,
speed: 200,
width: 32,
height: 32,
},
entities: [],
}Bu yaklaşım oyun durumunu kaydedip yüklemeyi, oyuncu öldüğünde sıfırlamayı ve game nesnesini konsola yazarak hata ayıklamayı kolaylaştırır. Bazı geliştiriciler sınıfları tercih eder, ancak sade bir nesne küçük ve orta ölçekli oyunlarda gayet iyi çalışır.
Hepsini Bir Araya Getirme
const canvas = document.getElementById('game')
const ctx = canvas.getContext('2d')
const FIXED_DT = 1 / 60
let accumulator = 0
let lastTime = 0
const input = { left: false, right: false, up: false, down: false }
const game = {
player: { x: 100, y: 100, speed: 200 },
}
function update(dt) {
const p = game.player
if (input.left) p.x -= p.speed * dt
if (input.right) p.x += p.speed * dt
if (input.up) p.y -= p.speed * dt
if (input.down) p.y += p.speed * dt
// Sınırlar içinde tut
p.x = Math.max(0, Math.min(canvas.width - 32, p.x))
p.y = Math.max(0, Math.min(canvas.height - 32, p.y))
}
function render() {
ctx.fillStyle = '#1a1a2e'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = '#4ade80'
ctx.fillRect(game.player.x, game.player.y, 32, 32)
}
function gameLoop(currentTime) {
const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
lastTime = currentTime
accumulator += frameTime
while (accumulator >= FIXED_DT) {
update(FIXED_DT)
accumulator -= FIXED_DT
}
render()
requestAnimationFrame(gameLoop)
}
// Girdi işleme
window.addEventListener('keydown', (e) => {
if (e.code === 'ArrowLeft' || e.code === 'KeyA') input.left = true
if (e.code === 'ArrowRight' || e.code === 'KeyD') input.right = true
if (e.code === 'ArrowUp' || e.code === 'KeyW') input.up = true
if (e.code === 'ArrowDown' || e.code === 'KeyS') input.down = true
})
window.addEventListener('keyup', (e) => {
if (e.code === 'ArrowLeft' || e.code === 'KeyA') input.left = false
if (e.code === 'ArrowRight' || e.code === 'KeyD') input.right = false
if (e.code === 'ArrowUp' || e.code === 'KeyW') input.up = false
if (e.code === 'ArrowDown' || e.code === 'KeyS') input.down = false
})
requestAnimationFrame(gameLoop)Bu, eksiksiz ve çalıştırılabilir bir oyun döngüsüdür. Oyuncu ok tuşları veya WASD ile hareket eder, sınırlar içinde kalır ve kare hızından bağımsız olarak her şey tutarlı bir hızda çalışır. Bunu herhangi bir Canvas 2D oyunu için başlangıç noktası olarak kopyalayabilirsiniz.
Girdi işlemenin oyun döngüsünden ayrı olduğuna dikkat edin. Olay dinleyicileri yalnızca bayrakları ayarlar, güncelleme fonksiyonuysa bu bayrakları okur. Bu ayrım önemlidir çünkü klavye olayları öngörülemeyen zamanlarda tetiklenir; oyun mantığınızın ise öngörülebilir bir hızda çalışmasını istersiniz.
Sprite Yükleme ve Çizme
const sprites = {}
async function loadSprite(name, url) {
return new Promise((resolve) => {
const img = new Image()
img.onload = () => {
sprites[name] = img
resolve(img)
}
img.src = url
})
}
function drawSprite(name, x, y, width, height) {
const sprite = sprites[name]
if (sprite) {
ctx.drawImage(sprite, x, y, width || sprite.width, height || sprite.height)
}
}Görseller eşzamansız yüklendiği için oyununuzu başlatmadan önce yüklenmelerini beklemeniz gerekir. Yaygın bir yaklaşım, tüm sprite'ları bir Promise döndüren loadAssets fonksiyonunda yüklemek ve oyun döngüsünü yalnızca bu Promise tamamlandıktan sonra başlatmaktır.
drawSprite fonksiyonu, çizmeden önce sprite'ın var olup olmadığını kontrol eder. Bu, henüz yüklenmemiş bir şeyi çizmeye çalıştığınızda hata oluşmasını önler.
Animasyonlu Sprite'lar
class AnimatedSprite {
constructor(image, frameWidth, frameHeight, frameCount, fps = 10) {
this.image = image
this.frameWidth = frameWidth
this.frameHeight = frameHeight
this.frameCount = frameCount
this.frameDuration = 1 / fps
this.currentFrame = 0
this.elapsed = 0
}
update(dt) {
this.elapsed += dt
if (this.elapsed >= this.frameDuration) {
this.currentFrame = (this.currentFrame + 1) % this.frameCount
this.elapsed = 0
}
}
draw(ctx, x, y) {
ctx.drawImage(
this.image,
this.currentFrame * this.frameWidth, 0,
this.frameWidth, this.frameHeight,
x, y,
this.frameWidth, this.frameHeight
)
}
}Bu sınıf, sprite sayfanızdaki karelerin tek bir satırda yatay olarak dizildiğini varsayar. Her kare aynı boyuttadır. update metodu geçen zamana göre kareler arasında ilerler; draw ise yalnızca geçerli kareyi kesip almak için drawImage fonksiyonunun dokuz argümanlı sürümünü kullanır.
Şöyle kullanabilirsiniz:
const playerWalk = new AnimatedSprite(walkImage, 32, 32, 4, 12)
// update içinde:
playerWalk.update(dt)
// render içinde:
playerWalk.draw(ctx, player.x, player.y)Çarpışma Algılama
function rectCollision(a, b) {
return (
a.x < b.x + b.width &&
a.x + a.width > b.x &&
a.y < b.y + b.height &&
a.y + a.height > b.y
)
}
function circleCollision(a, b) {
const dx = a.x - b.x
const dy = a.y - b.y
const distance = Math.sqrt(dx * dx + dy * dy)
return distance < a.radius + b.radius
}
// update içinde:
for (const enemy of game.enemies) {
if (rectCollision(game.player, enemy)) {
handleCollision(game.player, enemy)
}
}Dikdörtgen çarpışması (eksene hizalı sınırlayıcı kutu anlamına gelen AABB olarak da bilinir) en basit ve en hızlı seçenektir. Çoğu şey için bunu kullanın. Daire çarpışması, yuvarlak nesneler veya çarpışmaların daha toleranslı hissettirmesini istediğiniz durumlar için daha uygundur.
Az sayıda varlık için her çifti kontrol etmek yeterlidir. Yüzlerce varlığınız varsa uzamsal bölümleme (dörtlü ağaçlar veya bir ızgara) kullanmanız gerekir, ancak bu başka bir eğitimin konusu.
Basit Bir Kamera
const camera = {
x: 0,
y: 0,
follow(target) {
this.x = target.x - canvas.width / 2
this.y = target.y - canvas.height / 2
}
}
function render() {
ctx.save()
ctx.translate(-camera.x, -camera.y)
// Dünyayı çiz
drawWorld()
drawEntities()
ctx.restore()
// Arayüzü çiz (kameradan etkilenmez)
drawUI()
}İşin püf noktası ctx.save() ve ctx.restore() kullanmaktır. Geçerli dönüşüm durumunu kaydeder, translate ile kamera kaydırmasını uygular, dünya uzayındaki her şeyi çizer ve ardından arayüz öğelerinin kameradan etkilenmemesi için durumu geri yüklersiniz.
Daha yumuşak kamera hareketi için doğrudan hedefe sıçramak yerine hedefe doğru doğrusal enterpolasyon uygulayabilirsiniz. this.x += (targetX - this.x) * 0.1 gibi bir ifade, hoş bir takip gecikmesi efekti sağlar.
Oyunu Duraklatma
let paused = false
window.addEventListener('keydown', (e) => {
if (e.code === 'Escape') {
paused = !paused
}
})
function gameLoop(currentTime) {
const frameTime = Math.min((currentTime - lastTime) / 1000, 0.25)
lastTime = currentTime
if (!paused) {
accumulator += frameTime
while (accumulator >= FIXED_DT) {
update(FIXED_DT)
accumulator -= FIXED_DT
}
}
render()
if (paused) {
renderPauseOverlay()
}
requestAnimationFrame(gameLoop)
}
function renderPauseOverlay() {
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.fillStyle = '#fff'
ctx.font = '32px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('DURAKLATILDI', canvas.width / 2, canvas.height / 2)
}Temel fikir, oyun duraklatıldığında zaman biriktirmeyi bırakmanız ancak görüntü oluşturmaya devam etmenizdir. Böylece duraklatma katmanı hemen görünür ve oyun, devam ettirme girdisine yanıt vermeyi sürdürür.
Bu kalıbı menüler, ara sahneler ve oyun sonu ekranları gibi birden fazla oyun durumunu yönetmek üzere genişletebilirsiniz. Yalnızca geçerli durumu kontrol edin ve işlemi farklı güncelleme/görüntü oluşturma fonksiyonlarına yönlendirin.
Yaygın Hatalar
Geliştiricilerin sıkça takıldığını gördüğüm birkaç nokta:
Canvas'ı temizlemeyi unutmak. Görüntü oluşturmanın başında clearRect çağırmazsanız önceki karenin üzerine çizim yapar ve iz bırakma efektiyle karşılaşırsınız.
Delta time değerini sınırlamamak. Biri başka bir sekmeye geçip geri döndüğünde çok büyük bir delta time, fizik sisteminizi bozabilir veya oyunun saniyeler boyunca "arayı kapatmaya" çalışmasına neden olabilir.
Görseller yüklenmeden çizim yapmak. Canvas, henüz yüklenmemiş görsellerle yapılan drawImage çağrılarını sessizce yok sayar. Sprite'larınız herhangi bir hata mesajı olmadan görünmez.
Döngü için setInterval kullanmak. Ekranla eşzamanlanmaz ve sekme gizlendiğinde duraklamaz. requestAnimationFrame kullanın.
Dünya ve ekran koordinatlarını karıştırmak. Kamera eklediğinizde bir konumun dünya uzayında mı yoksa ekran uzayında mı olduğunu takip edin. Fare tıklamaları ekran uzayında gelir ve dönüştürülmeleri gerekir.
Sırada Ne Var?
Bu eğitim temel konuları kapsar. Bu kalıplara alıştıktan sonra şunları incelemek isteyebilirsiniz:
Farklı ekran boyutlarını ve yüksek DPI'lı ekranları yönetmek için Duyarlı oyun canvas'ı.
Dokunmatik kontroller, oyun kumandaları ve daha gelişmiş girdi sistemleri için Oyun girdisi işleme.
Bulanıklık olmadan net piksel grafikleri elde etmek için Piksel sanatı görüntü oluşturma.
Oyun döngünüze katı cisim fiziği, çarpışmalar ve eklemler eklemek için Oyun fiziği kütüphaneleri.
Projeniz için Canvas 2D, WebGL ve WebGPU arasında karar veriyorsanız 2026'da Web Oyunları Teknoloji Yığını rehberi, seçeneklerin avantaj ve dezavantajlarını açıklar. Web Oyun Motorları Karşılaştırması ise oyun döngüsünü sizin yerinize yöneten framework'leri ele alır.
Harici Kaynaklar
- MDN: requestAnimationFrame — tarayıcı API referansı
- MDN: Canvas API — eksiksiz Canvas 2D dokümantasyonu
- MDN: KeyboardEvent — tuş kodları ve olay özellikleri
- Zaman Adımınızı Düzeltin! — Glenn Fiedler'ın sabit zaman adımlı oyun döngüleri üzerine klasik makalesi