كل لعبة — من ثعبان النيون إلى أكبر ألعاب العالم — تتكوّن من نفس الأجزاء: حالة تحفظ ما يجري، وحلقة تحدّثها، ورسم يُظهرها. هنا الشرح بالترتيب، مع الكود الفعلي.
1
الفكرة: ثلاثة أجزاء فقط
أي لعبة تُبنى مسطّحة، تتكوّن من ثلاثة أجزاء لا أكثر: الحالة (أين الثعبان، وين الأكل، شكد النقاط)، والحلقة (تحرّك اللعبة خطوة كل فترة زمنية)، والرسم (تحوّل الحالة إلى صورة على الشاشة). افهم هذي الثلاثة، وتفهم أي لعبة.
الملفات المستخدمة:
index.html — البنية: اللوحة، الأزرار، لوحة الأرقام.
styles.css — الشكل والألوان والحركات.
game.js — عقل اللعبة: الحالة + الحلقة + الرسم.
قاعدة ذهبية: لا ترسم أي شيء داخل دالة step(). الخطوة تغيّر الأرقام فقط، والرسم يقرأها. هذي الفصلة تجعل الكود سهل التصحيح.
2
الحالة: كل شي باللعبة أرقام
game.js
الثعبان قائمة من خلايا (كل خلية لها x و y على شبكة ٢٠×٢٠). الأكل نقطة واحدة. والباقي أرقام صغيرة. حالة اللعبة نفسها كلمة واحدة: idle أو running أو paused أو over.
let snake; // [{x, y}, {x, y}, ...] الرأس أول عنصر
let dir; // {x: 1, y: 0} يعني يتجه يمين
let queue; // أوامر الحركة المنتظرة
let food; // {x, y}
let score, best, speedKey;
let state; // idle | running | paused | over
let acc = 0; // الوقت المتراكم من آخر خطوة
let particles = [];
أهم فكرة: لا تعتمد على الشاشة لتخزين المعلومة. لو مُسحت الشاشة بالكامل، لازم تقدر تعيد رسم اللعبة كاملة من هذي المتغيّرات.
3
حلقة اللعبة والحركة الناعمة
game.js
المتصفح يناديك ~60 مرة في الثانية عبر requestAnimationFrame. لكن اللعبة نفسها ما تتحرك ٦٠ مرة بالثانية — تتحرك كل tickMs (مثلًا ١٢٠ مللي ثانية للسرعة العادية). نفصل الاثنين: نرسم ٦٠ مرة، ونحدّث الحالة بخطوات ثابتة.
function loop(now) {
const dt = Math.min(now - last, 100); // درع ضد قفزة الوقت
last = now;
update(dt); // ١) منطق اللعبة
draw(acc / tickMs); // ٢) الرسم + نسبة التقدّم
requestAnimationFrame(loop);
}
function update(dt) {
if (state !== 'running') return;
acc += dt;
while (acc >= tickMs) { acc -= tickMs; step(); }
}
ليش نمرّر acc / tickMs للرسم؟ لأن هذي النسبة (بين ٠ و ١) تخلينا نرسم الثعبان بين الخلية السابقة والحالية — فتطلع الحركة ناعمة بدل ما تقفز مربع مربع.
4
الإدخال: أمر واحد في كل مرة
game.js
الخطأ الشائع: تنفّذ أمر اللاعب فورًا داخل مستمع الكيبورد. النتيجة إن اللاعب يضغط بسرعة فيرجع الثعبان على نفسه ويموت بلا سبب مفهوم. الحل: طابور أوامر يُفرَّغ مرة واحدة عند كل خطوة.
const OPPOSITE = { up: 'down', down: 'up', left: 'right', right: 'left' };
let currentName = 'right';
function turn(name) {
if (OPPOSITE[name] === currentName) return; // ممنوع الرجوع
if (queue.length > 1) return; // أمرين معلّقين كحد أقصى
queue.push(name);
}
ونفس الدالة turn() تخدم كل المدخلات: الكيبورد والأسهم على الشاشة والسحب بالإصبع. لهذا تكون تجربة الجوال مطابقة للكمبيوتر.
كل خطوة تسأل ثلاثة أسئلة بالترتيب: وين راح يكون الرأس الجديد؟ هل خرج من الجدار؟ هل دخل على جسمه؟ وبعدها: هل أكل؟ لو أكل، ما نحذف الذيل فيطول الثعبان؛ لو ما أكل، نحذف الذيل فيتحرك.
لمسة احترافية: طول الثعبان = النقاط. اجعل score = (snake.length - 3) * 10 فلا تحتاج تخزين عدّاد منفصل قد يختلف عن الحقيقة.
6
الرسم على canvas
game.js
الرسم دائمًا بنفس الترتيب: امسح، ارسم الشبكة، ارسم الأكل، ارسم الثعبان، ارسم الجزيئات. والوحدة الأساسية هي cell = حجم اللوحة ÷ عدد الخلايا، فلا يهم مقاس الشاشة.
function draw(t) {
const size = canvas.clientWidth, cell = size / COLS;
ctx.clearRect(0, 0, size, size);
drawGrid(size, cell);
drawFood(cell);
drawSnake(cell, t); // t = نسبة التقدّم للحركة الناعمة
drawParticles();
}
التوهّج (glow) سطرين فقط، ويغيّر إحساس اللعبة كلّه:
ctx.shadowColor = '#22c55e';
ctx.shadowBlur = 18;
// ثم ارسم الدائرة... ولا تنسَ إرجاعه لصفر بعده
ولأن الشاشات عالية الدقة تحتاج ضبطًا، نكبير المخزن الداخلي للـ canvas حسب devicePixelRatio وإلا تطلع الحواف مغبّشة:
ما نحمّل أي mp3. نبني صوت "أركيد" من موجة مربّعة بسيطة عبر Web Audio API، وكل نغمة دالة من سطرين. الصوت يُبنى وقت التشغيل، فحجم اللعبة يبقى صفر بايت من الملفات.
let ac;
function beep(freq, dur, type = 'square') {
if (muted) return;
ac ??= new (window.AudioContext || window.webkitAudioContext)();
const o = ac.createOscillator(), g = ac.createGain();
o.type = type; o.frequency.value = freq;
g.gain.setValueAtTime(.05, ac.currentTime);
g.gain.exponentialRampToValueAtTime(.0001, ac.currentTime + dur);
o.connect(g).connect(ac.destination);
o.start(); o.stop(ac.currentTime + dur);
}
انتباه: المتصفحات تمنع تشغيل الصوت قبل تفاعل المستخدم. لهذا نبنيه فقط عند أول ضغطة زر — وهو ما يحدث طبيعيًا عند "ابدأ اللعب".
8
حفظ أفضل نتيجة
game.js
سطران يعطيان اللعبة ذاكرة: نخزّن أفضل نتيجة في localStorage فتبقى بعد إغلاق المتصفح. ونتعامل مع try/catch لأن الوضع الخاص (incognito) قد يرفض التخزين.